Skip to content

Stop self-check/publish-history from mutating history - #3878

Open
tomerweller wants to merge 3 commits into
mainfrom
do/issue-3870
Open

Stop self-check/publish-history from mutating history#3878
tomerweller wants to merge 3 commits into
mainfrom
do/issue-3870

Conversation

@tomerweller

Copy link
Copy Markdown
Collaborator

Closes #3870

Summary

#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 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 to MAX(ledgerseq) only for fresh/legacy DBs (mirroring cleanup_ahead_of_lcl's None branch), else None for 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) keeps cleanup_ahead_of_lcl() unchanged — 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.

Plan reference

Converged Plan comment

Test plan

  • cargo fmt --check
  • cargo clippy --all -- -D warnings
  • cargo test -p henyey-db (incl. 3 new durable_read_anchor unit tests) passes
  • cargo test -p henyey (incl. new subprocess regression test) passes
  • cargo test -p henyey-app test_startup_cleanup_truncates_ahead_of_lcl_history (retained startup path) passes

Regression test (kind: bug-fix)

  • Test: crates/henyey/tests/self_check_preserves_ahead_of_lcl.rs::self_check_does_not_delete_ahead_of_lcl_rows
  • Pre-fix: committed as 7205bdc — verified FAILED: self-check reported "Removed 20 ahead-of-LCL history row(s)" and the reopened DB had 0 rows above LCL (expected 10).
  • Post-fix: verified PASSES after 5ce3436 — the CLI no longer mutates; ledgerheaders and txhistory rows above LCL survive and MAX(ledgerseq) is unchanged.

Deviations from plan

None. The publish-history path is covered at the shared durable_read_anchor contract level (its archive-config precondition bails before the anchor, making a dedicated subprocess test heavy); the anchor swap is line-identical to self-check's — as anticipated in the converged plan's minor-items note.

🤖 Generated with Claude Code

Tomer Weller and others added 2 commits August 14, 2026 03:36
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>
@tomerweller tomerweller added the pdr-managed PR opened by the henyey project-tick pipeline /do skill label Aug 14, 2026
@tomerweller

Copy link
Copy Markdown
Collaborator Author

🔍 Reviewer: Correctness

Verdict: APPROVE

Summary: Correct, minimal fix. It implements the issue's Option 2 (read-side anchoring): a new read-only Database::durable_read_anchor() replaces the destructive cleanup_ahead_of_lcl() call in both CLI reader paths. The returned anchor value is semantically identical to the pre-fix code's effective anchor (LCL when a durable LCL exists, else MAX(ledgerseq), else None) — only the mutation is removed. Well-tested at both the unit and process-boundary level; CI's Test/Doctest/Clippy/Build checks are green.

Full review

