perf(api): replace SSE polling with shared EventEmitter (ENG-1669) - #430
Conversation
- New core/task-event-bus.ts: shared EventEmitter broadcasting persisted task events in-process (setMaxListeners(0) for many SSE connections) - db.createTaskEvent now emits each event on the bus (with a light task-status lookup, only when listeners exist) preserving RML-716 - GET /api/logs/stream: removed the 2s-per-connection DB polling loop; now push-based via bus subscription, with a one-time catch-up query honoring Last-Event-ID/cursor and dedupe guards for the overlap window - keep-alive ping and abort cleanup preserved; unit tests added
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3cee88b272
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // One-time catch-up for events missed since Last-Event-ID/cursor | ||
| // (also covers events written between connect and subscribe). | ||
| try { | ||
| const missed = await db.getRecentTaskEvents(lastCursor, taskId); |
There was a problem hiding this comment.
Drain every page during catch-up
getRecentTaskEvents defaults to 50 rows, but the new implementation performs this catch-up only once. When a new or reconnecting client is more than 50 events behind, it receives only the first page; rows 51 onward were persisted before the bus subscription and therefore will never be emitted live. The previous polling loop advanced the cursor through successive pages, so the catch-up must continue until fewer than 50 rows are returned.
Useful? React with 👍 / 👎.
| event.createdAt.getTime() < lastCursor.createdAt.getTime() || | ||
| (event.createdAt.getTime() === lastCursor.createdAt.getTime() && | ||
| event.id <= lastCursor.id) |
There was a problem hiding this comment.
Buffer live events until catch-up completes
If a live event is emitted while the catch-up query is pending, sendEvent advances lastCursor to that new event. The catch-up loop then treats every older returned row as already delivered and skips it, even though those rows were never sent on this connection. This loses backlog whenever work completes during connection setup; live events need to be buffered and merged with the catch-up results rather than advancing the shared cursor immediately.
Useful? React with 👍 / 👎.
| event.createdAt.getTime() < lastCursor.createdAt.getTime() || | ||
| (event.createdAt.getTime() === lastCursor.createdAt.getTime() && | ||
| event.id <= lastCursor.id) |
There was a problem hiding this comment.
Preserve live events emitted out of database order
For concurrent createTaskEvent calls, each insert is followed by an independent awaited status query before emission, so a later database row can be emitted before an earlier one. Once the later event advances lastCursor, this comparison rejects the earlier event as already delivered even though it was never sent. This can silently omit events from the unfiltered stream under normal concurrent task processing; deduplication should track delivered IDs or the bus must guarantee database ordering.
Useful? React with 👍 / 👎.
| @@ -4351,7 +4379,7 @@ route("GET", "/api/logs/stream", async (req) => { | |||
| // Cleanup on close (handled by abort signal) | |||
| req.signal?.addEventListener("abort", () => { | |||
There was a problem hiding this comment.
Register abort cleanup before awaiting catch-up
The abort handler is attached only after the catch-up database request completes. If the client disconnects while that request is pending, the abort event has already fired by the time this listener is registered, so the bus subscription remains installed and the subsequently created keep-alive timer continues targeting a dead stream. Register cleanup before the await and also handle an already-aborted signal.
Useful? React with 👍 / 👎.
| * (GET /api/logs/stream). `db.createTaskEvent` emits every persisted | ||
| * event here; SSE connections subscribe instead of polling. |
There was a problem hiding this comment.
Route every task-event insert through the event bus
Not every persisted event uses db.createTaskEvent: for example, the stale-task cleanup endpoint inserts STALE_CLEANUP directly in router.ts:1249-1262, and the prompt optimizer also inserts directly. Because polling has been removed, active SSE clients will never receive those events. Either centralize all inserts through the broadcasting method or use a database-level notification mechanism that observes every insert.
Useful? React with 👍 / 👎.
| const [task] = await sql` | ||
| SELECT status FROM tasks WHERE id = ${mapped.taskId} | ||
| `; |
There was a problem hiding this comment.
Emit the persisted event when status enrichment fails
If the post-insert status lookup fails transiently, control jumps to the catch block without calling emitTaskEvent. The event is already committed, and connected clients no longer poll after initial catch-up, so they permanently miss it. Treat only the status as best-effort by emitting mapped without taskStatus when enrichment fails, or provide another delivery/retry path.
Useful? React with 👍 / 👎.
limaronaldo
left a comment
There was a problem hiding this comment.
Review: ENG-1669 — replace SSE polling with shared EventEmitter
Verdict: REQUEST CHANGES (FAIL) — a confirmed BLOCKER data-loss race plus a second confirmed BLOCKER (backlog >50 events silently dropped on reconnect) make this unsafe to ship as-is. Findings below combine manual analysis with an independent codex exec (gpt-5.6-terra, medium reasoning, read-only) cross-check on the same diff; both reviewers independently converged on the core race.
Process note: codex exec was invoked with --skip-git-repo-check (required outside a trusted git root) and completed within budget (~9 min wall including one retry after a missing-flag failure; findings below are from the successful run, corroborated point-by-point against the diff before inclusion).
Findings
| # | Severity | File:Line | Finding |
|---|---|---|---|
| 1 | BLOCKER | packages/api/src/router.ts:4340-4368 (live subscriber vs. catch-up loop, diff hunk ~198-268) |
Silent permanent event loss via shared mutable cursor race. lastCursor is written by both the live taskEventBus.onTaskEvent handler and the catch-up loop over db.getRecentTaskEvents(lastCursor, taskId). If a live event with a later timestamp is delivered while the catch-up loop still has earlier, not-yet-sent backlog items pending, the live handler advances lastCursor past them; when the catch-up loop resumes, its own dedupe check (event.createdAt < lastCursor.createdAt, hunk lines ~254-259) now evaluates true for those earlier events and continues past them without sending. There is no requeue/backfill — the event is gone forever, and no future catch-up can recover it because the cursor has already passed its timestamp. This is a genuine violation of the "no lost events" requirement the polling-replacement was supposed to preserve; polling was not perfectly ordered either, but it never permanently dropped a persisted row. Traced concretely: lastCursor=T0 → catch-up query in flight (WHERE created_at > T0) captures E_a(T1), E_b(T2) → live event E_c(T2.5) arrives and is delivered first (sendEvent sets lastCursor=T2.5) → catch-up loop resumes, sends E_a, then evaluates E_b(T2) against lastCursor=T2.5 → T2 < T2.5 → skipped. E_b is never delivered. |
| 2 | BLOCKER | packages/api/src/router.ts:4350 (catch-up call site) + packages/api/src/integrations/db.ts:415-421 (getRecentTaskEvents default limit: number = 50) |
Catch-up silently truncates backlogs >50 events. The router calls db.getRecentTaskEvents(lastCursor, taskId) with no limit argument, so it always uses the default of 50 and does not page. The old setInterval polling loop ran this same query every 2s, so a large backlog simply spread across multiple polls and eventually drained. With the one-shot catch-up model, any client that reconnects (or first connects) with more than 50 missed/backlogged events permanently loses everything past the first 50 — there is no follow-up fetch, and the live subscription only delivers events emitted after subscribe, not the truncated remainder. This is a straightforward, reproducible regression versus old polling semantics, not just a race-timing edge case. |
| 3 | HIGH | packages/api/src/integrations/db.ts:326-336 (catch (err) { console.error(...) } around the status-lookup + emit) |
Best-effort catch swallows delivery, not just enrichment. The comment says "Broadcasting is best-effort; never fail the write because of it" — correct not to fail the write. But the try block wraps both the SELECT status lookup and taskEventBus.emitTaskEvent(...). If the status query throws (e.g. transient pool/timeout), the event is never emitted at all — under polling, the client would still see this event on the next poll since it's already persisted; under push-only delivery, a transient status-lookup failure now means silent, permanent non-delivery to all currently-connected clients (recoverable only if they later reconnect within the 50-row/backlog window, per finding #2). The status enrichment should be best-effort; the broadcast itself should not be coupled to it — e.g. emit with taskStatus: undefined on lookup failure rather than skipping emit entirely. |
| 4 | HIGH | packages/api/src/router.ts:4340 (taskEventBus.onTaskEvent subscribe) vs. packages/api/src/router.ts:4379-4384 (abort listener registration) |
Listener/keep-alive registered before cleanup handler exists; disconnect during the await catch-up window leaks. The live listener is subscribed synchronously, then the code awaits db.getRecentTaskEvents(...) for the one-shot catch-up, and only after that resolves does execution reach the req.signal.addEventListener("abort", ...) registration that wires unsubscribe() + clearInterval(keepAlive) + controller.close(). If the client disconnects while the catch-up query is in flight, the abort event fires on req.signal before the listener is attached (or is simply missed if it already fired) — isActive never flips to false via the intended path, the EventEmitter listener is never removed, and later taskEventBus.emitTaskEvent(...) calls will keep invoking a callback bound to a dead connection, whose sendEvent() calls controller.enqueue() on an already-closed/errored controller (unhandled per-call error, only caught by the inner try/catch around sendEvent, which logs and moves on rather than actually cleaning up). Net effect: a slow catch-up query + a fast disconnect is a reliable way to leak a listener that fires (and errors) for the lifetime of the process. taskEventBus.setMaxListeners(0) (task-event-bus.ts:37) removes Node's default-10 warning that would otherwise have surfaced exactly this class of leak in logs/tests. |
| 5 | MEDIUM | packages/api/src/router.ts:4295-4309 (controller.enqueue in sendEvent) |
No backpressure/queue bound on the shared push path. Every broadcast event is synchronously enqueue()'d into every subscribed connection's stream with no check of controller.desiredSize and no bound on a slow/stalled client's internal queue. Under the old polling model, a slow client just meant the next poll's batch grew (bounded partly by wall-clock interval); under push, a burst of task events (many agents completing concurrently) enqueues into every open SSE connection unconditionally, and a client that isn't reading fast enough (or is on a bad network) has no backpressure signal — memory grows unbounded until the socket actually closes/errors. Not a data-loss bug on its own, but a resource-exhaustion risk under load that polling structurally avoided. |
| 6 | MEDIUM | packages/api/src/router.ts (GET /api/logs/stream, whole handler) — no cancel() on the ReadableStream |
No ReadableStream.cancel() handler; cleanup depends entirely on req.signal's abort event. This is unchanged from the pre-PR polling code (same gap existed there), but the failure mode is materially worse now: previously a missed abort left a bounded setInterval polling loop running (self-limiting, one DB query per 2s, and per-connection). Now a missed abort leaves a permanent EventEmitter listener registered on a shared, process-wide bus that fires on every future task event system-wide, for the life of the process, invoking controller.enqueue() against a closed controller each time (see finding #4). Given the PR explicitly changes the leak's blast radius (per-connection bounded timer → shared unbounded emitter), it should also add the cancel() handler as part of this change, not defer it. |
| 7 | LOW | packages/api/src/core/task-event-bus.test.ts (all 3 tests) |
Test coverage gap: only the standalone TaskEventBus class is unit-tested; the SSE route handler itself (where every finding above lives) has zero test coverage. The 3 tests confirm subscribe/unsubscribe/50-concurrent-listeners work in isolation, which is useful but does not exercise: subscribe-then-catch-up ordering/race (finding #1), backlog >50 (finding #2), status-lookup failure during broadcast (finding #3), abort-during-await (finding #4), or backpressure (finding #5). Given the bulk of the risk is in router.ts's integration of the bus with the stream lifecycle, not in the bus primitive itself, this test suite provides limited confidence for the actual regression surface. |
Confirmed non-issues (checked, ruled out)
db.ts:315listenerCountTaskEvent > 0gate racing with task completion — the listener-count check is synchronous (noawaitbetween the check and either taking or skipping the broadcast path), so there's no window for a listener to appear/disappear mid-check that would cause incorrect behavior beyond "one extra/one fewer status query," which is cosmetic, not a correctness bug.- UUID tiebreak correctness (
event.id <= lastCursor.id[on equal timestamps]) — confirmed the DB query (db.ts:415-436) orders bycreated_at ASC, id ASCand the JS-side dedupe uses the same field/ordering convention, so the tiebreak itself is internally consistent. It's the shared-cursor race (finding #1) that breaks correctness, not the tiebreak comparator. taskEventBus.setMaxListeners(0)as a leak in itself —0means "unlimited," which is the documented, intentional choice given one listener per SSE connection is expected. Correct choice given the design; it only becomes a liability in combination with finding #4/#6 (nocancel(), so leaked listeners accumulate silently without Node's warning as a tripwire).
Summary
- BLOCKER: 2 — data-loss race between catch-up and live delivery (#1); catch-up truncates backlogs beyond 50 events with no pagination (#2). Either is sufficient to block merge.
- HIGH: 2 — best-effort catch swallowing broadcast, not just enrichment (#3); listener/keep-alive leak on disconnect-during-catch-up (#4).
- MEDIUM: 2 — no backpressure bound on push path (#5); no
ReadableStream.cancel()handler, worse blast radius than before (#6). - LOW: 1 — route-handler-level test coverage gap (#7).
Recommendation: Do not merge as-is. At minimum, fix #1 (replace the shared-cursor merge with an ordering-safe approach — e.g. buffer live events received during the catch-up await and replay/dedupe them against the final catch-up result before switching to live-only, or serialize catch-up-then-subscribe so there is no concurrent window) and #2 (page the catch-up query until exhausted, or explicitly cap and document a "too far behind, resync" path) before this can safely replace polling. #3, #4, and #6 should be addressed in the same PR given they compound the blast radius of any missed cleanup path.
…k, backpressure - Extract SSE catch-up/live-merge logic into core/sse-log-stream.ts with a buffer-then-drain design: subscribe to the live bus first (buffering events instead of sending), paginate the catch-up query to full drain (no truncating LIMIT), then flush the buffer deduped against catch-up before switching to direct live delivery. Dedup and cursor advancement now happen at a single point keyed by event id, removing the two independently-advancing cursors that raced on timestamp. - router.ts: register the abort/cancel cleanup (unsubscribe + keep-alive clear + controller close) immediately in start(), before any await, so a disconnect during catch-up can no longer leak the live-bus listener. - router.ts: check controller.desiredSize before every enqueue; close the connection when a client falls behind instead of growing the buffer unbounded. - db.ts: split the task-status SELECT from the event emit into separate try/catch blocks so a transient SELECT failure degrades to broadcasting without enrichment instead of suppressing the broadcast entirely. - Add sse-log-stream.test.ts covering the race, >50-event pagination, listener-leak-on-disconnect, backpressure cutoff, taskId filtering, and live/catch-up dedup (8 tests, 159 assertions, all passing)
|
Rework pushed in BLOCKER 1 (race on shared BLOCKER 2 (truncating HIGH 1 (SELECT + emit sharing one try/catch) — fixed in
HIGH 2 (listener leak on disconnect during catch-up) — fixed in MED (no backpressure check, no Verification: |
Follow-up to PR #430 (SSE rework). packages/api/src/core/db.ts — now packages/api/src/integrations/db.ts — built the neon() client inside getDb() with no seam to substitute it, so createTaskEvent could not be unit-tested in isolation. Refactor (surgical, public API unchanged): - Extract client construction into createDb(connString?) factory. - getDb() delegates to createDb(), same lazy import-safe behavior. - Add setDb()/resetDb() test-only DI seam over the cached singleton. Tests for createTaskEvent (mock SqlClient via DI, no network): - (a) enrichment SELECT fails -> event still emitted without enrichment (HIGH-1 from #430: SELECT failure must never suppress the emit). - (b) happy path -> event emitted enriched with taskStatus. - (c) INSERT fails -> rejection propagates and no event is emitted (documents current behavior: the INSERT ... RETURNING has no try/catch, so it throws before the broadcast block). - no SSE listeners -> enrichment SELECT is skipped (RML-716 gating).
Problema
O endpoint SSE
GET /api/logs/streamfazia polling no banco a cada 2s por conexão (getRecentTaskEvents), escalando linearmente o custo de DB com o número de clientes conectados.Solução (ENG-1669)
core/task-event-bus.ts(novo):EventEmittercompartilhado in-process;setMaxListeners(0)para suportar muitas conexões SSE.db.createTaskEvent: após o INSERT, emite o evento no bus — choke point único que cobre todos os ~20 call sites (orchestrator, swarm, user-proxy, router, etc.). Faz lookup leve detasks.status(RML-716) apenas quando há listeners; broadcast é best-effort e nunca falha o write.setIntervalde polling. Agora: subscribe no bus → catch-up único viaLast-Event-ID/cursor → push em tempo real. Guards de dedupe cobrem a janela de sobreposição entre catch-up e eventos live. Keep-alive (30s) e cleanup no abort preservados.Limitações
In-process only — se a API escalar para múltiplos processos, trocar por Postgres LISTEN/NOTIFY ou Redis pub/sub (documentado no módulo).
Verificação
bunx tsc --noEmit -p packages/api→ exit 0bun test packages/api/src/core/task-event-bus.test.ts→ 3 passbun test packages/api→ 443 pass / 36 fail (falhas idênticas na árvore limpa: Visual Regression/BrowserManager, dependem de browser — pré-existentes)