Skip to content

fix(backup): rebuild the reversion bitsets on restore#1592

Open
Azorlogh wants to merge 5 commits into
release/v3.0from
fix/rebuilddelta-reversion-bitset
Open

fix(backup): rebuild the reversion bitsets on restore#1592
Azorlogh wants to merge 5 commits into
release/v3.0from
fix/rebuilddelta-reversion-bitset

Conversation

@Azorlogh

Copy link
Copy Markdown
Contributor

Found by the model test's restore cycle, immediately after a backup→restore: a revert the model knew was already applied got re-attempted by the server and rejected with insufficient funds instead of TRANSACTION_ALREADY_REVERTED. Same mechanism as an earlier unexplained finding where the incidental rejection was account does not match any account type pattern.

Bug

The FSM's already-reverted gate reads the per-ledger reversion bitset (ZonePerLedger/SubPLReversions) — not the tx rows' RevertedByTransaction markers. That bitset is persisted only by the FSM hot path and loaded verbatim on boot (query.ReadReversions). The restore rebuild replayed RevertedTransaction logs into the tx-row markers but never into the bitset, so a restored node forgot every revert inside the exported delta:

  • On a solvent account, a second revert of the same transaction passes the gate and succeeds — a double refund.
  • On an insolvent one, it surfaces as a wrong rejection (insufficient funds / account-type mismatch), which is how the model caught it.

Evidence from the failing run: the restored store's log stream held 30 reverts while the 0301 key range held zero rows; the re-attempted revert targeted a transaction whose own row said revertedByTransaction: 41.

Fix

The replay writer seeds the bitsets from the checkpoint rows, folds every replayed revert into them (SetRevertedBy), resets a ledger's fold on DeleteLedger, and flushes touched ledgers' nonzero words in the FSM's own layout (state.SaveReversionWord, exported for this).

Checker strengthening

The persisted bitset was an unverified projection (invariant 8): checkReversionInvariants only validates the log stream's internal consistency. The new compareReversions pass compares stored bitsets against the audit-derived reverted set (baseline tx-row markers + replayed reverts, complete across archived chapters), exact equality both ways — a lost bit re-admits a double revert, an unaudited bit silently blocks a legitimate one. Cleanup-pending ledgers are skipped (rows linger until the covering purge).

Tests

  • New e2e tests/e2e/cluster/restore_reversion_test.go: reverts a funded transaction pre-backup, keeps the account solvent, restores, and asserts a second revert is still rejected with "already reverted" and the account volumes are unchanged. Without the fix commit, the double revert succeeds.
  • Unit tests for compareReversions (match / missing bit / unaudited bit / cleanup-pending skip).

Behavioral note

Stores restored with the old rebuild are missing delta reverts from their bitsets and will now correctly fail Check() with REVERTED_MISMATCH — and genuinely accept double reverts until re-restored with the fix, so worth flagging to operators of existing restored environments.

Azorlogh added 2 commits July 14, 2026 23:07
The FSM's already-reverted gate reads the per-ledger reversion bitset
(ZonePerLedger/SubPLReversions), which only the FSM hot path persists.
The restore rebuild replays RevertedTransaction logs into the per-tx
RevertedByTransaction markers but not into the bitset, so every revert
inside the exported delta is forgotten by the gate: a second revert of
the same transaction passes it and, when the account is still solvent,
SUCCEEDS — a double refund. Otherwise it surfaces as a wrong rejection
(insufficient funds / account-type mismatch) instead of
TRANSACTION_ALREADY_REVERTED, which is how the model test caught it
right after a restore cycle.

The test reverts a funded transaction pre-backup, keeps the account
solvent with an independent deposit, restores, and asserts a second
revert is still rejected with "already reverted" and that the account's
volumes are unchanged. Currently fails at Phase 3 (the double revert
succeeds); goes green once the rebuild reconstructs the bitset.
The FSM's already-reverted gate reads the per-ledger reversion bitset
(ZonePerLedger/SubPLReversions), persisted only by the FSM hot path and
loaded verbatim on boot (query.ReadReversions). The restore rebuild
replayed RevertedTransaction logs into the tx rows'
RevertedByTransaction markers but never into the bitset, so a restored
node forgot every revert inside the exported delta: a second revert of
the same transaction passed the gate and, on a solvent account,
SUCCEEDED — a double refund. On an insolvent one it surfaced as a wrong
rejection (insufficient funds / account-type mismatch instead of
TRANSACTION_ALREADY_REVERTED), which is how the model test caught it
right after a restore cycle.

