Skip to content

perf(ledger): store commit sequence, not the receipt - #683

Closed
ScriptedAlchemy wants to merge 1 commit into
cursor/simplify-pr421-hot-pathsfrom
claude/perf-ledger-row-width
Closed

perf(ledger): store commit sequence, not the receipt#683
ScriptedAlchemy wants to merge 1 commit into
cursor/simplify-pr421-hot-pathsfrom
claude/perf-ledger-row-width

Conversation

@ScriptedAlchemy

Copy link
Copy Markdown
Owner

What

td_runtime_writer_idempotency_v1 replaces its 587-byte original_receipt_json
column with a commit_sequence integer, and gains a transactional in-place
upgrade so existing stores move to the narrow shape.

Why the receipt is reconstructible

This is the load-bearing claim, so it is argued twice — structurally and
empirically.

Structurally. StoreCommitReceiptV1 is #[serde(deny_unknown_fields)] over
exactly seven fields. Six were already columns of this same row:

receipt field column
operation_id operation_id
idempotency.key idempotency_key
idempotency.command_digest request_digest
shard_id shard_json
incarnation incarnation
authority_epoch authority_epoch
committed_at committed_at_micros
commit_sequence nothing

decode_row already required every one of those equalities and answered
LedgerError::Corrupt otherwise, so the encoded receipt was never authority for
anything except commit_sequence. The reconstruction is that same check run
backwards. Four of the six — shard, incarnation, authority epoch, idempotency
key — are the primary key the row was matched on, so they are the caller's own
arguments by construction.

Empirically. Every real row on this machine was rebuilt byte-for-byte from
the six columns plus the sequence:

proj_099ef1e4af1c6508/sessions.db: rows=24558 reconstruct_mismatches=0 unexpected_key_shape=0
proj_7b2798f1d4448b5d/sessions.db: rows=40003 reconstruct_mismatches=0 unexpected_key_shape=0
proj_a5b3d7e3ebe14ca7/sessions.db: rows=94313 reconstruct_mismatches=0 unexpected_key_shape=0
.tracedecay/user-sessions.db:      rows=65634 reconstruct_mismatches=0 unexpected_key_shape=0

TOTAL rows scanned:            224508
byte-exact reconstruct FAILED: 0
receipts with unexpected keys: 0
RESULT: commit_sequence is the ONLY field not already a column.

Why the row was spilling

maxLocal for an index b-tree at 4 KiB pages is ((4096-12)*64/255)-23 = 1002
bytes. Measured payloads, and the projection after the change:

BEFORE (payload incl. record header)          AFTER (projected)
proj_099ef1e4af1c6508  avg=1486 max=1493      min=895 avg=899 max=902  over_maxlocal=0
proj_7b2798f1d4448b5d  avg=1478 max=1493      min=895 avg=895 max=902  over_maxlocal=0
proj_a5b3d7e3ebe14ca7  avg=1485 max=1493      min=895 avg=898 max=902  over_maxlocal=0
user-sessions          avg=1372 max=1382      min=821 avg=823 max=828  over_maxlocal=0

Every one of the 224,508 rows was over maxLocal before and none is after. The
dbstat page census showed overflow pages exactly 1:1 with rows in all four
stores (24,558 / 40,003 / 94,313 / 65,634), each holding ~1,005 bytes in a
4,096-byte page — 688 MB of pure slack.

Note the remaining headroom is ~100 bytes, and the largest remaining column is
transaction_scope_json at 456 bytes, which re-encodes the binding and
durability this row already carries. Normalising it (and shard_json) is the
obvious follow-up, but it is a separate change and this one has to land first.

Why the migration is safe

The ledger shards sit at user_version = 0, outside the schema framework, and
share their file with ~207 unrelated tables. Claiming that file-global pragma
for the ledger would let a stamp disagree with the shape it describes, so
the shape is the version: the presence of original_receipt_json is the old
one, read from pragma_table_info, which is derived from the same schema record
the queries compile against and therefore cannot drift from what is there.

  • Interrupted migration. The rewrite runs entirely inside the writer's
    existing transaction, and SQLite DDL is transactional. A crash rolls back to
    the old table including its column set, so the next open re-detects the old
    shape and retries. There is no state outside the transaction that could
    disagree with it, so there is no half-migrated state to detect.
  • Concurrent access. One writer actor owns one shard, and SQLite serializes
    writers across processes. A racing process either waits for the exclusive lock
    and observes the new shape, or is refused SQLITE_BUSY having changed nothing.
  • Older binary afterwards. Its SELECT names original_receipt_json, which
    no longer exists, so the statement fails to prepare. The direction matters:
    it is refused, never told "no such row", so it cannot mistake an unreadable
    shape for an absent record and admit a duplicate. Pinned by
    an_older_binary_is_refused_by_a_migrated_store_rather_than_misreading_it.
    This is forward-only, consistent with the framework's own stance that a store
    at an unexpected shape "is refused at open".
  • Unmigratable row. A commit sequence that is not a positive integer aborts
    the migration with the same Corrupt class that reading that row already
    raised, leaving the store byte-identical at the old shape. typeof is checked
    explicitly because SQLite orders text above every integer, so a bare > 0
    would have let "not-a-number" through. Dropping such a row instead would
    silently shrink the set of submissions the ledger recognises — the one outcome
    that could admit a duplicate write.
  • Row count is verified after the copy; a short copy fails closed.
  • No view, trigger, or index depends on the table (checked against a real
    store), so the DROP/RENAME swap has no dependents.

