Skip to content

Committed-session index stamps atMs at scan start, so a slow scan never serves a cache hit - #689

Merged
philcunliffe merged 1 commit into
masterfrom
fix/issue-686
Aug 10, 2026
Merged

Committed-session index stamps atMs at scan start, so a slow scan never serves a cache hit#689
philcunliffe merged 1 commit into
masterfrom
fix/issue-686

Conversation

@philcunliffe

Copy link
Copy Markdown
Contributor

What was wrong

hypaware-core/plugins-workspace/ai-gateway/src/message_projector.js stamped the committed-session index's freshness timestamp when its session_id scan started:

constattempt={atMs: now(),ids: scanCommittedSessionIds(storage,log)}

while the freshness test in mightHaveCommittedRows reads now() - current.atMs < SESSION_INDEX_REBUILD_MS.

Root cause

The rebuild window was aging the attempt, not the answer. Any full session_id scan that itself takes longer than SESSION_INDEX_REBUILD_MS (10 min) resolves already stale, so the very next miss rebuilds at once: the index rebuilds back-to-back indefinitely and never serves a single cache hit, on exactly the table size that makes the index worth having. As the deferred finding from #683's round-2 review notes, this does not stampede (one chained rebuild at a time) and is still better than the pre-#683 one-scan-per-session, so it is a degradation of the optimisation, not a regression against master.

The fix

atMs starts at Infinity (never stale) and is stamped when the scan completes. The entry is still published to built synchronously, so concurrent callers keep sharing the in-flight scan and the round-1 stampede fix is untouched; only the timestamp is deferred. The stamp is chained onto the scan promise (rather than a floating .then) so it is structurally ordered before every awaiter, not dependent on microtask registration order. The self-clearing failed-build guard moved into that same handler unchanged.

Also updated: the SESSION_INDEX_REBUILD_MS JSDoc and the matching bullet in LLP 0204 (Status: Draft) now say the window is measured from scan completion.

Regression test

test/plugins/ai-gateway-message-projector.test.js - "committed-session index: a scan slower than the rebuild window still serves cache hits". It gates the stub's discoverCachePartitions on a promise, advances the injected clock past SESSION_INDEX_REBUILD_MS while the scan is in flight, releases it, and then asserts a later miss one millisecond after completion is served from the index.

FAIL before the fix (fixed test, source reverted to origin/master):

# Subtest: committed-session index: a scan slower than the rebuild window still serves cache hits
not ok 32 - committed-session index: a scan slower than the rebuild window still serves cache hits
name: 'AssertionError'
expected: 1
actual: 2
operator: 'strictEqual'
# tests 36
# pass 35
# fail 1

PASS after the fix, with the existing single-rebuild-under-concurrency and rebuild-after-window tests still green:

# tests 36
# pass 36
# fail 0

Verification

  • npm test: # tests 3864 / # pass 3858 / # fail 0 / # skipped 6
  • npm run typecheck: clean, exit 0
  • No em dashes, no semicolons, JSDoc-only types.

🤖 Generated with Claude Code

Fixes#686