The replay writer now seeds the bitsets from the checkpoint rows, folds
every replayed revert into them (SetRevertedBy), resets a ledger's fold
on DeleteLedger, and flushes touched ledgers' nonzero words in the
FSM's own layout (state.SaveReversionWord, exported for this).

Checker strengthening: the persisted bitset was an unverified
projection — checkReversionInvariants only validates the log stream's
internal consistency. The new compareReversions pass compares the
stored bitsets against the audit-derived reverted set (baseline tx-row
markers + replayed reverts, complete across archived chapters), exact
equality both ways; cleanup-pending ledgers are skipped since their
rows linger until the covering purge.

The e2e added in the previous commit now passes.
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • main

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 28ff93b8-df74-412d-8cd4-9006e608be83

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/rebuilddelta-reversion-bitset

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@NumaryBot

NumaryBot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

✅ Approve — automated review

The reversion-bitset rebuild and restore logic looks sound. The previously identified major issue (stale ZonePerLedger/SubPLReversions rows left in Pebble after a DeleteLedger replay event) was acknowledged and resolved in an earlier revision, confirmed by the prior discussion. The post-restore checker logic and invariant coverage for the new projection pass are consistent with the FSM path. Patch coverage sits around 70%, which is noted but not a blocker. The documentation follow-up noted in the prior discussion remains open but is a non-blocking concern. No new concrete findings were raised by the current round of reviews.

No findings.

@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 63.71681% with 41 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.79%. Comparing base (310ba1f) to head (88cd73b).
⚠️ Report is 1 commits behind head on release/v3.0.

Files with missing lines Patch % Lines
internal/infra/backup/rebuild.go 54.00% 17 Missing and 6 partials ⚠️
internal/query/reversions.go 41.17% 8 Missing and 2 partials ⚠️
internal/application/check/checker.go 89.74% 2 Missing and 2 partials ⚠️
internal/infra/state/recovery.go 33.33% 1 Missing and 1 partial ⚠️
internal/infra/state/write_set.go 0.00% 0 Missing and 2 partials ⚠️

