Skip to content

perf(sessions): resolve members/automations once per list, not per row — /api/sessions 51ms → 32ms (v0.292.1) - #533

Merged
vikasprogrammer merged 1 commit into
mainfrom
feat/sessions-rebuild
Aug 3, 2026
Merged

perf(sessions): resolve members/automations once per list, not per row — /api/sessions 51ms → 32ms (v0.292.1)#533
vikasprogrammer merged 1 commit into
mainfrom
feat/sessions-rebuild

Conversation

@vikasprogrammer

Copy link
Copy Markdown
Owner

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:

full response (gzip)             50.6 ms   200   246 KB
304 (unchanged, if-none-match)   49.5 ms   304     0 KB   ← same cost, no payload

Where the time actually went

I profiled listSessions against a snapshot of the live instawp DB (950 sessions, 108k audit rows) rather than guessing. Two of my assumptions were wrong:

  • the audit-stamp guard is holding — only 8 of 950 rows re-stamp, 0.8 ms total
  • the audit indexes are being used (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:

queries per poll ~1900
rows they were resolving 14 members + 40 automations

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.

before after
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 40×/min per open tab

Why it can't go stale: the scope is synchronous — no await, so no write can interleave — and nothing inside it mutates members or automations (markCrashed/backfillCosts/stampInsights write 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. It's re-entrant, and the previous scope is restored in a finally so 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 (listSessions mutates, so a shared copy would invalidate the comparison), with a same-build control run to establish what varies from wall-clock alone:

=== control: same build twice (what wall-clock alone changes) ===
  → wall-clock noise fields: updatedAt      (the 8 running rows)

=== equivalence: before vs after ===
  internal(no viewer)  rows 950  identical modulo wall-clock (updatedAt) ✓
  owner                rows 950  identical modulo wall-clock (updatedAt) ✓
  admin                rows 950  identical modulo wall-clock (updatedAt) ✓
  member               rows   1  IDENTICAL ✓
  archived(owner)      rows  71  IDENTICAL ✓

canViewSpawn is 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 to true. So a second harness reassigns the 40 automations across 9 real member accounts and compares the visible session set per member:

  ganesh@instawp.com [member]   21 visible   same ✓
  yogesh@instawp.com [member]  167 visible   same ✓
  … 9 members with non-empty automation-derived sets, all identical
✅ authz identical for every 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/sessions remains unbounded — 950 rows today, and the remaining ~14 ms is now mostly the SELECT * itself plus the tmux liveness poll. Pagination is the structural fix; this was the surgical one.
  • The Tasks page still polls /api/sessions on its own 5 s timer on top of the shell's 1.5 s poll.
  • main.js is one ~15.7k-line App.tsx with no code splitting.

🤖 Generated with Claude Code

…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
vikasprogrammer merged commit c74f077 into main Aug 3, 2026
@vikasprogrammer
vikasprogrammer deleted the feat/sessions-rebuild branch August 3, 2026 07:36
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant