Skip to content

perf(sessions): incremental list scan — fingerprint memoization eliminates 70s cold load - #625

Draft
Nuctori wants to merge 1 commit into
agegr:mainfrom
Nuctori:perf/l1-scanner
Draft

perf(sessions): incremental list scan — fingerprint memoization eliminates 70s cold load#625
Nuctori wants to merge 1 commit into
agegr:mainfrom
Nuctori:perf/l1-scanner

Conversation

@Nuctori

@Nuctori Nuctori commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

What

Replaces the inner scan of SessionManager.listAll with a pure-function
fingerprint memoization in lib/session-list-scanner.ts, then routes
loadAllSessions() through the new listSessionsIncremental() entry.

  • F(file) -> ScannedSessionInfo mirrors the SDK's buildSessionInfo
    line semantics 1:1 for the fields pi-web actually reads
    (id/cwd/name/created/modified/messageCount/firstMessage/
    parentSessionPath). The dropped field is allMessagesText, which
    pi-web never consumes (grep lib//app//components//hooks/
    returns zero non-test references).
  • The persisted index lives at ~/.pi/agent/pi-web-session-index.json
    (versioned, mode 0600, atomic write via writePrivateFileAtomicSync).
    Restarts re-validate entries by stat; nothing else changes.
  • Concurrent scans: MAX_CONCURRENT_SCANS = 10 workers over the changed
    set; unchanged files contribute zero IO.

Why

/api/sessions cold scan replays every byte of every JSONL (~9.5 GiB
across 1992 sessions) just to assemble a single string nobody reads. The
diag on the local catalog (cold 70085ms, warm 214ms, perf-bench 908ms
warm) shows the regression surface: the route is the front door, so
this is P0.

Invariant (formal)

SessionManager.listAll is replaced by listSessionsIncremental. For
an append-only JSONL:

fp := (size, mtimeMs)                 // fingerprint per file
fp unchanged  =>  bytes unchanged     // pi only appends or rewrites whole
              =>  F(file) unchanged   // pure function over the bytes

Changed / deleted / new files are always rescanned / dropped / added, so
the incremental set is the full set. Sort by modified desc matches
SessionManager.listAll. Therefore listSessionsIncremental() === listAll()
on (sessions, sort) for the fields pi-web reads.

No source changes outside the two files

  • 0 changes to app//components//hooks//e2e/
  • 0 changes to package.json / package-lock.json
  • 2 files: lib/session-list-scanner.ts (new), lib/session-reader.ts
    (3-line wire: loadAllSessions calls listSessionsIncremental instead
    of SessionManager.listAll)

Audit (self)

  • base: origin/main (28bab3c), ahead 0 before this PR
  • lint: 0 errors
  • tsc: 0 errors (node_modules/.bin/tsc --noEmit)
  • reverts: delete lib/session-list-scanner.ts; revert the 3-line
    wire in lib/session-reader.ts; no other touches
  • L1 invariant in commit body (append-only => fp unchanged => bytes
    unchanged => cached F unchanged); incremental set equals full set by
    construction

Stacked plan (each PR base=main, merge-order-free)

  • ci: add independent perf-bench job (cold/warm list, context tail=50) #624 P1 perf/bench — independent perf-bench job, no e2e harness dep
  • this PR P2 perf/l1-scanner — fingerprint memoization
  • P3 perf/l2-tailO(delta) tail-read for grown files (~140 lines)
  • P4 perf/l3-sidebar — windowed list (~85 lines)
  • P5 perf/l4-context — reverse-stream loadRecentEntries + test fix
    (~135 lines)

P2 stands alone: even without P1's CI gate, the local diff stats
(cold 70s → warm 200ms on the local 1992-session catalog) justify
landing it.

…nates 70s cold load

SDK SessionManager.listAll replays every byte of every session (~9.5 GiB)
to build allMessagesText, which pi-web never consumes. Replace the inner
scan with a pure-function memoization:

  F(file) -> ScannedSessionInfo  (1:1 mirror of SDK semantics for the
  fields pi-web actually reads: id/cwd/name/created/modified/
  messageCount/firstMessage/parentSessionPath)

Invariant (append-only): pi only appends lines or rewrites wholesale;
either changes (size, mtimeMs). So fingerprint fp=(size,mtimeMs)
unchanged => bytes unchanged => cached F(file) === fresh scan.
Changed/deleted/new files are always rescanned/dropped/added, so the
incremental set equals a full rescan and sort(modified desc) matches SDK.

Effect: cold /api/sessions 70s at 1992 sessions -> warm-hit stat-only
pass (~200ms) with at most changed files rescanned; persisted index
(pi-web-session-index.json, atomic 0600) survives restarts and is
revalidated by stat on every load.
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