…er serves a hit (#686)
The committed-session index stamped its freshness timestamp when the
session_id scan STARTED. A scan that itself takes longer than
SESSION_INDEX_REBUILD_MS was therefore already stale when it resolved,
so the very next miss rebuilt immediately: back-to-back whole-table
scans that never serve a single cache hit, on exactly the table size
that makes the index worth having.
atMs now starts at Infinity and is stamped when the scan COMPLETES, so
the window ages the answer rather than the attempt. The entry is still
published to `built` synchronously, so concurrent callers share the
in-flight scan and the round-1 stampede stays fixed; the stamp is
chained onto the scan promise so it is ordered before every awaiter.
Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Neutral review round: PR #689 @ da2cd4f7bfc97376ec1e3e5ec096641ac849d27d

Verdict: APPROVE. No actionable findings. Reviewed in a detached worktree off origin/fix/issue-686; the main checkout was never touched. Nothing was pushed, because nothing needed fixing.

Scope reviewed

3 files, 1 commit (code + doc + test landed together, as CLAUDE.md requires):
hypaware-core/plugins-workspace/ai-gateway/src/message_projector.js (+34/-10), llp/0204-gateway-daemon-memory-leak.issue.md (+4/-2), test/plugins/ai-gateway-message-projector.test.js (+45).

Concurrency analysis (the risky part)

The fix rests on one invariant: no reader can ever observe atMs === Infinity. I verified this both statically and empirically.

Statically.atMs has exactly one reader in the whole repo, message_projector.js:388:

constcurrent=built??rebuild()constids=awaitcurrent.ids// :384...if(now()-current.atMs<SESSION_INDEX_REBUILD_MS)returnfalse// :388

The read at :388 is unconditionally preceded by await current.ids at :384. current.ids is the chained promise (message_projector.js:357, attempt.ids = scan.then(...)), whose handler assigns attempt.atMs = now() at :358 before returning. Promise resolution is causally ordered after the handler returns, so the stamp is guaranteed visible to every awaiter. This is genuine structural ordering, not .then registration order, exactly as the PR body claims. (hypaware-core/plugins-workspace/claude/src/transcripts.js:134 has an unrelated field of the same name; not affected.)

The Infinity window is also closed on the publish side: built = attempt (:364) is executed afterattempt.ids is reassigned (:357), with no intervening await, so no caller can ever pick up the raw, unchained scan from built. The one-tick sliver where attempt.ids === scan (between :353 and :357) is unreachable because attempt is not yet published anywhere.

Empirically. I inserted a probe immediately before :388:

if(current.atMs===Infinity)thrownewError("PROBE: reader observed atMs === Infinity")

and ran the full projector file: 36/36 pass, probe never fired across the slow-scan test, the N=6 concurrent-miss test, and the concurrent restart-replay test. Probe reverted; worktree confirmed clean (git status --porcelain empty).

Interaction with the round-1 stampede fix: preserved.mightHaveCommittedRows runs synchronously to its first await (the canScanCommittedRows guard is not an await), so built ?? rebuild() is atomic with respect to other callers, and rebuild() assigns built before yielding. On the post-window path, each caller re-tests built === current synchronously on resume, so the first resumer installs attempt2 and every later resumer takes next = built and shares it. Unchanged by this diff. Confirmed by the pre-existing N=6 test still green.

Interaction with the self-clearing failed-build guard: equivalent, and slightly safer. The guard moved inside the chained handler and now runs structurally before any awaiter resumes, where previously it depended on the guard's .then having been registered on scan before the awaiter's. Same effective ordering, now enforced rather than incidental. Two side benefits: the handler return ids preserves the value for awaiters, and the old floating .then (an unhandled-rejection hazard if scan ever rejected) is gone.

Failure-path bias. On the failure branch the handler stamps atMs and then clears built; the caller reads ids === undefined and returns true (err toward scanning) without reaching :388, so the stamp is inert. And in the hypothetical where a future non-awaiting reader did see Infinity, it reads as "fresh", biasing toward trusting a miss, which lands inside the failure envelope LLP 0204 already documents for a stale miss ("only risks the duplicate seeding guards against, which settlement/compaction still collapse"). Acceptable, and the code comment states the invariant.

Test quality

test/plugins/ai-gateway-message-projector.test.js:894 is deterministic and non-vacuous, not timing-dependent:

  • Injected clock (now = () => clockMs), no wall clock.
  • Explicit promise gate (scanGate / releaseScan) holds discoverCachePartitions open across the window; no setTimeout races. The single setImmediate fully drains the microtask queue, and the whole projector-to-scan chain is microtasks, so the build has reliably started before the clock advances.
  • Non-vacuity verified: with message_projector.js reverted to origin/master and the test file kept, exactly this one test fails, expected: 1 / actual: 2 at the discoverCalls assertion, matching the PR body. At HEAD, 36/36 pass.
  • Not flaky: 40 consecutive runs of the file, 0 failures.

Docs and conventions

  • llp/0204-gateway-daemon-memory-leak.issue.md is Status: Draft (line 4), so it is editable under the CLAUDE.md immutability rule. Confirmed, not an Accepted/Active doc.
  • The doc edit (lines 79-81) accurately describes the new behaviour, and the SESSION_INDEX_REBUILD_MS JSDoc (:317-326) matches. The @ref LLP 0204#fix at message_projector.js:181 still resolves (## Fix at 0204:59) and still applies. The nearby comment at :179 ("one scan per SESSION_INDEX_REBUILD_MS window") remains accurate, and is in fact more true after this change than before it.
  • No semicolons on any added line. No em dashes (U+2014) anywhere in the diff; runtime/comment prose uses -. No inline import() types, no @typedef. JSDoc types only.

Verification run (in the review worktree, at da2cd4f7)

  • npm test: # tests 3864 / # pass 3858 / # fail 0 / # skipped 6
  • npm run typecheck: clean, exit 0
  • node --test test/plugins/ai-gateway-message-projector.test.js: 36/36, x40 runs, 0 failures

Both match the numbers in the PR description.

Nits (not acted on, no change requested)

  1. llp/0204:81 is a 39-character line mid-paragraph, and the SESSION_INDEX_REBUILD_MS JSDoc has similar ragged wrapping after the insert. Pure prose reflow, no meaning change.
  2. message_projector.js:353 sets ids: scan and then overwrites it at :357. This is necessary (the handler closes over attempt, so attempt must exist first), just slightly redundant-looking.

@philcunliffephilcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 9, 2026
@philcunliffe
philcunliffe merged commit 3e8f05a into masterAug 10, 2026
9 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-686 branch August 10, 2026 20:26
philcunliffe pushed a commit that referenced this pull request Aug 10, 2026
Both sides appended a new test at the same point in
test/plugins/ai-gateway-message-projector.test.js:
- master (#688) added 'committed-session index: a build that could not
scan is not cached as "no committed rows"'
- this branch added 'seed failure: a storage that breaks its discover
contract loses no rows and does not poison the session memo'
The tests cover different defects and neither is redundant, so both are
kept. master's test is placed first, directly after the test its own
comment refers to ('a throwing storage degrades to not-seeded').
message_projector.js merged without conflict: #689 reworked rebuild()
in the committed-session index, this branch changed
seedSeenMessagesForSession and scanCommittedMessageIds, and the regions
are disjoint.
Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe pushed a commit that referenced this pull request Aug 10, 2026
Both sides changed `createCommittedSessionIndex().rebuild()`:
- master (#689) chained the self-clearing guard onto the scan so `atMs` is
stamped when the scan COMPLETES;
- this branch (#685) normalizes a rejecting scan to the `undefined` "could
not scan" outcome so the failure degrades the index instead of escaping.
Resolved by keeping both: the `.catch` normalization now wraps the scan
promise that master's chain is built on, so the stamp-on-completion and
the shared in-flight attempt are untouched and the chained handler still
only ever sees a fulfilled value.
master's chaining incidentally gave the rejection an awaiter, so the
unhandled-rejection-kills-the-daemon shape is no longer reachable on its
own; the wedge it caused still is. Without the `.catch`, the rejected
attempt stays published in `built` and never self-clears, so every later
exchange on that listener re-awaits the same rejection (verified: 3
sequential exchanges, all rejected, index never rebuilt). The regression
test now pins that directly with a second exchange, and keeps the
unhandled-rejection assertion so the chaining cannot silently regress.
Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approvedneutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Committed-session-index atMs is stamped at scan start, so a scan longer than the rebuild window never serves a cache hit (deferred from #683)

1 participant

@philcunliffe