perf(sessions): resolve members/automations once per list, not per row — /api/sessions 51ms → 32ms (v0.292.1) - #533
Merged
Conversation
…w (v0.292.2) v0.291.6 made the 1.5s console poll cheap on the WIRE (a 304 with no body), but the server still paid the whole rebuild before it could decide to send that 304 — measured, a 304 cost exactly as much as the full response (49.5 ms vs 50.6 ms). Profiling the rebuild against a snapshot of the live instawp tenant (950 sessions, 108k audit rows) put the blame somewhere I'd assumed was already fine. The audit-stamp guard holds (only 8 of 950 rows re-stamp, 0.8 ms) and the audit indexes are used. The cost was the four per-row helpers — spawnedByLabel, sourceKind, runAsLabel and canViewSpawn — each issuing its own SQLite point lookup per row: ~1900 queries per poll to resolve a grand total of 14 members and 40 automations. The lookup tables are tiny and bounded; the row count is not. withRowCache() now loads each table once per list call and the helpers read from it. Two queries instead of ~1900. listSessions(owner) 35 ms -> 14 ms (-60%) listSessions(no viewer) 43 ms -> 22 ms listArchivedSessions 1.5 ms -> 0.6 ms GET /api/sessions 51 ms -> 32 ms ...its idle 304 path 50 ms -> 30 ms <- runs 40x/min per tab Safe by construction: the scope is SYNCHRONOUS (no await, so no write can interleave) and nothing inside it mutates members or automations — markCrashed/backfillCosts/stampInsights touch term_sessions and the audit log only. Outside a scope the helpers take the old direct-query path verbatim, so every other caller is untouched. Re-entrant, and the previous scope is restored in a finally so an exception can't strand a stale cache. Also splits the gzip level by how often the same bytes are compressed: static assets are compressed once per build and cached, so they keep level 6; live JSON is re-compressed on nearly every poll, so it drops to level 4 — 15.8 ms -> 10.1 ms for 5.9% more bytes on the 1.2 MB sessions payload. Compression had grown to cost more than the rebuild itself. Verified against a copy of the live DB, both builds from identical state: output byte-identical for every viewer role, with a same-build control run to establish that the only varying field (updatedAt, on the 8 running rows) is wall-clock noise rather than a behavioural change. Because the live data can't exercise canViewSpawn's member branch (every automation there was created by an owner/admin, who short-circuit to true), a separate harness reassigns the 40 automations to 9 real member accounts and compares the visible session SET per member — 4 to 167 sessions each, identical across builds. Governance 159/159, tier-A 18/18, registry 18/18, idle reaper 18/18; transport checks still pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vikasprogrammer
force-pushed
the
feat/sessions-rebuild
branch
from
August 3, 2026 07:36
8dfa82e to
9cc42e7
Compare
vikasprogrammer
added a commit
that referenced
this pull request
Aug 3, 2026
GET /api/sessions has always shipped `task` clipped to 240 chars, but the server still SELECT *'d every session's FULL prompt out of SQLite — up to 53 KB/row on instawp, 2.1 MB materialised per 1.5s poll — only for server.ts to throw it away. listSessions/listArchivedSessions now take an optional taskClip; the list endpoint passes LIST_CLIP and the SELECT projects substr(task,1,241) AS task (schema-derived column list), so SQLite stops materialising the overflow text. Measured on a live instawp snapshot (950 rows): task bytes 2.10 MB → 201 KB, raw query 5.23 → 3.53 ms (-33%), full listSessions(owner) 13.3 → 11.1 ms (-17%) per poll, plus ~1.9 MB less string allocation each tick. Output is byte-identical — clipText still runs as the ellipsis-preserving finisher on the ≤241-char string (verified across all 950 rows, 746 of them >240). Internal callers that read the whole prompt (sessionsForAgent, Cockpit context) pass no clip and keep the full SELECT *. Follow-on to #530/#532/#533; pagination (the structural fix) still open. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vikasprogrammer
added a commit
that referenced
this pull request
Aug 3, 2026
…#535) GET /api/sessions has always shipped `task` clipped to 240 chars, but the server still SELECT *'d every session's FULL prompt out of SQLite — up to 53 KB/row on instawp, 2.1 MB materialised per 1.5s poll — only for server.ts to throw it away. listSessions/listArchivedSessions now take an optional taskClip; the list endpoint passes LIST_CLIP and the SELECT projects substr(task,1,241) AS task (schema-derived column list), so SQLite stops materialising the overflow text. Measured on a live instawp snapshot (950 rows): task bytes 2.10 MB → 201 KB, raw query 5.23 → 3.53 ms (-33%), full listSessions(owner) 13.3 → 11.1 ms (-17%) per poll, plus ~1.9 MB less string allocation each tick. Output is byte-identical — clipText still runs as the ellipsis-preserving finisher on the ≤241-char string (verified across all 950 rows, 746 of them >240). Internal callers that read the whole prompt (sessionsForAgent, Cockpit context) pass no clip and keep the full SELECT *. Follow-on to #530/#532/#533; pagination (the structural fix) still open. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
Follow-up to #530. That PR made the 1.5 s console poll cheap on the wire (a 304 with no body), but the server still paid the whole rebuild before it could decide to send that 304. Measured on the live instawp tenant, a 304 cost exactly as much as a full response:
Where the time actually went
I profiled
listSessionsagainst a snapshot of the live instawp DB (950 sessions, 108k audit rows) rather than guessing. Two of my assumptions were wrong:SEARCH … USING COVERING INDEX idx_audit_run_type)The cost was the four per-row helpers —
spawnedByLabel,sourceKind,runAsLabel,canViewSpawn— each issuing its own SQLite point lookup per row:The lookup tables are tiny and bounded. The row count (950, growing) is not.
The change
withRowCache()loads each table once per list call; the helpers read from it. Two queries instead of ~1900.listSessions(owner)listSessions(no viewer)listArchivedSessionsGET /api/sessionsWhy it can't go stale: the scope is synchronous — no await, so no write can interleave — and nothing inside it mutates
membersorautomations(markCrashed/backfillCosts/stampInsightswriteterm_sessionsand the audit log only). Outside a scope the helpers take the old direct-query path verbatim, so every other caller is untouched. It's re-entrant, and the previous scope is restored in afinallyso an exception can't strand a stale cache on the instance.Also: gzip level split by recompression frequency
A static asset is compressed once per build and cached → keeps level 6. Live JSON is recompressed on nearly every poll → drops to level 4. On the 1.2 MB sessions payload: 15.8 ms → 10.1 ms for 5.9% more bytes (level 1 would be 6.5 ms but 18.8% more). Compression had grown to cost more than the rebuild itself.
Verification
Both builds run from identical copies of the live DB (
listSessionsmutates, so a shared copy would invalidate the comparison), with a same-build control run to establish what varies from wall-clock alone:canViewSpawnis an authz decision, and the live data can't exercise its member branch — every automation there was created by an owner/admin, who short-circuit totrue. So a second harness reassigns the 40 automations across 9 real member accounts and compares the visible session set per member:Governance 159/159 · tier-A 18/18 · registry 18/18 · idle reaper 18/18 · #530's transport checks still pass ·
tsc+ web build clean.Still open
/api/sessionsremains unbounded — 950 rows today, and the remaining ~14 ms is now mostly theSELECT *itself plus the tmux liveness poll. Pagination is the structural fix; this was the surgical one./api/sessionson its own 5 s timer on top of the shell's 1.5 s poll.main.jsis one ~15.7k-lineApp.tsxwith no code splitting.🤖 Generated with Claude Code