fix(realtime): één gedeelde LISTEN-connectie per kanaal i.p.v. één per SSE-stream #172
No reviewers
Labels
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
janpeter/Scrum4Me!172
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/realtime-listen-multiplex"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Waarom
Alle zes
/api/realtime-routes luisteren op hetzelfde kanaalscrum4me_changes, maar openden elk een eigenpg.Clientper open SSE-stream. Het aantal Postgres-connecties schaalde daardoor lineair mee met het aantal open browser-streams.Gemeten op prod 2026-08-16: 35 connecties uit één web-proces, allemaal
LISTENen nul gepoolde. Samen met remote clients liep de gedeelde 100-slots server die dag twee keer helemaal vol, mettoo many clientstot gevolg — ook voor de queue.De pool-fix van #171 raakte dit niet. Die werkt aantoonbaar (de app houdt nu nul gepoolde connecties), maar de pool was de consument niet. Dit is een andere fout-klasse in dezelfde storing.
Waarom de timeouts het ook niet vangen: een
LISTEN-sessie wordt actief levend gehouden en haalt nooit deidle_session_timeout.Wat deze PR doet
lib/realtime/notify-hub.tshoudt één connectie per kanaal aan en deelt notificaties in-process uit aan alle abonnees. 27 open streams worden zo 1 connectie.Twee dingen die deze module moet kloppen, beide uit eerdere schade geleerd:
globalThis. Next bundelt servercode per route-chunk, dus gewone module-state zou alsnog één listener per route-bundle opleveren in plaats van één per proces — precies de fout dielib/prisma.tseen pool per module-scope liet openen.idle_session_timeoutaan; zonder periodieke no-op wordt een stilleLISTEN-connectie server-side weggesweept.Bij verbindingsverlies krijgt elke abonnee
onErroren wordt losgekoppeld; de SSE-route sluit dan zijn stream en deEventSourcevan de browser herverbindt. Dat is exact het gedrag dat de losse clients al hadden — alleen nu gedeeld. Het kanaal wordt gevalideerd tegen een identifier-patroon, want het gaat geïnterpoleerd deLISTENin.Meegenomen, beide gevonden tijdens dezelfde diagnose
Ik heb deze twee erbij gedaan omdat een PR die de connectie-explosie aanpakt en ze bewust laat liggen maar 27 van de 35 connecties dekt.
lib/hub/queue-server.tskreeg een expliciete pool-maxvia de bestaandepoolMaxFromUrl. Zonder die waarde valt node-postgres terug op 10 en negeert het deconnection_limituit de URL — dezelfde fout-klasse als #171, in een tweede bestand.startQueueListenervervingqueueListenerzónder de vorige te sluiten, en eenpg.Clientkan zijnerrorméér dan eens emitten. Eén storing spawnde daardoor meerdere listeners: er stonden er acht tegelijk opagent_queue. Nu wordt de voorganger gesloten en bewaakt een generatieteller de herstart.Verificatie
solo-stream,notifications-stream) blijven groen — 6/6.npm run typecheck: 296 fouten mét én zónder deze wijziging. Met regelnummers genormaliseerd zijn de twee foutverzamelingen identiek; de wijziging voegt er geen enkele toe. (Die 296 zijn pre-existing in de dev-clone door een stale generated client / dirtyvendor/scrum4me-shared.)npm test: 17 falende regels mét én zónder de wijziging, niets nieuw en niets stilletjes opgelost.npx eslintop alle tien gewijzigde bestanden: exit 0.Wat dit niet oplost
De hub-question-listener in
runtime-server.tsgebruikt nog een eigenClientvoorscrum4me_changes. Dat is één langlevende connectie, geen per-request lek, dus die heb ik bewust laten staan. Wel heeft hij geen keepalive: metidle_session_timeoutactief wordt hij elke ~15 minuten weggesweept en herverbindt hij via zijn eigenon('end')-pad. Functioneel zelfherstellend, maar het is onnodige churn — kandidaat voor een volgende PR.En de tweede helft van de storing zit niet in deze repo: ~45 van de connecties komen van remote clients over Tailscale (MCP-clients die met
wait_for_jobop hetzelfde kanaal luisteren). Die dragen geenapplication_name, waardoor ze alleen via NAT-forensiek toewijsbaar zijn.Verdict: REQUEST_CHANGES
geen gekoppeld plan gevonden — beoordeeld op codekwaliteit + product-standaarden.
Findings
lib/realtime/notify-hub.ts:115: alsclient.connect()slaagt maarclient.query(\LISTEN ${channel}`)faalt, wordt de net geopende pg-client nergens geregistreerd en ook niet gesloten. DesubscribeToChannel-catch verwijdert alleen de subscriber;teardown()zietstate.client === nullen kan deze client dus niet opruimen. Bij tijdelijke LISTEN/query-fouten kan dit alsnog DB-connecties laten hangen, precies in het realtime-connection pad dat deze PR wil stabiliseren. Zet de client vóór de LISTEN in state of sluit hem inconnect()in een catch/finally voordat de fout doorgegeven wordt, en test datend()/closePgClientSafely` wordt aangeroepen bij een LISTEN-fout na succesvolle connect.Opmerkingen
De nieuwe gedeelde channel-hub en de route-fanout behouden het bestaande server-side filtercontract uit de realtime-docs, en de toegevoegde tests dekken de normale fanout, reconnect en channel-validatie goed. De startup-cleanup-case mist nog dekking.