Skip to content

perf(ledger): prune superseded idempotency records - #672

Closed
ScriptedAlchemy wants to merge 3 commits into
cursor/simplify-pr421-hot-pathsfrom
claude/perf-ledger-prune
Closed

perf(ledger): prune superseded idempotency records#672
ScriptedAlchemy wants to merge 3 commits into
cursor/simplify-pr421-hot-pathsfrom
claude/perf-ledger-prune

Conversation

@ScriptedAlchemy

Copy link
Copy Markdown
Owner

Measured problem

The writer idempotency ledger is never pruned. Measured across seven real stores: 1,149 MB of 3,112 MB total (36%), 257,131 rows. Three freshly indexed stores were 87% idempotency ledger.

What the investigation refuted

The original hypothesis was that shard_json — single-valued across all seven stores, 182 bytes, leading PK column — was causing overflow-page padding. Measured against 5,000 real rows:

variant bytes overflow pages
current schema 23,412,736 5,000
drop shard_json 23,412,736 5,000
shard_json → integer 23,412,736 5,000
reorder PK so the selective column leads 23,412,736 5,000

Byte-identical. All three proposed fixes save exactly zero. The real cause is that the average row (~1,486 B) exceeds SQLite's 1,002-byte maxLocal at 4 KiB pages, so every row spills into its own overflow page — 257,131 rows, 257,131 overflow pages, 1:1.

The change that would actually work is replacing original_receipt_json (587 B avg) with a commit_sequence integer: measured 5,140,480 bytes, zero overflow, 78% reduction. It is not in this PR — the ledger shards sit at user_version=0, entirely outside the migration framework, so there is no mechanism to migrate existing stores. That is deliberate scope, not an oversight.

What this PR does

Prunes rows whose authority_epoch trails the checkpoint epoch for that same incarnation, fired when a commit advances the epoch.

Pruning by superseded incarnation would have reclaimed ~84% of rows and is unsound: StoreIncarnationV1 is not monotonic — non-daemon processes derive it from random process-run bytes while the daemon uses a small counter, so a CLI attach can outrank a later daemon one. Pruning by it would silently re-admit duplicate writes.

Honest caveat: this reclaims zero rows on all seven measured stores, because epochs never advance in those workloads. It bounds growth across authority transitions rather than reclaiming what is there today.

🤖 Generated with Claude Code

The writer idempotency ledger was never pruned. Measured across seven
stores it holds 1,149 MB of 3,112 MB total (36%), and 87% of three
freshly indexed stores.