❌ Your patch check has failed because the patch coverage (63.71%) is below the target coverage (80.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files
@@               Coverage Diff                @@
##           release/v3.0    #1592      +/-   ##
================================================
- Coverage         74.80%   74.79%   -0.02%     
================================================
  Files               430      430              
  Lines             45697    45789      +92     
================================================
+ Hits              34185    34249      +64     
- Misses             8463     8477      +14     
- Partials           3049     3063      +14     
Flag Coverage Δ
e2e 74.79% <63.71%> (-0.02%) ⬇️
scenario 74.79% <63.71%> (-0.02%) ⬇️
unit 74.79% <63.71%> (-0.02%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The test engine mimics WriteSet.Merge but kept reverted status in a
per-proposal in-memory map, never persisting the reversion words — so
compareReversions correctly flagged every synthetic revert as a missing
bit. The engine now folds reversions into per-ledger bitsets that
survive across proposals (GetReverted consults them, matching the FSM
gate) and persists the touched words at commit via SaveReversionWord,
like the real dirty-word flush.

@NumaryBot NumaryBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

NumaryBot posted 1 new inline finding.

Summary: #1592 (comment)

Comment thread internal/infra/backup/rebuild.go Outdated
Unlike the rest of the per-ledger data (deferred to the covering
purge), the live path deletes the SubPLReversions rows at DeleteLedger
apply (WriteSet.Merge). The replay only dropped its in-memory fold, so
a restored store kept a deleted ledger's checkpoint-time reversion rows
and boot resurrected them into Registry.Reversions — a restored-vs-live
divergence until the purge. Mirror the live path with the now-exported
state.DeleteReversionsByLedger.

Flagged by NumaryBot on #1592.

@NumaryBot NumaryBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

NumaryBot review complete: no remaining inline findings.

Resolved 1 stale NumaryBot review thread (0 fixed, 1 outdated).

Summary: #1592 (comment)

@gfyrag gfyrag left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I found one blocking issue in the new reversion projection checker.

  1. compareReversions must not skip based on the persisted pending-cleanup marker, and it should also account for stored rows for non-live ledgers.

The checker derives knownLedgers from the audit stream, but then compareReversions skips a ledger whenever pendingCleanupLedgers[name] exists. That marker is itself a Pebble projection, so a stale or forged pending-cleanup row for an audit-live ledger suppresses both missing-bit and unaudited-bit errors. The current TestCompareReversions_CleanupPendingSkipped locks that behavior in: it marks ledger-a as live, writes an unaudited reversion bit, adds a pending cleanup marker, and expects no error.

This exception needs to be driven by audit-derived deletion state, not by the projection being checked. With this PR, the live FSM path deletes SubPLReversions rows immediately on DeleteLedger, and the rebuild path mirrors that, so stale reversion rows for deleted/non-live ledgers should not be treated as legitimate lingering cleanup data either. Otherwise a regression in the DeleteLedger replay cleanup, or a tampered pending-cleanup marker on a live ledger, can pass Check() cleanly while Registry.Reversions would be wrong on recovery.

Please make the checker compare the decoded stored reversion rows against the audit-derived live/deleted state in both directions, and adjust the cleanup test so a pending-cleanup marker alone cannot hide mismatches for an audit-live ledger.

  1. Related hardening: the checker now uses query.ReadReversions as an integrity source, but that reader silently skips malformed keys/short values and does not return iter.Error(). For a checker pass, malformed rows under ZonePerLedger/SubPLReversions should be surfaced as an error/event instead of being dropped before comparison.

@gfyrag

gfyrag commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Documentation follow-up: this PR adds a new restore/checker mechanism for ZonePerLedger/SubPLReversions, but the docs still describe the restore/checker surface without that projection pass. At minimum, please update the checker/restore documentation to mention the reversion-bitset projection validation, and correct the documented reversion key layout to the actual [zone][sub][ledgerName padded 64B][wordIndex] format. This is relevant here because the PR changes both rebuild behavior and invariant-#8 coverage for this projection.

Review feedback on #1592 (gfyrag):

1. The pass skipped ledgers with a persisted pending-cleanup marker —
   itself an unverified Pebble projection, so a stale or forged marker
   on an audit-live ledger suppressed both mismatch directions. The
   skip is gone: audit-live ledgers are always compared. Stored rows
   for non-live ledgers are now flagged instead of ignored —
   DeleteLedger deletes them at apply on both the live path and the
   replay, so nothing legitimately lingers.

2. query.ReadReversions silently dropped malformed keys/short values
   and never checked iter.Error(). It now returns malformed rows
   alongside the bitsets and surfaces iterator errors (also fixed in
   ReadReversionBitset). The checker reports each malformed row as a
   REVERTED_MISMATCH event, recovery logs them loudly, and the restore
   rebuild refuses to seed from a corrupt checkpoint.

Docs: checker.md gains the compareReversions pass (and stops claiming
checkReversionInvariants verified the stored bitset); attributes.md's
reversion key layout corrected to [zone][sub][ledgerName padded
64B][wordIndex] (was documented with a 4-byte ledger id) and the
in-memory map key corrected to the ledger name; backup-restore.md moves
reversions from the attributes zone to the per-ledger zone and lists
them among the state RebuildDelta reconstructs.

@gfyrag gfyrag left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved current head 88cd73b. I rechecked the previous requested-change items: compareReversions is now driven by audit-derived state rather than pending-cleanup markers, stored rows for non-live ledgers are flagged, malformed reversion rows surface instead of narrowing the comparison silently, and restore rebuild now seeds/folds/flushes the reversion bitsets in the FSM layout.

Caveats: GitHub currently reports this PR as conflicting with release/v3.0, so it is not merge-ready until rebased/resolved. codecov/patch is also failing, but I did not find a remaining code blocker in this pass.

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.

3 participants