Stop self-check/publish-history from mutating history - #3878
Conversation
Adds a process-boundary test that seeds a DB with a durable LCL and ahead-of-LCL ledgerheaders/txhistory rows (mirroring an in-flight catchup that persisted its batch per #3827), runs `henyey self-check` as a subprocess, then reopens the DB and asserts the ahead-of-LCL rows survive. Fails on main: cmd_self_check calls cleanup_ahead_of_lcl() first, deleting the rows and leaving a permanent history hole once catchup advances LCL. Refs #3870 Co-authored-by: Claude Code <claude-code@anthropic.com>
#3868 wired the destructive cleanup_ahead_of_lcl() into the self-check and publish-history CLI subcommands — read-oriented diagnostics that take no single-instance lock. Running either while a catchup is persisting ahead-of-LCL rows (#3827) deleted those rows; once catchup advanced the LCL past them the range became a permanent history hole. Add a read-only Database::durable_read_anchor() that returns the durable LCL (falling back to MAX(ledgerseq) for fresh/legacy DBs, else None), and have both CLI paths anchor their reads there instead of deleting rows. The startup path (app/mod.rs) keeps cleanup_ahead_of_lcl() — it runs under the held db lock where single-instance is guaranteed. Side benefit: self-check is now safe to run against a live node (read-only, LCL-anchored). Refs #3870, #3812, #3827, #3868 Co-authored-by: Claude Code <claude-code@anthropic.com>
🔍 Reviewer: CorrectnessVerdict: APPROVE Summary: Correct, minimal fix. It implements the issue's Option 2 (read-side anchoring): a new read-only Full reviewCycle 1 (no prior Correctness — confirmed:
Test-verification gate (bug-fix ⇒ regression test that fails pre-fix):
No correctness blockers. |
🔍 Reviewer: RiskVerdict: APPROVE Summary: Net risk-reducing change. It removes an unguarded destructive DB mutation ( Full reviewCycle 1 (no prior
No risk blockers. |
| // LCL-durable state, never ahead-of-LCL rows. | ||
| let current_ledger = db | ||
| .get_latest_ledger_seq()? | ||
| .durable_read_anchor()? |
There was a problem hiding this comment.
test-coverage (non-blocking): publish-history now anchors via durable_read_anchor() instead of cleanup_ahead_of_lcl(), but unlike self-check it has no process-boundary regression test. Reverting just this line to get_latest_ledger_seq()/cleanup_ahead_of_lcl() would fail no test — the same wiring-seam gap #3868 was faulted for. The shared durable_read_anchor() unit tests plus the self-check subprocess test cover the mechanism, so this is not blocking, but a symmetric subprocess test for publish-history (or a follow-up issue) would close the gap the issue explicitly calls out.
There was a problem hiding this comment.
Addressed in e4a4f80: added crates/henyey/tests/publish_history_preserves_ahead_of_lcl.rs, a process-boundary regression test symmetric to the self-check one. It seeds a DB with rows ahead of the durable LCL, runs henyey publish-history as a subprocess (validator config + one writable local file:// archive so it reaches the anchor step), and asserts the ahead-of-LCL ledgerheaders/txhistory rows survive and MAX(ledgerseq) is unchanged. Verified it FAILS on the pre-fix code (reintroducing cleanup_ahead_of_lcl() deletes 10/10 ahead-of-LCL rows) and PASSES after the durable_read_anchor() fix — so reverting publish_history.rs now fails a test, closing the wiring-seam gap.
Review: Bounce-Back Cycle 1Reason: CI failed (unrelated, will rebase) Reviewer A (Correctness): APPROVE — fix is correct; anchor value equals the pre-fix effective anchor minus the mutation; regression + unit tests present and green in CI. Why this is not diff-attributable: the diff only touches the Action: Routing back to |
Add a publish-history process-boundary regression test symmetric to the self-check one, closing the second wiring seam the linked issue faults #3868 for leaving untested. The test seeds a DB with rows ahead of the durable LCL, runs `henyey publish-history` as a subprocess, and asserts the ahead-of-LCL rows survive. Verified it FAILS on the pre-fix code (cleanup_ahead_of_lcl deletes the rows before publishing) and PASSES after the durable_read_anchor fix. Reverting publish_history.rs to cleanup_ahead_of_lcl() now fails a test. Refs #3870 Co-authored-by: Claude Code <claude-code@anthropic.com>
tomerweller
left a comment
There was a problem hiding this comment.
Addressed the non-blocking inline note: added a publish-history process-boundary regression test symmetric to the self-check one (crates/henyey/tests/publish_history_preserves_ahead_of_lcl.rs), verified failing pre-fix and passing post-fix. fmt/clippy/both regression tests green locally. CI on the prior push was already fully green (the earlier bounce was a testnet-shard infra flake, now cleared). Ready for re-review.
🔍 Reviewer: CorrectnessVerdict: APPROVE Summary: Cycle 2. The fix is unchanged and correct (read-only Full reviewCycle N≥2 — prior
No new class discovered. No correctness blockers. |
🔍 Reviewer: RiskVerdict: APPROVE Summary: Cycle 2. Net risk-reducing: removes an unguarded destructive DB mutation from two lock-free read-only CLI subcommands. No observable/interop-surface change (non-parity crates Full reviewCycle N≥2 — prior
No new class discovered. No risk blockers. |
Review: Unrelated CI Red Persists After Rebase — Blocking for OperatorBounce count (head-scoped): 0 on current head (cycle-1 bounce predates the current commit Why blocked, not bounced again: This is the second Not diff-attributable: the diff touches only Operator options:
To retry through the pipeline after the shard clears (or to reset the bounce counters), post |
Closes #3870
Summary
#3868 wired the destructive
cleanup_ahead_of_lcl()into theself-checkandpublish-historyCLI subcommands — read-oriented diagnostics that take no single-instance lock. Running either while a catchup is persisting ahead-of-LCL rows (#3827) deleted those legitimately-ahead rows; once catchup advanced the durable LCL past them, the range became a permanent history hole — the exact silent data loss #3811/#3827 set out to eliminate.This PR takes the issue's preferred Option 2 (read-side anchoring): it removes the mutation from the CLI paths entirely rather than guarding it. A new read-only
Database::durable_read_anchor()returns the durable LCL when present, falling back toMAX(ledgerseq)only for fresh/legacy DBs (mirroringcleanup_ahead_of_lcl'sNonebranch), elseNonefor an empty DB. Both CLI paths anchor their reads there, so ahead-of-LCL rows are never observed and never deleted. The startup path (crates/app/src/app/mod.rs) keepscleanup_ahead_of_lcl()unchanged — it runs under the held db lock where single-instance is guaranteed. Side benefit:self-checkis now safe to run against a live node.Plan reference
Converged Plan comment
Test plan
cargo fmt --checkcargo clippy --all -- -D warningscargo test -p henyey-db(incl. 3 newdurable_read_anchorunit tests) passescargo test -p henyey(incl. new subprocess regression test) passescargo test -p henyey-app test_startup_cleanup_truncates_ahead_of_lcl_history(retained startup path) passesRegression test (kind: bug-fix)
crates/henyey/tests/self_check_preserves_ahead_of_lcl.rs::self_check_does_not_delete_ahead_of_lcl_rows7205bdc— verified FAILED: self-check reported "Removed 20 ahead-of-LCL history row(s)" and the reopened DB had 0 rows above LCL (expected 10).5ce3436— the CLI no longer mutates;ledgerheadersandtxhistoryrows above LCL survive andMAX(ledgerseq)is unchanged.Deviations from plan
None. The
publish-historypath is covered at the shareddurable_read_anchorcontract level (its archive-config precondition bails before the anchor, making a dedicated subprocess test heavy); the anchor swap is line-identical toself-check's — as anticipated in the converged plan's minor-items note.🤖 Generated with Claude Code