fix: global SSE hub silently drops non-coalescing events on a full subscriber buffer #490
Labels
No labels
backend
bug
chore
duplication
effort:complex
effort:medium
effort:trivial
enhancement
follow-up
frontend
fullstack
priority:high
ready-for-agent
refactor
security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
dries/ocman#490
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
The global
/api/eventsbroadcast hub silently drops events when a subscriber falls behind, and only two event types are exempt.internal/server/broadcast.go:43-46:Subscribers are a
chan broadcastEventbuffered at 16 (broadcast.go:52-59,:92-96).broadcast()is non-blocking (:121-135): on a full buffer a coalescing event is parked last-write-wins keyed byevent + "\x00" + id(:74-86,:140-152), and everything else is dropped on the floor:The backstop is the frontend's 10 s notify poll (
frontend/src/lib/useNotifyData.ts:21).Why it matters
ocman.session.idleandocman.session.changedare not in the coalescing set, so under a burst — several sessions streaming at once, a workflow fan-out, a slow tab — the events that drive queue drain, notification state, and the sidebar are the ones thrown away. The failure is invisible: no log, no metric, and the 10 s poll papers over it well enough that it looks like ordinary lag.Buffering 16 events per subscriber is also the wrong axis. The problem is not "occasionally more than 16 events", it is "one thread emits a hundred near-identical events per second and each of them costs a downstream read".
Suggested fix
Replace the drop-or-park scheme with a windowed, aggregate-keyed batcher, and make it safe by re-reading current state instead of replaying deltas.
Coalesce on a window, not on overflow. Group events over a short window (start at 50 ms, cap the batch size), keep only the newest event per aggregate key (
session:<id>,run:<id>,trigger:), re-sort survivors by their original order, then emit. A burst of tenocman.session.changedfor one session becomes one; an unrelatedocman.session.idlein the same window is never stuck behind them.Make coalescing safe by construction. A coalesced event must carry only an identity, so the consumer re-reads current state. That is already true of most of them (
{sessionID}), with two exceptions to fix:broadcast.go:264-286embeds a full provisionaldb.Sessionon the created path (including a hardcodedStatus: "waiting").internal/server/queue.go:156-178embeds the session's entire queue inocman.queue.updated.Both should shrink to an id, with the client fetching. Guard it: a deletion/removal event must still survive collapsing — if the refetch finds nothing, emit a removal rather than swallowing the event.
Never silently drop. Every event type becomes coalescible under (2), so the
default:drop arm disappears. If a subscriber is still hopelessly behind, close it and let the client reconnect and resync — a reconnect already triggers a refetch (frontend/src/lib/useGlobalEvents.ts:165-168).Add a counter for coalesced and for closed-behind subscribers so the behaviour is observable instead of inferred.
Acceptance criteria
coalescingEventsallowlist is gone.ocman.session.changedandocman.queue.updatedcarry identities only; the frontend refetches. Payload size is independent of queue length and session size.Effort: M.