Measured, on a copy of a real 94,366-row store

The exact statements the migration runs, applied to a copy of a live store:

--- before ---
  rows=94366
  ledger_bytes=442458112 overflow_pages=94366
  db_bytes=1604157440
--- migrating ---
  unmigratable_rows=0
--- after ---
  rows=94366
  ledger_bytes=96649216 overflow_pages=0
  db_bytes=1239306240

--- receipt equivalence over the whole real store ---
  expected receipts: 94366
  rebuilt receipts:  94366
  RESULT: every receipt round-trips byte-for-byte through the migration.

Ledger 442,458,112 → 96,649,216 bytes (78.2%, 345,808,896 saved), overflow
pages 94,366 → 0, whole store down 22.7%, every receipt unchanged.

RED / GREEN

RED, with the migration neutered to an early Ok(()) (the "row-width fix with
no migration mechanism" state):

running 6 tests
test ledger::migrate::tests::an_unmigratable_receipt_fails_closed_without_dropping_the_row ... FAILED
test ledger::migrate::tests::an_interrupted_migration_leaves_the_legacy_store_untouched_and_retries ... FAILED
test ledger::migrate::tests::a_migrated_duplicate_still_replays_instead_of_committing_again ... FAILED
test ledger::migrate::tests::migrating_an_already_narrow_store_is_a_no_op ... ok
test ledger::migrate::tests::migration_preserves_the_exact_receipt_a_legacy_store_recorded ... FAILED
test ledger::migrate::tests::the_narrow_shape_removes_the_per_row_overflow_page ... ok

---- a_migrated_duplicate_still_replays_instead_of_committing_again stdout ----
called `Result::unwrap()` on an `Err` value: Sqlite(SqlInputError { ...
  msg: "no such column: commit_sequence", ... })

---- migration_preserves_the_exact_receipt_a_legacy_store_recorded stdout ----
assertion failed: !has_column(&connection, "original_receipt_json")

---- an_interrupted_migration_leaves_the_legacy_store_untouched_and_retries stdout ----
assertion failed: !has_column(&transaction, "original_receipt_json")

---- an_unmigratable_receipt_fails_closed_without_dropping_the_row stdout ----
assertion failed: matches!(initialize_schema(&transaction), Err(LedgerError::Corrupt { .. }))

test result: FAILED. 2 passed; 4 failed; 0 ignored; 0 measured; 299 filtered out

That third failure is itself the shape-mismatch evidence: a mismatch is a hard
SQL refusal, never a silent miss.

GREEN:

running 12 tests
test repository::fact::tests::fact_executor_does_not_claim_replay_without_writer_ledger ... ok
test ledger::tests::commit_uses_one_replay_and_conflict_disposition ... ok
test ledger::tests::malformed_canonical_json_fails_closed ... ok
test ledger::migrate::tests::migrating_an_already_narrow_store_is_a_no_op ... ok
test ledger::migrate::tests::an_unmigratable_receipt_fails_closed_without_dropping_the_row ... ok
test ledger::migrate::tests::an_older_binary_is_refused_by_a_migrated_store_rather_than_misreading_it ... ok
test ledger::tests::ledger_records_share_the_callers_transaction_boundary ... ok
test ledger::migrate::tests::migration_preserves_the_exact_receipt_a_legacy_store_recorded ... ok
test ledger::migrate::tests::a_migrated_duplicate_still_replays_instead_of_committing_again ... ok
test ledger::tests::runtime_effect_payloads_persist_inbox_and_ack_bookkeeping ... ok
test ledger::migrate::tests::an_interrupted_migration_leaves_the_legacy_store_untouched_and_retries ... ok
rows=500 legacy_bytes=2314240 narrow_bytes=466944 saved=1847296 reduction=79%
test ledger::migrate::tests::the_narrow_shape_removes_the_per_row_overflow_page ... ok

test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 294 filtered out; finished in 0.12s

The migration tests start from LEGACY_IDEMPOTENCY_DDL, the pre-change table
definition reproduced verbatim, with receipts encoded by the same serializer
production used — a real pre-migration shape, not something the new code could
have written.

Duplicate-admission behaviour is covered directly:
a_migrated_duplicate_still_replays_instead_of_committing_again seeds a legacy
row, migrates, then asserts a same-key/same-digest retry still Replays the
original receipt, a same-key/different-digest submission still Conflicts, and
that neither added a row.

Full crate suite: 440 tests across 20 binaries, all passing. No assertion
anywhere is on elapsed time.

Checks

cargo check -p tracedecay-rusqlite-runtime --all-targets --locked   # clean
cargo clippy -p tracedecay-rusqlite-runtime --all-targets --locked  # no warnings
cargo test -p tracedecay-rusqlite-runtime --locked                  # all pass

Per-crate only, as requested.

Relationship to the pruning rule

perf(ledger): prune superseded idempotency records is not on this base branch.
The two are complementary and near-disjoint — prune deletes unreachable rows,
this narrows the rows that remain. On merge the only textual overlaps are two
adjacent lines in commit.rs (prune inserts a prune_superseded call directly
above the idempotency::insert call whose receipt_json argument this removes)
and separate test functions in tests.rs; the migration tests were deliberately
placed in migrate.rs to keep tests.rs conflict-free. Nothing in this change
touches prune.rs's DELETE, which selects on shard_json and
authority_epoch only.

Scope deviation

  • tests.rs::malformed_canonical_json_fails_closed and
    tests/runtime_actor/faults.rs both corrupted original_receipt_json to
    assert fail-closed behaviour. That column no longer exists, so both were
    retargeted to transaction_scope_json, which is still an encoded column, to
    preserve each test's intent.
  • commit.rs no longer encodes the receipt to JSON, so its now-unused
    encode_json import was dropped.
  • Not done, deliberately: transaction_scope_json and shard_json
    normalisation (the further ~20%), and any change to
    td_runtime_writer_checkpoint_v1, which holds one row per shard/incarnation
    and is not a size problem.

🤖 Generated with Claude Code

The writer idempotency ledger averages 1,486 bytes per row, which exceeds
the 1,002-byte maxLocal of a 4 KiB index b-tree page, so every row spills
into an overflow page of its own. Across four real stores that is 224,508
rows and 224,508 overflow pages holding ~1,005 bytes each: 1,004 MB of
3,228 MB, and 77% of one store.

The 587-byte original_receipt_json column was the cause and was already
redundant. StoreCommitReceiptV1 is deny_unknown_fields over seven fields
and six were already columns; decode_row required every one of those
equalities and failed closed otherwise, so the encoded receipt was never
authority for anything but its commit_sequence. Replacing it with that
integer keeps the receipt exactly reconstructible and drops the row under
maxLocal. Verified byte-for-byte over all 224,508 real rows.

The ledger shards sit outside the schema framework at user_version 0 and
share their file with 200-odd unrelated tables, so the shape itself is the
version: the presence of original_receipt_json is the old one. The upgrade
runs in the writer's own transaction, which makes an interruption a
rollback to a shape that re-triggers it, and a row whose commit sequence
is not a positive integer aborts it with the same Corrupt class that
reading the row already raised, rather than dropping a record the ledger
must still recognise.

Measured on a copy of a real 94,366-row store: the ledger falls from
442,458,112 to 96,649,216 bytes with zero overflow pages, the store from
1,604,157,440 to 1,239,306,240, and every receipt round-trips unchanged.

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: 2cc123c

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: 2cc123c8dc

ℹ️ 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".

Comment on lines +71 to +72
WHERE typeof(json_extract(original_receipt_json, '$.commit_sequence')) <> 'integer'
OR json_extract(original_receipt_json, '$.commit_sequence') <= 0

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 Validate every legacy receipt before dropping it

When a legacy row contains syntactically valid JSON with a positive commit_sequence but an incomplete receipt such as {"commit_sequence":7}, or a full receipt whose operation, digest, binding, or timestamp disagrees with the duplicated columns, this preflight passes and the migration permanently drops the evidence of corruption. Before this commit, decode_row decoded the complete canonical StoreCommitReceiptV1 and checked every duplicated field; afterward it synthesizes a valid receipt from the columns and can return Replay/Conflict instead of failing closed. Validate and compare the complete legacy receipt before projecting it to the narrow table.

AGENTS.md reference: AGENTS.md:L126-L128

Useful? React with 👍 / 👎.

@ScriptedAlchemy

Copy link
Copy Markdown
Owner Author

Integrated directly into PR #421 as f55a1d6, resolving the ledger conflict by preserving the already-landed bounded 256-row superseded-record pruning while adopting the narrower commit-sequence representation.

@ScriptedAlchemy

Copy link
Copy Markdown
Owner Author

Post-merge review found two blockers in the integrated migration: it copies the full legacy table on the first foreground writer transaction, defeating bounded pruning, and it drops original_receipt_json after validating only commit_sequence rather than all legacy receipt invariants. The integration was reverted from PR #421 in the follow-up revert commit; this PR remains closed pending a bounded/background migration with full corruption validation.

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