Cycle 1 (no prior ## 🔍 Reviewer: Correctness comment on this PR) — complete, class-labeled change-list below.

Correctness — confirmed:

  • durable_read_anchor() (crates/db/src/database/mod.rs) mirrors cleanup_ahead_of_lcl()'s branch structure exactly: Some(lcl) when get_last_closed_ledger() is present, else get_latest_ledger_seq() (MAX), else None. The old CLI flow was cleanup_ahead_of_lcl() (delete rows above LCL) then get_latest_ledger_seq() — which, post-delete, returns LCL when an LCL exists and MAX otherwise. So the new anchor value equals the old effective anchor for every branch; the only behavioral change is the removal of the DELETE side-effect and its "Removed N rows" print. This is exactly the intended fix.
  • Anchoring at the durable LCL (rather than MAX) is the safe lower bound during an in-flight catchup: LCL <= MAX(ledgerseq) holds because the LCL's header row is durably stored in the same close transaction. Ahead-of-LCL rows persisted by Persist catchup batch history before advancing LCL #3827 are now neither observed nor deleted.
  • Ahead-of-LCL rows are still swept at node startup (crates/app/src/app/mod.rs, unchanged), so the fix removes the destructive op only from the two unguarded CLI paths — no functional regression to the cleanup guarantee where a single-instance lock is implied.

Test-verification gate (bug-fix ⇒ regression test that fails pre-fix):

  • crates/henyey/tests/self_check_preserves_ahead_of_lcl.rs runs henyey self-check as a real subprocess against a DB seeded with rows ahead of the durable LCL and asserts ledgerheaders/txhistory rows above LCL survive and MAX(ledgerseq) is unchanged. This provably fails at the pre-fix commit (where cmd_self_check calls cleanup_ahead_of_lcl() first) and passes after — it covers the wiring seam the linked issue faults Truncate ahead-of-LCL history rows on restart and in CLI readers #3868 for leaving untested.
  • Three focused unit tests cover durable_read_anchor: LCL-present + non-mutation, no-LCL fallback to MAX, and empty-DB None.

test-coverage (non-blocking — filed inline): The regression coverage is asymmetric. self-check's wiring seam has a process-boundary test, but publish-history's does not — reverting crates/henyey/src/publish_history.rs to cleanup_ahead_of_lcl() would fail no test, which is the same wiring-seam gap the issue criticizes in #3868. This is a genuine gap but non-blocking here because (a) the shared durable_read_anchor() method has direct non-mutation unit coverage and (b) the publish-history change is a line-for-line mirror of the covered self-check change. Left as an inline note so it is picked up as follow-up / next /do pass rather than blocking a data-loss fix.

No correctness blockers.

@tomerweller

Copy link
Copy Markdown
Collaborator Author

🔍 Reviewer: Risk

Verdict: APPROVE

Summary: Net risk-reducing change. It removes an unguarded destructive DB mutation (cleanup_ahead_of_lcl → DELETE) from two read-oriented CLI subcommands and replaces it with a pure read. No observable/interop surface is altered: the published history archive contents and the self-check verification range are still bounded at the durable LCL, exactly as before. Non-parity crates only (db, henyey), so no docs/PARITY.md hash/XDR/wire concerns apply.

Full review

Cycle 1 (no prior ## 🔍 Reviewer: Risk comment) — complete class-labeled list.

regression-risk — none identified:

  • The two CLI paths (self-check, publish-history) previously deleted ahead-of-LCL rows then anchored at MAX(ledgerseq) (== LCL post-delete). They now anchor directly at the durable LCL with no delete. The anchor value is unchanged, so downstream behavior (what self-check verifies, what publish-history publishes) is identical for the steady state and safer during catchup.
  • The destructive sweep is still performed at node startup (app/mod.rs), where single-instance is implied. Removing it from the CLI paths does not lose the cleanup guarantee; it relocates it to the only place it can run safely.

data-format / observable-surface — no break:

  • publish-history writes history-archive files. Previously it published checkpoints at/below LCL (after deleting above). Now it publishes at/below LCL (via the anchor). Archive format and contents are unchanged. No SCP/overlay/XDR/hash surface is touched.
  • The only user-visible output delta is the removal of the "Removed N ahead-of-LCL history row(s)" line — a diagnostic print on an admin/debug CLI path, explicitly allowed to deviate per docs/PARITY.md.

operational — strictly improved:

  • This directly closes the silent-data-loss window the issue describes: running self-check (the natural thing to do when a node looks unhealthy — i.e. when a catchup is most likely in flight) can no longer punch a permanent history hole. Removing a destructive op from a lock-free path is the correct operational posture.

efficiency — neutral/positive: two indexed point-reads replace a transaction with multiple DELETEs; cheaper.

No risk blockers.

// LCL-durable state, never ahead-of-LCL rows.
let current_ledger = db
.get_latest_ledger_seq()?
.durable_read_anchor()?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

@tomerweller

Copy link
Copy Markdown
Collaborator Author

Review: Bounce-Back Cycle 1

Reason: 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.
Reviewer B (Risk): APPROVE — removes an unguarded destructive DB op from two read-only CLI paths; no observable/interop-surface change; strictly risk-reducing.
External reviewers: none.
CI: red — but the only non-green check is test (testnet, core,horizon, …), which ran ~55 min and was cancelled by the diagnostic hang-watchdog (#3286) on the soft-on-timeout testnet shard (#3272). No branch-protection required checks are configured, and every other check (Test, Doctest, Clippy, Build, CodeQL, all local/pubnet integration shards) is green.

Why this is not diff-attributable: the diff only touches the self-check/publish-history CLI history-anchoring paths (crates/db, crates/henyey). The failing shard runs Go integration tests (test_core.go, test_horizon_*.go) against a testnet quickstart image and exercises none of the changed code. This is a testnet-connectivity infra flake.

Action: Routing back to ready-for-doing. /do Mode B should rebase on origin/main and push to re-trigger CI (the testnet shard typically clears on a fresh run). While rebasing, please also address the single non-blocking inline note: add a publish-history process-boundary regression test symmetric to the self-check one, to close the wiring-seam gap the linked issue calls out. If the testnet shard is still red after a clean rebase, the next /review-pr will escalate to blocked for operator review.

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 tomerweller left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

@tomerweller

Copy link
Copy Markdown
Collaborator Author

🔍 Reviewer: Correctness

Verdict: APPROVE

Summary: Cycle 2. The fix is unchanged and correct (read-only durable_read_anchor() replacing the destructive cleanup_ahead_of_lcl() in both CLI paths; startup sweep untouched). The one non-blocking test-coverage inline note from cycle 1 — the untested publish-history wiring seam — is now closed by the new process-boundary test. No new concern classes.

Full review

Cycle N≥2 — prior ## 🔍 Reviewer: Correctness verdict was APPROVE with a single inline test-coverage note. Re-verifying prior classes; sticking to them.

  • test-coverage (prior inline note — RESOLVED): cycle 1 flagged that reverting crates/henyey/src/publish_history.rs to cleanup_ahead_of_lcl() would fail no test (the same wiring-seam gap the issue faults Truncate ahead-of-LCL history rows on restart and in CLI readers #3868 for). Commit e4a4f80 adds crates/henyey/tests/publish_history_preserves_ahead_of_lcl.rs, a subprocess regression test symmetric to the self-check one: it seeds ahead-of-durable-LCL rows, runs henyey publish-history, and asserts they survive. Both wiring seams now have process-boundary coverage.
  • Correctness (re-confirmed): durable_read_anchor() mirrors cleanup_ahead_of_lcl()'s branch structure (Some(lcl) when a durable LCL exists, else MAX(ledgerseq), else None), so the anchor value equals the pre-fix effective anchor minus the DELETE side-effect. Startup sweep in crates/app/src/app/mod.rs is unchanged.
  • Test-verification gate (bug-fix): regression test provably fails pre-fix (7205bdc) and passes post-fix (5ce3436); three durable_read_anchor unit tests cover LCL-present/non-mutation, no-LCL→MAX fallback, and empty-DB None.

No new class discovered. No correctness blockers.

@tomerweller

Copy link
Copy Markdown
Collaborator Author

🔍 Reviewer: Risk

Verdict: 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 db, henyey only). The added publish-history regression test only strengthens coverage. No new concern classes.

Full review

Cycle N≥2 — prior ## 🔍 Reviewer: Risk verdict was APPROVE. Re-verifying prior classes.

  • regression-risk — none: both CLI paths previously deleted ahead-of-LCL rows then anchored at MAX(ledgerseq) (== LCL post-delete); they now anchor directly at the durable LCL with no delete. Anchor value unchanged; behavior identical in steady state and safer during catchup. The destructive sweep is retained only at node startup where single-instance is implied.
  • data-format / observable-surface — no break: publish-history still publishes checkpoints at/below LCL; archive format/contents unchanged. The only user-visible delta is the removal of the "Removed N ahead-of-LCL history row(s)" diagnostic print — an admin/debug CLI line explicitly allowed to deviate per docs/PARITY.md.
  • operational — improved: closes the silent-data-loss window (running self-check on a suspect node mid-catchup can no longer punch a permanent history hole).

No new class discovered. No risk blockers.

@tomerweller

Copy link
Copy Markdown
Collaborator Author

Review: Unrelated CI Red Persists After Rebase — Blocking for Operator

Bounce count (head-scoped): 0 on current head (cycle-1 bounce predates the current commit e4a4f80); lifetime: 1.
Reviewer A (Correctness): APPROVE — fix correct; anchor value equals pre-fix effective anchor minus the mutation; prior inline test-coverage note now resolved by the new publish-history regression test.
Reviewer B (Risk): APPROVE — removes an unguarded destructive DB op from two lock-free CLI paths; no observable/interop-surface change; strictly risk-reducing.
External reviewers: tomerweller: COMMENTED (neutral, non-blocking).
CI: red — 43/44 checks green; the sole non-green check is the test (testnet, core,horizon, …) shard, whose entire Quickstart workflow run was cancelled (soft-timeout testnet shard #3272, hang-watchdog #3286).

Why blocked, not bounced again: This is the second /review-pr cycle. Cycle 1 (both reviewers APPROVE) bounced solely for this same unrelated testnet shard and asked /do to rebase/re-push. /do did so (commit e4a4f80, which also added the requested publish-history regression test), re-triggering CI. The Quickstart/testnet shard cancelled again — it is now cancelled on both 5ce34362 (Aug 14) and e4a4f80 (Aug 18), two pushes four days apart, while the CI and Gitlinks workflows are green on both. Per the /review-pr matrix ("unrelated CI red — if still red after rebase, the next /review-pr marks blocked"), this escalates to blocked rather than churning further.

Not diff-attributable: the diff touches only crates/db (durable_read_anchor) and crates/henyey (self-check/publish-history read anchoring). The failing shard runs Go integration tests (test_core.go, test_horizon_*.go) against a testnet quickstart image and exercises none of the changed code.

Operator options:

To retry through the pipeline after the shard clears (or to reset the bounce counters), post ## Review: Reset with a one-line reason.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pdr-managed PR opened by the henyey project-tick pipeline /do skill

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CLI self-check/publish-history now mutate history with no single-instance guard — concurrent catchup leaves a permanent hole

1 participant