Pruning by superseded incarnation would have reclaimed far more but is
unsound: StoreIncarnationV1 is not monotonic — every non-daemon process
derives it from random process-run bytes while the daemon uses a small
counter, so a CLI attach can outrank a later daemon one and pruning by
it would re-admit duplicate writes. This prunes only rows whose
authority epoch trails the checkpoint epoch for that same incarnation,
which never crosses a shard or incarnation boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 24, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 94cff8e

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8b84650119

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// the superseded records unreachable, so the prune reads it after persist.
// The record inserted below sits at the new epoch and is never eligible.
if checkpoint.supersedes_authority() {
prune::prune_superseded(transaction, &metadata.shard_id)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound pruning before running it in foreground commits

When the first write after an authority transition has hundreds of thousands of superseded rows, this call runs one shard-wide DELETE inside the user mutation's savepoint and sole SQLite writer transaction. At the motivating scale of roughly 257,000 overflow-backed rows, it can monopolize admission and generate substantial journal/WAL pressure; interruption or SQLITE_FULL rolls back the checkpoint advance, so retries repeat the same cleanup and may prevent the new epoch from committing. Move this to bounded background batches rather than coupling all retention work to the first foreground write.

AGENTS.md reference: AGENTS.md:L148-L150

Useful? React with 👍 / 👎.

Comment on lines +42 to +45
SELECT checkpoint.authority_epoch
FROM td_runtime_writer_checkpoint_v1 AS checkpoint
WHERE checkpoint.shard_json = td_runtime_writer_idempotency_v1.shard_json
AND checkpoint.incarnation = td_runtime_writer_idempotency_v1.incarnation

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict pruning to the validated checkpoint

When another incarnation on the same shard has an inconsistent checkpoint row, this subquery trusts its raw scalar authority_epoch even though checkpoint::next decoded and validated only the incarnation being advanced. For example, a scalar epoch corrupted to 999 while its watermark and receipt still encode 7 causes an unrelated valid transition to silently delete that incarnation's receipt rows; loading that checkpoint normally would instead return typed LedgerError::Corrupt. Scope the deletion to the already validated incarnation and epoch, or validate every checkpoint consulted before deleting.

AGENTS.md reference: AGENTS.md:L102-L104

Useful? React with 👍 / 👎.

ScriptedAlchemy and others added 2 commits August 24, 2026 02:17
perf(observation) landed an import of
tracedecay_runtime_core::background_cpu, but neither the module file nor
its declaration was added, so the pushed integration branch does not
compile: every branch cut from it fails on an unresolved import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Retention ran as one shard-wide DELETE inside the user mutation's
savepoint and the process's sole SQLite writer transaction. At the
measured scale - 257,131 rows across seven stores - that monopolises
admission, and an interruption or SQLITE_FULL rolls the checkpoint
advance back with it, so every retry re-attempts the same delete and the
new epoch may never commit.

Each commit now removes at most one bounded batch, so the epoch advance
commits regardless of how much backlog remains, and later commits at the
standing epoch keep draining it until it converges.

The delete also read each incarnation's raw scalar authority_epoch,
though checkpoint::next decodes and validates only the incarnation being
advanced. A neighbouring row whose scalar is corrupt - 999 while its
watermark and receipt still encode 7 - would silently retire that
incarnation's live receipts, re-admitting the duplicate writes they
exist to stop. The candidate set is now scoped to the single incarnation
whose checkpoint this commit validated, at that checkpoint's epoch; a
neighbour's records are retired by its own commits.

Pruning is still keyed on authority supersession, never on age and never
on superseded incarnation: StoreIncarnationV1 is not monotonic, so a
non-daemon attach can outrank a later daemon one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ScriptedAlchemy

Copy link
Copy Markdown
Owner Author

Both findings verified and fixed in 94cff8eff. Both were correct.

Finding 1 (P1) — unbounded pruning inside a foreground user write

Confirmed. prune_superseded issued one shard-wide DELETE inside the user mutation's savepoint and the process's sole SQLite writer transaction. The livelock you described is the real hazard: the delete and the checkpoint advance share a commit boundary, so an interruption or SQLITE_FULL rolls back the advance too, and the retry re-attempts the same delete — the new epoch never commits.

Each commit now removes at most one bounded batch (MAX_PRUNED_ROWS_PER_COMMIT = 256) via WHERE (pk cols) IN (SELECT … ORDER BY … LIMIT ?). Two consequences, both intended:

  • The epoch advance carries at most 256 deletions, so it commits independently of how much retention work remains. That is the half that closes the livelock.
  • Pruning is no longer gated on supersedes_authority() — it runs on every commit. Without that, a bounded pass would strand the remainder until the next epoch advance, which at 257k rows would need ~1000 advances. Now later commits at the standing epoch keep draining until it converges. NextCheckpoint::supersedes_authority is deleted; nothing else used it.

On "bounded background batches" specifically: I kept the batches on the writer path rather than introducing a background task. The ledger never opens or commits a connection — the writer supplies the transaction capability — and the process has a sole SQLite writer, so a background prune would contend for that same writer and need its own admission path, while a second writer transaction is not available to be had. Amortized bounded batches deliver the property the finding is actually about (foreground work is bounded; the epoch advance is not hostage to the backlog) without a second writer. Happy to revisit if you want the task boundary regardless.

A parallel local change on another branch bounded this the same way; I adopted the identical scheme and constant so the two do not diverge into competing bounding schemes.

Finding 2 (P2) — prune subquery trusting an unvalidated checkpoint

Confirmed, and this is the dangerous one. The correlated subquery read every incarnation's raw scalar authority_epoch, while checkpoint::nextloaddecode_row validates only the incarnation being advanced (cross-checking the scalar against watermark_json, original_receipt_json, scope and durability, else LedgerError::Corrupt). A neighbouring row with a scalar corrupted to 999 while its watermark and receipt still encode 7 made an unrelated valid transition delete that incarnation's live receipts — and a lost receipt silently re-admits a duplicate write, which is the exact failure the table exists to prevent.

Took the conservative fix you named: the candidate set is scoped to the single incarnation whose checkpoint this commit decoded, validated, and persisted, at that checkpoint's validated epoch. No other incarnation's checkpoint row is consulted at all. A neighbour's superseded records are retired by that neighbour's own commits, under its own validated checkpoint.

I did not take the "validate every consulted checkpoint" option: it would put a full decode of every checkpoint row on the shard onto every commit's hot path, and it fails open in the case that matters — a corrupt neighbour would abort the user's unrelated write rather than being ignored.

The new delete set is a strict subset of the old one, so this cannot delete anything the previous rule kept.

RED / GREEN

RED — new tests against the unmodified rule (the bound constant declared but not enforced by the SQL):

running 10 tests
test ledger::tests::a_pruned_record_fails_closed_rather_than_admitting_a_duplicate ... ok
test ledger::tests::a_corrupt_neighbouring_checkpoint_cannot_retire_its_receipts ... FAILED
test ledger::tests::records_are_retained_while_their_authority_still_stands ... ok
test ledger::tests::commit_uses_one_replay_and_conflict_disposition ... ok
test ledger::tests::malformed_canonical_json_fails_closed ... ok
test ledger::tests::advancing_authority_prunes_only_the_superseded_records ... ok
test ledger::tests::ledger_records_share_the_callers_transaction_boundary ... ok
test ledger::tests::runtime_effect_payloads_persist_inbox_and_ack_bookkeeping ... ok
test ledger::tests::later_commits_drain_the_remaining_superseded_backlog ... FAILED
test ledger::tests::one_foreground_commit_prunes_at_most_one_bounded_batch ... FAILED

failures:

---- ledger::tests::a_corrupt_neighbouring_checkpoint_cannot_retire_its_receipts stdout ----
panicked at crates/tracedecay-rusqlite-runtime/src/ledger/tests.rs:466:5:
an unvalidated neighbouring checkpoint must not authorise deleting that incarnation's receipts

---- ledger::tests::later_commits_drain_the_remaining_superseded_backlog stdout ----
panicked at crates/tracedecay-rusqlite-runtime/src/ledger/tests.rs:398:5:
assertion `left == right` failed: the bounded transition pass leaves the remainder of the backlog
  left: 0
 right: 1

---- ledger::tests::one_foreground_commit_prunes_at_most_one_bounded_batch stdout ----
panicked at crates/tracedecay-rusqlite-runtime/src/ledger/tests.rs:357:5:
assertion `left == right` failed: one foreground commit removes at most one bounded batch of superseded records, never the whole backlog
  left: 0
 right: 2

test result: FAILED. 7 passed; 3 failed; 0 ignored; 0 measured; 295 filtered out; finished in 0.04s

left: 0 in both bound tests is the finding stated numerically: the foreground commit deleted the entire seeded backlog (258 and 257 rows) in one statement.

GREEN — same tests after the fix:

running 10 tests
test ledger::tests::commit_uses_one_replay_and_conflict_disposition ... ok
test ledger::tests::advancing_authority_prunes_only_the_superseded_records ... ok
test ledger::tests::a_corrupt_neighbouring_checkpoint_cannot_retire_its_receipts ... ok
test ledger::tests::malformed_canonical_json_fails_closed ... ok
test ledger::tests::a_pruned_record_fails_closed_rather_than_admitting_a_duplicate ... ok
test ledger::tests::records_are_retained_while_their_authority_still_stands ... ok
test ledger::tests::ledger_records_share_the_callers_transaction_boundary ... ok
test ledger::tests::runtime_effect_payloads_persist_inbox_and_ack_bookkeeping ... ok
test ledger::tests::one_foreground_commit_prunes_at_most_one_bounded_batch ... ok
test ledger::tests::later_commits_drain_the_remaining_superseded_backlog ... ok

test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 295 filtered out; finished in 0.09s

Whole crate, cargo test -p tracedecay-rusqlite-runtime --locked: 305 lib tests + every integration suite pass, 0 failures. cargo check -p tracedecay-rusqlite-runtime --all-targets --locked and cargo fmt --check are clean.

The bound assertions are on batch counts, not elapsed time: seed MAX + 2, advance the epoch once, assert exactly 2 superseded rows survive; then a second seed of MAX + 1 asserts 1 survives the transition commit and 0 survive the next commit at the standing epoch.

What did not change

  • Pruning is still keyed on authority supersession only — never on age (idempotency keys are content-derived, so a resubmission can arrive arbitrarily later), and never on superseded incarnation. StoreIncarnationV1 is not monotonic: non-daemon processes derive it from random process-run bytes while the daemon uses a small counter, so a CLI attach can outrank a later daemon one. Pruning that way would reclaim far more and be unsound.
  • The honest claim stands unchanged: this reclaims zero rows on all seven measured stores, because epochs never advance in those workloads. It bounds growth across authority transitions; it is not a reclamation of the existing 1,149 MB.
  • a_pruned_record_fails_closed_rather_than_admitting_a_duplicate still passes: a submission whose record was pruned fails closed on stale authority rather than being admitted as new.

04ff619b2 (fix(runtime-core): commit the missing background CPU module) is on the branch because the base does not compile without it.

@ScriptedAlchemy

Copy link
Copy Markdown
Owner Author

Superseded by the cleaned and pushed #663 stack at 6f1a568; the coherent production fix and its required tests are now carried by the base branch.

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