perf(sseHub): batch live log lines into chunked SSE writes - #157
Merged
Conversation
A tailer sync cycle can produce tens of thousands of lines, and each one was broadcast separately: one JSON.stringify and one response.write per client per line. Concatenated SSE frames are byte-identical to the same frames written individually, so the new broadcastBatch emits the whole batch in a single write per client while EventSource still dispatches the events one by one. No client change is needed. pushLiveLines now buffers the whole batch before broadcasting it, keeping the invariant that a broadcast line is already buffered for /api/resync. The per-client back-pressure check moves into a shared writeToAll helper and runs once per batch, which is the right granularity.
… meaningful Writing a whole batch in one call let a stalled client be handed a full sync cycle (up to MAX_SYNC_DELTA_BYTES per source) before the next back-pressure check, turning MAX_CLIENT_BUFFER_BYTES into a per-cycle floor rather than a ceiling. Frames are now appended to a rolling chunk and flushed every 256 KB, so writableLength is re-checked at that granularity while a burst still collapses into a few dozen writes instead of tens of thousands. Accumulating into a chunk also avoids materializing the per-frame array and the joined payload at the same time.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Summary
Live log lines produced by one tailer sync cycle are now broadcast to each SSE client in chunked writes (one per 256 KB of payload) instead of one write per line.
Why
pushLiveLineslooped over the lines of a sync cycle and calledsseHub.broadcast('log-line', …)once per line, sobroadcastdid oneJSON.stringifyplus oneresponse.write()per client per line. A burst is bounded only byMAX_SYNC_DELTA_BYTES(8 MB of appended log per cycle), which can be tens of thousands of lines; with up to 10 connected clients that meant hundreds of thousands of small writes on the single event-loop thread that also serves HTTP requests. The per-write bookkeeping dominates — the payload bytes are identical either way.SSE frames concatenate naturally: writing several
event: …\ndata: …\n\nframes in one write is byte-identical to writing them separately, and the browser'sEventSourcestill dispatches the same individuallog-lineevents. No client change is needed.Chunking rather than a single write per batch is deliberate.
MAX_CLIENT_BUFFER_BYTES(1 MB) only bounds a stalled client's outgoing buffer ifwritableLengthis re-checked often enough; writing an entire sync cycle at once would hand a stalled client several megabytes before the next check and turn that constant into a per-cycle floor instead of a ceiling. Flushing every 256 KB keeps essentially all of the syscall win — a 10k-line burst becomes a few dozen writes rather than one — while the cap keeps its meaning.Changes
src/server/sseHub.ts: addedbroadcastBatch(event, items), which appends frames to a rolling chunk and flushes to all clients everyWRITE_CHUNK_CHARS(256 KB), plus a final flush for the remainder; an empty batch writes nothing. Accumulating into a chunk rather thanitems.map(…).join('')also avoids materializing the per-frame array and the joined payload at the same time. Extracted the per-client delivery loop — back-pressure check,try/write, client removal — into a privatewriteToAllhelper shared bybroadcastandbroadcastBatch, and the frame formatting into aformatEventhelper.src/server/startupSeed.ts:pushLiveLinespushes the whole batch into the buffer first (collecting the persisted lines with their assigned ids), then emits them with onebroadcastBatch('log-line', …)call. Buffering before broadcasting preserves the invariant that a broadcast line is always already buffered, which/api/resyncrelies on. The localLogLineBroadcasterinterface now requiresbroadcastBatch;SseHub.broadcastremains for the single-itemsource-statusandheartbeatemissions.Tests
src/server/sseHub.test.ts: a smallbroadcastBatchproduces exactly one write that parses as three well-formedlog-lineframes; an empty batch writes nothing; a batch broadcast still drops a slow client whosewritableLengthexceeds the cap and removes a client whosewritethrows; a 20-frame / ~2 MB batch to a client that never drains is split across several writes (fewer than one per frame) and the client is dropped part way through, with its buffer still bounded well under the batch size.src/server/startupSeed.test.ts: the broadcaster double now records batches, and the seeded-pusher test asserts that two live lines result in a single batch emission carrying the persisted lines with ids1and2, in order.npm run typecheck,npm test(139 tests), andnpm run buildall pass.Linked issues
Closes #135
Review notes
The wire output is unchanged, so no client-side change was required and the existing client tests pass untouched. Ordering is also unchanged: lines are buffered and broadcast in the same sequence as before, just grouped into chunks.
The issue text proposed a single write per batch and called the resulting per-batch back-pressure check "the correct granularity". This implementation deviates on that point for the memory-bound reason above; everything else follows the proposal.
Follow-up work
None.