Repository navigation
perf: make log streaming fast under output bursts - #131
Merged
Merged
Conversation
A process printing 100k lines left /api/health unanswered for ~9s. Each line was its own autocommit INSERT (re-prepared every time), and pumpStream read already-buffered pipe chunks as microtasks, so the whole burst ran in one event-loop turn. Log lines are now buffered and written once per turn in a single transaction with a cached statement; the stored rows (ids and session_id included) are what the broadcaster publishes, and close() flushes any pending lines before the DB closes. pumpStream yields with setImmediate after each chunk. The FTS trigger stays on the insert path: batching alone brings the worst /api/health latency during the burst to ~70ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the viewer open while a process printed 20k lines, the browser spent ~8.6s in main-thread long tasks: every SSE event set state, mergeLogs rebuilt a Set and sorted the whole array per line, and every row re-rendered (and re-parsed its ANSI codes) each time. SSE entries are now queued and applied once per animation frame. mergeLogs appends a batch directly when its ids follow what is held (the live tail) and only dedups and sorts when they overlap, as with the history fetch and the SSE replay. The viewer keeps the newest 5000 lines and says older output is in History. Rows are memoized, and auto-scroll follows the array rather than its length, which stops changing at the cap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
queryLogs filtered by process_id with no index on it, so ORDER BY timestamp DESC walked idx_logs_timestamp across every process until it found 500 matches. On a 1M-row table a quiet or freshly started process took ~190ms per query, and the viewer runs it twice on open (the /api/logs fetch and the SSE replay). Add idx_logs_process on (process_id, id) and order history by id, which follows insertion order and matches the index, so neither the pid nor the command filter needs a sort. The same queries now take ~0.3ms. Building the index on an existing 1M-row database takes ~0.3s, once, at startup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Python block-buffers stdout when it is a pipe, so a service printing a line every 200ms showed nothing in the viewer until ~8KB had piled up (or until it exited). PYTHONUNBUFFERED=1 is now the lowest env layer, below process.env, so the shell, global_env, profile env and env_overrides can all override it (an empty value turns it off). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PhantomDave
force-pushed
the
perf/log-streaming
branch
from
October 22, 2026 22:44
a760a2a to
b82e875
Compare
PhantomDave
enabled auto-merge (squash)
October 22, 2026 22:45
PhantomDave
disabled auto-merge
October 22, 2026 22:48
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.
Logs often took seconds to show up. Rendering a backlog was never the slow part (500 lines render in ~100ms); the time went to output bursts and to buffered child output. Four fixes, one commit each:
perf(core): the server stayed blocked for the whole burst. Each line was its own autocommitINSERT(prepared again every time), andpumpStreamread already-buffered pipe chunks as microtasks, so a burst ran in a single event-loop turn. Lines are now buffered and written once per turn in one transaction with a cached statement. The stored rows (ids andsession_idincluded) are still what the broadcaster publishes, andclose()flushes pending lines before the DB closes.pumpStreamyields withsetImmediateafter each chunk. Thelogs_ftstrigger stays: with batching alone the burst is well under target, so grep keeps working as it does now.perf(ui): the viewer froze during a live burst. SSE entries are applied once per animation frame.mergeLogsappends directly when ids follow what's held and only dedups and sorts when they overlap (history fetch vs SSE replay). The viewer keeps the newest 5000 lines and says older output is in History. Rows are memoized. Auto-scroll now follows the array instead of its length, because the length stops changing once the cap is reached.perf(core): history queries scanned the timestamp index. Addsidx_logs_process (process_id, id)and orders history byid(insertion order), so the query reads only that process's rows.idx_logs_command (command_id)already ends in the rowid, so it needed no change. Building the index on an existing 1M-row DB takes ~0.3s, once, at startup.fix(core): Python output arrived in 8KB blocks.PYTHONUNBUFFERED=1is now the lowest env layer, underprocess.env, so every other layer can override it (an empty value turns it off). Documented indocs/CONFIG.md.Before / after
Compiled sidecar + built UI, headless Chromium, Linux.
/api/healthlatencyThe 20k-burst "before" row uses the old UI against a core that already had fix 1, so it isolates the UI change. Against the original core it was 9.0 s of long tasks and 13.5 s to settle.
Not done here
listLogRunsis still ~900ms unfiltered on a 1M-row table (History page open). Grouping byprocess_idalone over the new index takes it to ~125ms, but a pid reused by a different command (common on macOS, where pids wrap at 99999) would then merge two runs. A covering index takes it to ~180ms but adds a five-column index to every insert. A rollup table, as the existingponytail:note suggests, is the clean fix, but it belongs in a separate PR./api/logs+ SSE replay) stays. With the index both are sub-millisecond, and the fetch still gives an instant first paint.Checks
bun test(only the two knownlocalhost→::1healthcheck failures),bun run lint,bun run typecheck,bun run format:check, and therun-desktopsidecar smoke before every commit.🤖 Generated with Claude Code