Skip to content

Report a compaction retry spent by a failed attempt (#739) - #741

Merged
philcunliffe merged 3 commits into
masterfrom
fix/issue-739
Aug 13, 2026
Merged

Report a compaction retry spent by a failed attempt (#739)#741
philcunliffe merged 3 commits into
masterfrom
fix/issue-739

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

A compaction retry spent by a failed attempt is now reported on every later tick, instead of leaving the partition indistinguishable from a healthy converged one.

The gap

PR #735 made a stale-verdict retry that throws re-stamp the cursor before rethrowing, so a persistently failing partition is attempted once per writer generation rather than once per tick. stampWriterGeneration deliberately records no effectiveness claim, because a failed rewrite proves nothing about the partition.

But compactionKnownIneffective requires compactionReducedFiles === false, which requires a numeric dataFilesBefore - deliberately absent after a failure. So the skip-report branch never fired, and later ticks skipped the partition with no compactionIneffective report, no line in hyp query maintain, and no span attribute.

LLP 0217 promises a partition maintenance deliberately leaves fragmented is skipped for a stated reason. That held for the recorded-ineffective case and not for the failure case. The failing tick itself logs daemon.maintenance_failed and its span carries the error, so the evidence exists once - but the ongoing frozen state was invisible, which is the same legibility gap #723 was about, one layer down.

The fix

stampWriterGeneration now also writes attemptFailedAt, a new reader compactionAttemptFailedAt exposes it, and a new skip branch reports compaction skipped: the retry failed under this writer at <ts>, --force to retry, with a compaction_attempt_failed span attribute.

Deliberately weaker than an ineffective verdict. The failure stamp still records no dataFilesBefore, so compactionKnownIneffective is unchanged and the new report is a separate, weaker statement. Where both could apply, the committed verdict outranks the error that followed it: a rewrite that commits its verdict and then throws reports compactionIneffective: true, not compactionAttemptFailed.

No extra retries. attemptFailedAt is read in exactly one place, the reporting branch guarded by !shouldCompact. It is not consulted by grewSinceCompaction, compactionVerdictStale, needsCompaction, or compactionDue. stampWriterGeneration still sets writerGeneration, so compactionVerdictStale stays false exactly as before. Retry-once-per-generation is untouched and --force remains the override, so LLP 0199's rewrite-forever loop cannot return.

Evidence

Test written first, driving the real maintainCache path with the merged suite's own torn-file technique (truncate a live parquet data file so the retry's scan throws). Against unmodified source, independently re-derived by the reconciler:

not ok 5 - a partition frozen by a failed retry is reported as skipped on every later tick
  tick 1 reports the partition exactly as it reports a healthy converged one
  + actual: undefined   - expected: true
# tests 8 # pass 7 # fail 1

It fails for the right reason: the fixture invariants (assert.rejects on the failing tick, and record.dataFilesBefore === undefined proving the stamp recorded no effectiveness) both pass first, so the failure is precisely "the later tick reports nothing". After the fix: 8/8.

Anti-regression is covered two ways. The merged suite's existing tests all pass unchanged, including the one whose second tick asserts compactionIneffective === true. And a new test, a committed ineffective verdict outranks the failed attempt that followed it, uses the read-only-directory late-throw fixture to prove precedence - it passes both before and after the fix, which is what makes it an anti-regression rather than a driver.

Cursor compatibility, both directions

  • Old cursor, new reader: compactionAttemptFailedAt returns undefined unless typeof c.attemptFailedAt === 'string', so every pre-existing cursor reads as "last attempt not known to have failed". Covered by every other test in the file, all of which use cursors without the key.
  • New cursor, old reader: the added key is one extra string in the untyped compaction object. tryReadCursorSync passes it through untyped and every reader probes only the fields it needs, so an older build ignores it. Nothing validates the record's key set.
  • Self-clearing: cursorAfter builds the compaction record fresh rather than spreading the old one, so a successful rewrite drops the note in the same write that supersedes it (asserted on the --force path). rebaselineCursor spreads, so the key is deleted there explicitly, next to where it already deletes dataFilesBefore.

LLP route

Minted LLP 0218 (Decision) with an Extended-by: forward-ref on LLP 0217, rather than editing 0217. It is Accepted, and CLAUDE.md treats an Accepted doc as a record. This is genuinely additive - a new cursor key and a new report field, for a case 0217's two decisions create between them but neither settles - and it needs prose of its own for the verdict-outranks-error precedence rule and the recognition-path drop, which is more than a forward-ref note can carry. Nothing 0217 settled is contradicted. Checked against every open branch: no number collision.

Suite 3996 pass / 0 fail / 1 pre-existing skip; typecheck clean; llp-ref-hygiene 11/11; cache-retention-maintenance 38/38 with the LLP 0207 test intact.

Fixes #739

test added 2 commits August 13, 2026 07:50
… in silence (#739)

LLP 0217 made a partition maintenance deliberately leaves fragmented a
stated outcome, but the skip report needs a recorded effectiveness verdict
and the failure path deliberately records none: a rewrite that threw proves
nothing about whether the partition can be shrunk. So a retry that failed
stamped the cursor, spent its writer generation, and from the next tick on
the partition was skipped with no report, no `hyp query maintain` line and
no span attribute, indistinguishable from a healthy converged one while it
was still fragmented and still held the torn file that broke the rewrite.

The stamp now records `attemptFailedAt` beside `writerGeneration`, and the
skip branch reports `compactionAttemptFailed` when the stamp is there
without an effectiveness record. A recorded verdict still outranks it: a
rewrite that committed a verdict and then threw is described by the verdict,
which says something about the partition, and not by the error, which does
not. A stamp from a writer generation this build does not run is suppressed
too, because that partition is owed a fresh attempt rather than frozen.

Nothing about dueness changes. The new field is read only by the reporting
branch, never by the baseline gate or `compactionVerdictStale`, so it cannot
grant a retry and cannot reopen the LLP 0199 rewrite-forever loop. The
recognition path drops it where it already drops effectiveness, and a
rewrite that commits writes a fresh record, so the note clears itself with
the same write that supersedes it. An older build ignores the extra string
key, and a cursor without it reads as one whose last attempt is not known to
have failed, which is every cursor written before this change.

LLP 0218 records the extension; LLP 0217 carries the forward-ref.
… unpinned precedence fixture

An empty-string compaction.attemptFailedAt used to pass the guard in
compactionAttemptFailedAt and print an operator line with a hole
("...failed under this writer at , --force to retry"). Reject '' too.

The precedence anti-regression test asserted only one side of the
competing-records fixture (dataFilesBefore === 8) without pinning that
the late-throw also wrote attemptFailedAt, so a future stamp change that
silently stopped writing it could pass the test while no longer testing
precedence. Assert the fixture invariant explicitly.
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Review round 1 of 6e6ee51. Verdict: findings - 2 minor, both fixed and pushed as cd41cea. The core holds up end to end.

This review was briefed with a lesson from sibling PR #740, which described a mechanism wrongly five times because each claim was verified against internals and never against the surface an operator actually sees. So this one drove real maintainCache and runQueryMaintain ticks over a torn-parquet fixture and captured stdout verbatim, pre-fix and post-fix.

1. minor - an empty-string attemptFailedAt produced a malformed operator line. FIXED

compactionAttemptFailedAt accepted any string, including '', so a hand-edited or partially-written cursor printed:

  ai_gateway_messages/source=claude: compaction skipped: the retry failed under this writer at , --force to retry

Six shapes were planted (12345, {at:'x'}, '', null, ['x'], true); only '' slipped through, and none crashed. It is the one hand-edit that turns this PR's new sentence into a sentence with a hole in it, on exactly the surface the PR exists to add. Fixed with an explicit empty check.

Worth recording from the fix: the obvious reproduction does not work. Reusing the existing ineffective-partition fixture and overwriting attemptFailedAt does not hit the bug, because a prior effectiveness verdict short-circuits at the compactionReducedFiles(cursor) !== undefined guard. The fixer had to build a minimal same-generation, no-verdict cursor to reach the defect, and said so rather than reporting a passing check against the wrong path.

2. minor - the anti-regression test did not pin the invariant that makes it about precedence. FIXED

a committed ineffective verdict outranks the failed attempt that followed it asserted the fixture invariant for one competing record (dataFilesBefore === 8) but not the other - nothing asserted that attemptFailedAt was actually written by the late-throw.

It is written today (verified: the cursor after the read-only-directory throw carries both keys), so the contested path is genuinely exercised and the PR's claim was honest. But this is the one test whose whole job is precedence, and it passes both before and after the fix. If a future change made the stamp skip attemptFailedAt when a verdict is present - an entirely plausible "simplification" of exactly this logic - the test would keep passing while silently testing nothing, since compactionAttemptFailed === undefined would hold trivially.

Fixed with the missing assertion, and its discrimination verified both ways: it holds today, and temporarily making stampWriterGeneration skip the key when a verdict exists makes this test alone fail (7 pass, 1 fail) on exactly that assertion.

The operator surface

Real runQueryMaintain invocations against a real cache root, stdout verbatim. Fixture: 8 identity-partitioned sessions, forced compaction, the stamp-less #723 cursor planted, one live parquet file truncated to 4 bytes.

$ hyp query maintain --compact-only              [the tick whose retry fails]
  <<threw: RangeError: Offset is outside the bounds of the DataView>>   (no stdout)

$ hyp query maintain --compact-only              [later tick]
  ai_gateway_messages/source=claude: compaction skipped: the retry failed under this writer at 2026-08-13T08:33:28.285Z, --force to retry
maintenance: 0 snapshots expired, 0 partitions compacted (1ms)

$ hyp query maintain --compact-only --dry-run
[dry-run]
  ai_gateway_messages/source=claude: compaction skipped: the retry failed under this writer at 2026-08-13T08:33:28.285Z, --force to retry

$ hyp query maintain --force --compact-only      [after untearing the file]
  ai_gateway_messages/source=claude: compacted epoch=? (8 -> 8 files), no file-count reduction

$ hyp query maintain --compact-only              [settled tick]
  ai_gateway_messages/source=claude: compaction skipped: the last rewrite of 8 files reduced nothing

Pre-fix, same script: every later tick, the dry run and the full maintain printed no partition line at all. The gap is real at the operator surface, not just in the cursor.

Surface details, all correct: the timestamp matches the stamp exactly; it appears on --dry-run and the default invocation, consistent with compactionIneffective; it is absent under --force (the partition is being retried) and --expire-only. The last two blocks demonstrate self-clearing and reverse-order precedence in one step - after the rewrite commits, the note is gone and the report degrades to the ineffective verdict, the more useful of the two.

Span attribute confirmed on a real exported span: compaction_attempt_failed: true alongside compacted: false, data_files_before: 8. The daemon discards the report object entirely, so the span is its only surface - same as compaction_ineffective, so the design is consistent.

One thing worth knowing, pre-existing and out of scope: the failing tick prints nothing and propagates a raw RangeError out of hyp query maintain. That is #735 behaviour, and it is precisely why the standing line this PR adds matters.

Also checked, clean

  • Precedence, all three orders. Verdict-then-failure: the late throw leaves a cursor carrying both records; the next tick reports the ineffective verdict and compactionAttemptFailed: undefined. Failure-then-successful-but-ineffective-rewrite: the fresh record supersedes the note. A recorded effective verdict plus a stamp yields no line at all, which is right. The suppression is the right way round: the verdict says something about the partition, the error does not.
  • No extra retries, verified by grep and by construction. attemptFailedAt is written only in stampWriterGeneration (sole caller inside the catch, still gated on verdictStale), read only in compactionAttemptFailedAt (one call site, inside the !shouldCompact arm), deleted in rebaselineCursor. It is referenced by none of grewSinceCompaction, compactionVerdictStale, needsCompaction, compactionDue, hasResettle, compactionKnownIneffective. 12 consecutive ticks after the failure: 0 compactions, byte-stable stamp. A stamp naming a foreign writerGeneration is correctly suppressed and the partition retried, so an old failure cannot freeze a partition across a writer change.
  • Cursor compatibility both directions. Old cursor / new reader: the type guard short-circuits. New cursor / old reader: the pre-fix src/ was run against a post-fix cursor - the extra key is ignored, no crash. Self-clearing verified on disk for both cursorAfter branches; every writeCursor call site audited, and only the stamp preserves the key.
  • LLP 0218 header well-formed and shaped like 0217; the Extended-by: on 0217 is a single additive line matching the repo's format (compared against 0012, 0044, 0086, 0188); nothing Accepted rewritten; both @ref sites resolve. Every prose claim checked against the code. No number collision across the merged corpus or the 15 other open PRs. llp-ref-hygiene 11/11.
  • Conventions clean; npm test 3996 pass / 0 fail / 1 pre-existing skip; typecheck clean; cache-retention-maintenance 38/38 with the LLP 0207 test intact.

The head has moved to cd41cea, so the next tick reviews that head (round 2).

…o reason

The `|| c.attemptFailedAt === ''` guard added to
`compactionAttemptFailedAt` landed unpinned: deleting it left the whole
suite green, so the malformed operator line ("failed under this writer
at , --force to retry") could come back unnoticed. This is deterministic
cursor-reading logic in a file that already has a dedicated test, which
is exactly what CLAUDE.md asks to be covered traditionally.

The branch is hard to reach by accident, which is the reason to pin it
rather than a reason not to: the obvious reproduction plants a record
that still carries an effectiveness verdict, and that short-circuits at
the verdict guard before the timestamp is ever consulted. The cursor
here is the shape a failed retry actually leaves - this build's writer
generation, taken from the real record so a future generation bump
cannot silently stop the test exercising the branch, and no verdict,
because a rewrite that threw records none.

The second half pins the other direction: the same cursor carrying a
real timestamp is still reported, so a future guard that rejects too
much fails too.
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Review round 2 of cd41cea. Verdict: findings - 1 minor, fixed and pushed as af1cbf7. Both round-1 fixes landed correctly and were confirmed load-bearing by mutation.

1. minor - round 1's empty-string fix landed with no test. FIXED

Verified by mutation: deleting || c.attemptFailedAt === '' from maintenance.js:1220 and running the entire suite leaves 3996 pass / 0 fail. Nothing in the repo detected the regression, so the malformed operator line (...failed under this writer at , --force to retry) would have silently come back.

Three reasons this was worth raising rather than waving through:

  • The same commit did pin its other fix with an assertion, so it was internally inconsistent about which of its two fixes got protection.
  • CLAUDE.md explicitly requires traditional tests for deterministic logic, and this is deterministic cursor-reading logic in a file that already has a dedicated test file.
  • The branch is unusually hard to reach. As round 1's fixer discovered, the obvious reproduction does not work: a prior effectiveness verdict short-circuits at the compactionReducedFiles(cursor) !== undefined guard before the timestamp is ever consulted. Logic that is hard to reach by accident is exactly what a future refactor breaks without noticing - and the expensive part, working out the minimal cursor that reaches it, had already been done and then discarded.

Fixed with a test that discriminates in both directions: it passes at head, and with the guard reverted it fails on exactly the intended assertion and only that one (8 pass / 1 fail). Two deliberate properties were preserved: it takes the writer generation from the real record rather than hardcoding it, so a future generation bump cannot silently stop it exercising the branch; and its second half pins the non-over-tightening direction, so a guard that later rejected too much would fail too.

Verified from round 1

  • Fix 1 (empty-string attemptFailedAt) - landed correctly and not over-tightened. Only '' is added to the existing type guard, and the main test proves a legitimate toISOString() timestamp still reports across two consecutive ticks. The positive direction was independently confirmed with a planted real timestamp. The only gap was the missing regression test, which is finding 1.
  • Fix 2 (unpinned precedence fixture) - landed and genuinely discriminates. Verified non-vacuous by applying the exact future change round 1 named - making stampWriterGeneration skip attemptFailedAt when a verdict exists - which produces not ok 6 ... 'fixture invariant: the throw also stamped a failed attempt, so the two records really do compete'. Without the assertion that mutation passes silently.

Also checked, clean

  • The guard-chain short-circuit ordering, walked across all combinations of (verdict, stamp, generation), is correct and intended in each. Verdict false plus stamp reports the ineffective verdict, which is more informative. Verdict undefined plus stamp plus current generation reports the spent attempt, the target case. Verdict undefined plus stamp plus a foreign generation is correctly silent, because compactionVerdictStale grants a fresh attempt instead. The non-obvious combination is verdict true plus stamp - reachable when a rewrite commits its cursor then throws in post-work - and silence is right there, since the partition genuinely converged and a "retry failed" line would be actively misleading. The internal verdict guard is not redundant with the else branch: the else only excludes known-ineffective, so the internal guard is what catches the verdict-true case. LLP 0218 documents this precedence exactly as implemented.
  • Degenerate-but-truthy timestamps (whitespace-only, non-ISO, far-future, malformed) still print as-is. Judged acceptable rather than a finding: the sole producer is new Date().toISOString(); '' is the realistic degenerate value (a falsy default, a truncated write, a refactor writing x ?? '') whereas ' ' is not something anything naturally produces; and a malformed-but-present timestamp still yields a well-formed sentence naming a moment, leaving the operator's action unchanged. Tightening to Date.parse validation would add a failure mode without removing a real one.
  • Stale-crumb lifecycle: attemptFailedAt cannot outlive its condition. Both success paths build a fresh compaction object rather than spreading the old one, so any committed rewrite drops the note, and rebaselineCursor explicitly deletes it on the LLP 0207 recognition path. Both are asserted. Where the crumb does persist alongside a verdict it is inert, short-circuited by the internal guard.
  • LLP 0218 is correctly numbered and named, Related: and ## Extends are accurate, and all four ## Consequences claims match the code - in particular "not consulted by the baseline gate, by compactionVerdictStale, or by any dueness heuristic", confirmed: the field appears only in the reporting branch, the stamp writer, the reader, and the rebaseline delete. The Extended-by: forward-ref on 0217 is present and points at the right file; the anchor matches every @ref citing it.
  • Delta 6e6ee51..cd41cea was exactly the two intended lines with no collateral edits.
  • Conventions clean; npm test 3997 pass / 0 fail / 1 pre-existing skip; typecheck clean; cache-retention-maintenance 38/38 with the LLP 0207 test intact.

The head has moved to af1cbf7, so the round budget (2) is spent at an unreviewed head: the next tick triages rather than opening a round 3.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Triage after the review budget (LLP 0017). Two rounds ran (3 findings, all fixed). Judged mergeable. One deferred item in #742.

The operator message, judged as an operator would

Observed with a torn-parquet fixture and real runQueryMaintain, stdout captured:

ai_gateway_messages/source=claude: compaction skipped: the retry failed under this writer at 2026-08-13T10:05:37.776Z, --force to retry

It clears the bar. The bare ISO timestamp is deliberately the right payload, not a weaker choice than elapsed time or a count: it is the correlation key into the failing tick's daemon.maintenance_failed log and partition span, where the cause actually lives, and an elapsed duration would sever that link. A failure count would also be constant at 1, since the stamp is structurally written at most once per writer generation.

And the advice was tested rather than assumed. Following --force while the cause persists throws the actual underlying error at the operator (RangeError: Offset is outside the bounds of the DataView in the fixture). So it is honest either way: --force either fixes the partition and clears the line (verified - report and cursor both clear), or reproduces the real diagnostic interactively. It says "to retry", not "to fix".

Two preferences, neither blocking: a failed --force retry does not refresh the timestamp (arguably correct - the sentence describes the retry that spent the generation, and the operator saw the forced failure interactively), and "under this writer" is jargon, though the sibling #735 line sits in the same register and the actionable content survives it.

On the #737 interaction

This PR is the mitigation, not undercut by it. The failing tick still aborts the whole walk with a raw error and prints nothing - but the stamp written on the way out means that happens once per writer generation. Verified: every subsequent tick completes the walk, returns exit 0, and prints the standing line. Until #737 lands a per-partition catch, this line is exactly the durable evidence that the one-shot lost tick cannot be.

Precedence and lifecycle, verified

The verdict-outranks-error rule is enforced twice and consistently: the report branch is an else if off compactionKnownIneffective, and the reader independently suppresses on any recorded verdict - including the reduced-true late-throw case, where suppression is right because the partition genuinely converged. The stamp cannot grant a retry (read nowhere in compactionVerdictStale or the baseline gate), and it clears on every exit path: a committed rewrite writes a wholesale-fresh compaction object with no spread of the old record, and rebaselineCursor deletes it explicitly.

Deferred to #742

The daemon discards the report object, so the daemon-side surface is the span attribute alone. That is byte-for-byte the same surface compactionIneffective has had since #735, and LLP 0218 explicitly scopes this decision to report field plus CLI line plus span attribute - so widening it is a new request, not a defect here. #742 asks that whichever surface lands covers both skip reasons, so the two frozen-partition states stay symmetrical.

@philcunliffe
philcunliffe marked this pull request as ready for review August 13, 2026 10:09
@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 13, 2026
@philcunliffe
philcunliffe merged commit 0358df7 into master Aug 13, 2026
9 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-739 branch August 13, 2026 14:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approved neutral 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.

A compaction retry spent by a failed attempt is skipped silently ever after

1 participant