daily-cache: never finalize history against a degraded session parse - #856
daily-cache: never finalize history against a degraded session parse#856avs-io wants to merge 1 commit into
Conversation
A read-only session parse (served when the cache refresh lock times out or is unavailable) reported itself as a complete hydration, so the daily backfill published `complete: true` and advanced `lastComputedDate` to yesterday over days the parse never covered. Because gapStart is lastComputedDate + 1, those days were never looked at again — observed as a cache marked complete at 2026-07-28 whose newest entry was 2026-07-25. - parser: a read-only run reports a complete hydration only when nothing changed under the snapshot it served (a skipped or staled file makes it partial). - daily-cache: only a complete parse may advance `lastComputedDate`, on both the gap and the full re-derive paths. - daily-cache: a cache whose watermark outruns its newest populated day has its watermark pulled back to that day, so the ordinary gap parse re-derives the tail instead of trusting the marker. Days are only ever added or re-derived, never dropped; the preservation bias is unchanged and covered by test.
7e7c934 to
213f4cb
Compare
iamtoruk
left a comment
There was a problem hiding this comment.
The fix is real and load-bearing — reproduced the field bug end-to-end on a 58-day corpus through the real parser: on main, four contended runs finalize the cache with two days permanently lost; here the watermark holds and the healed run recovers them. Gate placement in daily-cache.ts is right (baseline-wins merge, retention anchoring intact), the three "complete" concepts stay properly distinct, healthy-corpus output is byte-identical to main (cache hashes matched across three corpus shapes), and the port to the extraction branch is path-only.
One change requested before merge — performance, not correctness:
The signal is date-blind, and that costs ~2× per invocation in two common healthy states. Measured on 1,425–1,450 session files: idle-tail cache (newest day 4 days ago, nothing missing) 63ms → 124ms; sustained contention with only today's transcript growing 53ms → 115ms. Mechanism: any cache whose newest day ≠ yesterday gets a watermark pull-back + full gap re-parse + cache rewrite on every run, and a contended run's complete:false sends the next run down the 365-day re-derive path (4/4 and 7/7 consecutive contended runs re-derived in my probes) — during active work, today's file is always growing, so this is the normal case, on the desktop poll path. Two cheap mitigations, either suffices: date-scope the flag (don't set it when the stale/skipped file can't carry a turn dated ≤ yesterday — both mtimes ≥ local midnight with straddle slack), and/or on the gap path hold the watermark but leave complete alone (recovery still works via the pull-back, and the next run re-derives a 2-day gap instead of 365 days).
Description corrections (cheap, worth doing in the same push):
- The title reads as if the degraded-parse class is closed; the signal covers one mode of ~five. Confirmed unflagged by probe: permission-denied config dirs (
collectJsonlFiles'readdir().catch(() => [])turns EACCES into a silent empty scan that finalizes undercounted history — the biggest remaining hole, deserving its own issue), per-filefailed:truemarkers, theif (!fp) continueskips (a one-liner fix inside this PR's own signal), and network providers in read-only mode. Suggest retitling to the mode actually covered and filing the EACCES swallow as a follow-up. - "All confirmed failing before the change": 3 of the 9 new tests pass on main.
- The "Preservation" section should note that days whose source files expire during the degraded window are still lost — the guard delays the write, it doesn't capture the data. (The deeper fix, if ever wanted: let read-only runs parse uncached files in memory without publishing.)
- A permanently unavailable lock now means permanent 365-day re-derive per run rather than wrong finalization — right trade, but the body argues it can't happen; it can, and it self-heals on the first uncontended run. Worth stating.
Also: branch is 5 commits behind main (clean merge per merge-tree, unrelated regions) — please rebase. The test suite's single red (cli-durable-totals.test.ts:182) is the known pre-existing flake, identical on both trees.
The synthetic gate tests are fine as unit coverage, but the two halves (parser signal → daily-cache gate) are never integration-tested together — happy to share my real-parser degraded→healthy probe as a starting point; a case asserting the flag is NOT set when only today's file grew would also lock in the perf fix.
What
Stop the daily cache from being finalized against a session parse that did not cover the days it is finalizing.
parser.ts: a read-only parse now reports a complete hydration only when nothing changed under the snapshot it served.daily-cache.ts: only a complete parse may advancelastComputedDate, on both the gap-fill and full re-derive paths.daily-cache.ts: a cache whoselastComputedDateis newer than its newest populated day has the watermark pulled back to that day, so the ordinary gap parse re-derives the tail instead of trusting the marker. A cache with no days at all is exempt, so a machine with no history can still finalize.Why
ensureCacheHydratedalready takes asessionCompletecallback, andusage-aggregator.tsalready wiresisSessionHydrationCompleteinto it. The gate is in place; the signal feeding it was inaccurate.runParsesetsessionHydrationComplete = trueat the end of every run, including the read-only runs taken when the refresh lock times out, is unavailable, or the publication fence is lost. In read-only mode a changed file is served at its stale fingerprint and a file with no cache entry is skipped entirely, so such a run can legitimately return nothing for recent days while reporting a complete hydration. The backfill then writescomplete: trueand advanceslastComputedDateto yesterday. SincegapStartislastComputedDate + 1, the days the parse never covered fall behind the watermark and are never revisited.Observed in the field as a
daily-cache.v15.jsonmarked complete withlastComputedDate: 2026-07-28whose newest entry was2026-07-25; three days of history were missing from the Trend view until the cache was rebuilt by hand.The watermark pull-back also heals caches already on disk in that state, so no cache-version bump is needed and existing consistent caches take an identical path.
Why the signal is "served stale", not "was read-only"
Marking every read-only run incomplete would be one line instead of six, but
complete !== truetriggers a full 365-day re-derive on the next run. Anyone running the menubar alongside the CLI contends for the refresh lock routinely, and a filesystem where the lock is permanently unavailable would re-derive forever. A read-only run over an unchanged snapshot is equivalent to a full parse and stays trusted; only one that actually staled or skipped a file is treated as partial.Preservation
Every path here only adds or re-derives days; none can drop one. Partial parses merge with the baseline winning, the re-derived range is strictly after the newest cached day, and retention still anchors on
yesterdayStr(the real calendar edge), so holding the watermark cannot evict anything. A carried day whose session files are gone is asserted present, with its original accounting, across the degraded, healed, and pull-back paths.Testing
New
tests/daily-cache-degraded-completeness.test.ts(6 tests): a degraded parse publishes neithercomplete: truenor an advanced watermark on either path; a later healthy run rebuilds the missed days; an internally inconsistent complete cache is re-derived; a carried day survives all of the above; an empty cache still finalizes without re-parsing.tests/parser-cache-refresh-timeout.test.tsgains cases on the existing timed-out-lock harness: a stale served snapshot and a snapshot missing a session file both report an incomplete hydration; an unchanged snapshot still reports complete.All confirmed failing before the change. Full suite 2473 passed,
tsc --noEmitclean. The existing 39 carry-forward and 22 daily-cache tests pass unchanged.Out of scope
adoptOlderDailyCaches' filename-prefix adoption is left as-is.ensureCacheHydrated'selse if (c.complete !== true && sessionComplete())arm appears unreachable, sincec.complete !== truealways returns from the re-derive branch above. Left untouched; that is a dead-code question, not this change.