fix: Re-store nodes missing from both backends during online_delete rotation#7763
Conversation
|
If only the most recent commit is unsigned, you can run:
If multiple commits are unsigned, you can run:
If you're new to commit signing, there are different ways to set it up: Sign commits with
|
There was a problem hiding this comment.
Pull request overview
This PR addresses a durability gap in online-delete backend rotation: when a node is reachable from the validated in-memory state map but has been deleted from disk due to an earlier backend rotation (and never rewritten because it remained “clean”), rotation could later fail and/or the node could surface as an unresolvable SHAMapMissingNode. The change makes the rotation copy walk detect a miss in both backends and re-store the in-memory node bytes into the writable backend, restoring the invariant that all nodes reachable from the validated ledger exist on disk.
Changes:
- In
SHAMapStoreImp::copyNode, detect whenDatabaseRotatingImp::fetchNodeObject(..., duplicate=true)returns null (missing from both writable and archive backends). - Re-serialize the provided
SHAMapTreeNode(with prefix) and store it as aNodeObjectType::AccountNodeinto the rotating database’s writable backend. - Emit a
warnlog when this recovery path is taken to make the repair observable operationally.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## develop #7763 +/- ##
=========================================
- Coverage 82.8% 82.8% -0.0%
=========================================
Files 1036 1036
Lines 80067 80090 +23
Branches 8920 8928 +8
=========================================
+ Hits 66282 66292 +10
- Misses 13776 13789 +13
Partials 9 9
🚀 New features to boost your workflow:
|
470001b to
718ddad
Compare
718ddad to
7606335
Compare
fix: Log when a clean node missing from both backends is re-stored Addional guardrails and logging
7606335 to
c364ebb
Compare
|
This PR has conflicts, please resolve them in order for the PR to be reviewed. |
|
All conflicts have been resolved. Assigned reviewers can now start or resume their review. |
ximinez
left a comment
There was a problem hiding this comment.
This is good as-is. I think any further enhancements can come later.
ae3898d to
50a0772
Compare
50a0772 to
40c6353
Compare
| // backend; copyForwardCount_ tallies them per rotation for the | ||
| // summary line logged at swap. | ||
| std::atomic<bool> rotationInFlight_{false}; | ||
| std::atomic<std::uint64_t> copyForwardCount_{0}; |
There was a problem hiding this comment.
[nit] copyForwardCount_ can use std::memory_order_relaxed on both the increment (fetch_add) and the exchange(0). This counter is stats-only — its value feeds a single log line and gates no other memory access — so atomicity alone is sufficient. operator++ in particular currently defaults to seq_cst, which is the strongest (and most expensive) ordering. Switching to explicit fetch_add(1, std::memory_order_relaxed) is a small performance win.
There was a problem hiding this comment.
Thanks. I made the change though AI says: "Performance: real but negligible here. On x86-64, a seq_cst and a relaxed fetch_add compile to the identical lock xadd instruction — zero difference. On ARM (Apple Silicon, Graviton) there is a genuine difference ( ldadd vs ldaddal with acquire-release semantics). But context matters: this increment executes only on archive-backend hits during a rotation window, on a path that just completed a backend disk fetch and is about to do a backend store. A saved fence is unmeasurable next to the I/O on either side of it. The exchange(0) runs once per rotation (roughly hourly) — irrelevant either way."
Co-authored-by: Valentin Balaschenko <13349202+vlntb@users.noreply.github.com>
There was a problem hiding this comment.
| } | |
| XRPL_ASSERT(!dbRotating_->getRotationInFlight(), | |
| "SHAMapStoreImp::run : rotationInFlight_ must be false " | |
| "outside rotation window"); |
vlntb
left a comment
There was a problem hiding this comment.
The change makes sense and allows us to catch the gap earlier than we do right now.
I left a couple of suggestions to add runtime asserts so we can use Antithesis to verify the theory and the change - those are nits and only valuable once we start testing db rotation in Antithesis, so they can be added later.
High Level Overview of Change
Fixes a silent durability gap in online delete: a node that is still referenced by the validated state map, but whose only on-disk copy was deleted with a previously rotated-out backend, is never rewritten to disk. The fix has two small, independent layers:
DatabaseRotatingImp::fetchNodeObject). Today onlyduplicate == truefetches do this; a node fetched from the soon-to-be-deleted archive after the freshen snapshot would otherwise survive only in RAM once the archive is dropped.SHAMapStoreImp::copyNode) detects a walked node that misses in both backends and re-serializes the in-memory body into the writable backend, restoring the invariant that every node reachable from the validated ledger exists on disk.Both layers mirror existing store patterns (
duplicatecopy-forward andSHAMap::writeNodeserialize-then-store respectively); each logs on occurrence so operators can see the recovery happen.Context of Change
Symptom
Affected nodes kept dropping in and out of sync with the network. Log analysis tied the desyncs to two surfaces of one condition:
SHAMapMissingNode); the locally built ledger diverged from the network-validated one, taking minutes to recover, repeatedly.Root cause
Verified step by step against the code:
SHAMap::flushDirtywrites only dirty nodes (cowid != 0);walkSubTreeskips clean children by design.TreeNodeCachewhile its only on-disk copy sits in the old backend.Database::fetchNodeNTreturns oncacheLookupsuccess).SHAMapMissingNode, with the entire clean subtree beneath it unreachable from disk.Ruled out (by production instrumentation, kept out of this PR)
cowid == 0), present inTreeNodeCache, absent from both backends.Why two layers
Prevention closes the exposure window that the copy walk cannot see.
freshenCaches()takes agetKeys()snapshot of each cache and re-fetches those keys withduplicate == true, copying their archive bodies forward. But a node fetched from the archive after that snapshot — and canonicalized into a cache — generates no further disk reads, so nothing copies it forward before the swap deletes the archive. The change:SHAMapStoreImpsets a rotation-in-flight flag (setRotationInFlight, RAII-cleared on every exit path) just before freshening; while set,DatabaseRotatingImp::fetchNodeObjectextends the existingduplicatecopy-forward to ordinary reads served by the archive. Zero cost outside the window (one atomic load on the archive-hit path only); a per-rotation counter is logged at swap (Rotating: copied forward N archive-served reads…).Repair (
copyNode) handles nodes already stranded by past rotations. It is the only point that simultaneously holds the live in-memory node and proof that disk lost it (the both-backends fetch miss,!obj). The re-store:AccountNodetype, and store path asSHAMap::writeNode);const&;AccountNodeis hardcoded deliberately: the state-map walk is the only call site that can reach this code. The transaction map is not exposed — it is built fresh per ledger, shares no clean nodes across ledgers, and is never walked by rotation. Parameterizing the type for a hypothetical future tx-map path was considered and rejected as speculative.Type of Change
API Impact
No public API, protocol, or on-disk format changes; the stored bytes are identical to a normal flush write. One addition to the internal
NodeStore::DatabaseRotatinginterface (setRotationInFlight), implemented only byDatabaseRotatingImpand called only bySHAMapStore.Test Plan
friend-based test hook if maintainers prefer. Until then, the logs below are the production observables.healthWait()pauses that defer rotation while the node is catching upSHAMapMissingNodeledger-close failures or resulting desyncs since deployment;What to watch in the logs:
Rotating: copied forward N archive-served reads into the writable backend during the rotation window— one summarywarnper rotation (suppressed when N=0); prevention layer working.copyNode: re-stored node missing from both backends, hash=... type=...— onewarnper recovered node, emitted only in the miss branch, so it cannot flood; repair layer working. Expected to be rare and to trend to zero once nodes stranded by past rotations have been repaired.Status: confirmed on a mainnet-tracking node that previously reproduced the issue — rotations complete cleanly and the missing-node symptom has not recurred.