Skip to content

fix: Extend the fix in #7763 to re-store more, targeted nodes - #7793

Open
ximinez wants to merge 16 commits into
ximinez/online-delete-gapsfrom
ximinez/online-delete-gaps2
Open

fix: Extend the fix in #7763 to re-store more, targeted nodes#7793
ximinez wants to merge 16 commits into
ximinez/online-delete-gapsfrom
ximinez/online-delete-gaps2

Conversation

@ximinez

@ximinez ximinez commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

High Level Overview of Change

Expands on the changes in #5531 and #7763:

  1. Sets the "copy forward" flag at the beginning of rotation, so any new ledgers created during the rotation have all their nodes brought over. This appears to prevent any in-use nodes from being deleted.
  2. Restricts the "copy forward" process to data for ledgers which are not about to be deleted by virtue of only being in the old archive (or unknown). This reduces the amount of data wasted, because we won't preserve anything that is about to become inaccessible.
  3. Allows rotation to continue without pausing if the node is completely disconnected. That only happens during network outages and other similar failures, so there's no syncing or consensus going on that rotation will interrupt.

Context of Change

This PR is built on top of #5531, and expands on the changes in #7763 to improve data integrity and reduce data loss when the online_delete archive store is deleted.

Before / After

There should be no directly user-observable effects from this change.

Operators and administrators should stop seeing any missing node errors or crashes.

@github-actions

This comment was marked as outdated.

@github-actions

Copy link
Copy Markdown

This PR has conflicts, please resolve them in order for the PR to be reviewed.

@ximinez
ximinez force-pushed the ximinez/online-delete-gaps2 branch from fcfba84 to 67e4cd2 Compare July 15, 2026 01:19
@github-actions

Copy link
Copy Markdown

All conflicts have been resolved. Assigned reviewers can now start or resume their review.

@ximinez
ximinez force-pushed the ximinez/online-delete-gaps2 branch from f865fc2 to c0cd461 Compare July 15, 2026 15:23
@ximinez ximinez added this to the 3.4.0 milestone Jul 15, 2026
@github-actions

Copy link
Copy Markdown

This PR has conflicts, please resolve them in order for the PR to be reviewed.

@github-actions

Copy link
Copy Markdown

All conflicts have been resolved. Assigned reviewers can now start or resume their review.

@ximinez ximinez added the DraftRunCI Normally CI does not run on draft PRs. This opts in. label Jul 17, 2026
@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

@ximinez
ximinez force-pushed the ximinez/online-delete-gaps2 branch from adb511e to 3dbcec5 Compare July 21, 2026 19:49
@ximinez ximinez changed the title fix: (DRAFT) Combine #5531 and #7763 fix: Extend the fix in #7763 to re-store more, targeted nodes Jul 23, 2026
- Add a special case to healthWait() to not pause when the server is
  DISCONNECTED.
- Log sequence differences at the start and end of rotation.
- Limit copy-forward to ledgers we're not about to delete, and unknown.
  (Changes rotationInFlight_ from a bool to a LedgerIndex.)
- Set the "rotation in flight" index right at the beginning of the rotation
    - Because the node copy process can take a long time, other ledgers may
      get validated. Any reads for those ledgers have the potential to be
      served by the archive DB and thus lost, too. Why wait?
- Add an assertion suggested on @vlntb in #7763.
- Don't wait as long for ledgers that should be built soon.
- Rescue nodes from the tree node cache, too.
@ximinez
ximinez force-pushed the ximinez/online-delete-gaps2 branch from bae52af to 5f087f6 Compare July 24, 2026 01:17
@ximinez
ximinez marked this pull request as ready for review July 24, 2026 01:18
@ximinez
ximinez marked this pull request as draft July 24, 2026 01:19
Comment thread src/xrpld/app/misc/SHAMapStoreImp.cpp
@sublimator

Copy link
Copy Markdown
Contributor

@ximinez @sophiax851 @vlntb

Hey — I noticed something while working on a memory-only node some months back: SHAMap trees are only partially and opportunistically linked, and normally rely on NodeStore to relink children on demand.

There is also substantial competition for canonical nodes. Because canonicalization picks one same-hash object without merging linkage, a poorer canonical can win and discard child pointers from a richer copy. Those children then have to be resynthesized through the cache or NodeStore. I directly observed this producing SHAMapMissingNode after switching to NullFactory; changing canonicalization from pick to merge stopped those failures.

I asked Fable and Sol to gather the relevant code paths because I suspect this is part of the MissingNode puzzle here and may interact with the rotation durability issue. I can’t guarantee every detail in either document is correct (don't agree with Fable's maybe that's why flapping? for example), but they should point toward the relevant areas:

https://gist.github.com/sublimator/d89356a3933f6c64432089810440afeb

ximinez added 2 commits July 24, 2026 11:55
…ximinez/online-delete-gaps2

* XRPLF/ximinez/online-delete-gaps:
  build: Patсh binary in local Linux nix environment (7859)
- Nodes rescued from freshenCaches are checked dynamically, and not
  written if not AccountNode.
@ximinez

ximinez commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Hey — I noticed something while working on a memory-only node some months back: SHAMap trees are only partially and opportunistically linked, and normally rely on NodeStore to relink children on demand.

There is also substantial competition for canonical nodes. Because canonicalization picks one same-hash object without merging linkage, a poorer canonical can win and discard child pointers from a richer copy. Those children then have to be resynthesized through the cache or NodeStore. I directly observed this producing SHAMapMissingNode after switching to NullFactory; changing canonicalization from pick to merge stopped those failures.

I asked Fable and Sol to gather the relevant code paths because I suspect this is part of the MissingNode puzzle here and may interact with the rotation durability issue. I can’t guarantee every detail in either document is correct (don't agree with Fable's maybe that's why flapping? for example), but they should point toward the relevant areas:

gist.github.com/sublimator/d89356a3933f6c64432089810440afeb_ (loading)_

This is fascinating, but also beyond the scope of this PR. Could you create an Issue (or PR with the merging changes) so that we can track it independently of this PR?

This insight could not just make memory-only nodes work more reliably, but could also help reduce I/O load for disk-backed nodes, since they'll be needing a lot fewer reads.

Now, the flipside of this is that, aside from memory-only stuff, we don't want to hold the entire ledger in memory all the time. There are more than 27 million nodes in the mainnet ledger - keeping all of those all the time is going to also be expensive. And a typical ledger is going to need, what, a few thousand leaf nodes, plus their corresponding inner nodes? I doubt this is the only way that untouched, or infrequently touched nodes are allowed to expire from RAM, or whether it's even intentional to let it go, but it is something to keep in mind for the more widely-used disk-backed SHAMap.

FWIW, running my node with the current changes in this PR, I have not yet seen a single missing node or re-stored node. I think the copy forward mechanism, and particularly moving it early is preventing active nodes from being deleted from disk in the first place. If that pans out, it would be a big win.

@ximinez
ximinez force-pushed the ximinez/online-delete-gaps2 branch from 9eff879 to e61b134 Compare July 24, 2026 23:33
- The "copied ledger" count may differ from the "Rotating:" count,
  because the latter will also count nodes from the cache.
@ximinez
ximinez marked this pull request as ready for review July 24, 2026 23:58
@ximinez
ximinez requested a review from Copilot July 24, 2026 23:58
@ximinez
ximinez requested a review from sophiax851 July 24, 2026 23:59
@ximinez ximinez removed the DraftRunCI Normally CI does not run on draft PRs. This opts in. label Jul 24, 2026

Copilot AI 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.

Pull request overview

This PR expands the online-delete (NodeStore rotating backend) durability fix from #7763 by widening the “copy-forward” protection window, reducing unnecessary copy-forward work for soon-to-be-deleted ledgers, and letting rotation continue during full disconnection.

Changes:

  • Start the rotation “copy-forward” window earlier and track it with a ledger-index threshold (instead of a boolean).
  • Add targeted “rescue” writes for cache-resident SHAMap nodes that are missing from both backends, and add additional rotation diagnostics/counters.
  • Adjust rotation health gating to treat DISCONNECTED as “healthy” for rotation progress.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/xrpld/app/rdb/backend/detail/Node.cpp Improves accepted-ledger missing-node logging by including exception details.
src/xrpld/app/misc/SHAMapStoreImp.h Adds cache-driven node rescue during cache freshening (rotation).
src/xrpld/app/misc/SHAMapStoreImp.cpp Implements rescueNode, starts rotation flag earlier, improves rotation/healthWait logging and behavior.
src/libxrpl/nodestore/DatabaseRotatingImp.cpp Implements ledger-index-gated copy-forward, adds counters/logging, and exposes rotation state.
include/xrpl/nodestore/detail/DatabaseRotatingImp.h Updates rotating DB implementation API and adds new counters/state members.
include/xrpl/nodestore/DatabaseRotating.h Extends the DatabaseRotating public interface to support ledger-index rotation windows and metrics.
Comments suppressed due to low confidence (1)

src/xrpld/app/misc/SHAMapStoreImp.cpp:781

  • Grammar in comment: "A disconnected state is should never be caused" -> "A disconnected state should never be caused".
    auto healthy = [&]() {
        // Special case: If the server is disconnected, it's not doing any ledger I/O, because
        // it's focused on trying to get peers. A disconnected state is should never be caused by
        // the activity of the server. It's usually limited to hardware or connectivity issues. Take
        // advantage of that to run as much rotation I/O as possible before it comes back online.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/xrpld/app/misc/SHAMapStoreImp.cpp
Comment thread src/xrpld/app/misc/SHAMapStoreImp.h
Comment on lines +488 to +493
JLOG(journal_.warn())
<< "finished rotation. validatedSeq: " << validatedSeq
<< ", lastRotated: " << lastRotated << " diff " << diff
<< ". Updated validated seq is " << currentValidatedSeq << ", " << processingDiff
<< " ledgers were validated during the rotation processs. Complete ledgers: "
<< ledgerMaster_->getCompleteLedgers();

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.

✅ Auto-resolved: the latest review no longer flags this issue, so it appears to have been addressed. If that is not correct, reopen this thread and it will be re-checked on the next review.

Comment thread include/xrpl/nodestore/DatabaseRotating.h
Comment on lines 92 to +97
void
DatabaseRotatingImp::setRotationInFlight(bool inFlight)
DatabaseRotatingImp::setRotationInFlight(LedgerIndex inFlight)
{
rotationInFlight_.store(inFlight, std::memory_order_release);
JLOG(j_.debug()) << "Rotating: copy-forward on archive reads "
<< (inFlight ? "enabled" : "disabled");
JLOG(j_.debug()) << "Rotating: copy-forward on archive reads from " << inFlight << " forward";
}

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.

✅ Auto-resolved: the latest review no longer flags this issue, so it appears to have been addressed. If that is not correct, reopen this thread and it will be re-checked on the next review.

@xrplf-ai-reviewer xrplf-ai-reviewer Bot 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.

Critical: freshenCache() rescue path triggers UNREACHABLE crash on TnInner nodes during rotation.

Comment thread src/xrpld/app/misc/SHAMapStoreImp.h
@sublimator

Copy link
Copy Markdown
Contributor

@ximinez

Yeah, in practice it keeps it all largely resident, albeit haphazardly, so I agree that the policy should be more explicit.

I am also very much with you in thinking that fully materializing the tree all the time is not scaling well.

I've done a lot of research on hybrid trees with mmap snapshotskeee, and I think that's the future.

That said, lower hanging fruit may be available. This is completely off the top of my head (i.e. maybe not feasible), but I suppose you could make a configuration option to actually /unlink/ on sweep (or flush at extremes??) if you wanted by direct policy choice. How that would interact with syncing and backfill settings, with potential disk thrash? I'm not sure, but I could imagine a steady-state node using the current paradigm with lazily materialized nodes.

Opened an issue re: pick vs merge:
#7870

@github-actions

Copy link
Copy Markdown

This PR has conflicts, please resolve them in order for the PR to be reviewed.

…ximinez/online-delete-gaps2

* XRPLF/ximinez/online-delete-gaps:
  chore: Move semantic version tests to gtest (7872)
  style: Make clang-tidy format files using clang-format rules (7880)
  ci: Change `server_definitions` upload config name (7878)
  chore: Trivial gtest migrations (7865)
  ci: Group github-actions dependabot updates (7876)
  ci: Update CI image and prepare-runner action (7874)
  test: Migrate `nodestore` tests from Beast to GTest (7292)
  test: Improve the server status test to not race and randomly fail (7304)
  ci: [DEPENDABOT] bump actions/checkout from 7.0.0 to 7.0.1 (7871)
  chore: Fix clang version in devshell (7860)
  chore: Verify tooling version for Nix-managed environments (7862)
@github-actions

Copy link
Copy Markdown

All conflicts have been resolved. Assigned reviewers can now start or resume their review.

…ximinez/online-delete-gaps2

* XRPLF/ximinez/online-delete-gaps:
  Update lastLedger in the healthWait loop in the right order
if (duplicate || rotationInFlight_.load(std::memory_order_acquire))
auto const inFlight = getRotationInFlight();
if (duplicate || (inFlight != 0 && (ledgerSeq == 0 || ledgerSeq >= inFlight)))
{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think I purposely included all possible duplicate=false reads as in-flight reads to guarantee that anything in the TreeNodeCache is backed by the DB store. Here is an example I can think of:
Let say, T0 - rotation starts. setRotationInFlight(90,000,000).
T1 - a peer requests an old ledger or getObjectByHash() after the getKeys() during freshenCache. This inner node H found in 89,999,000, since the rotation swap hasn't happened, so was loaded from the archive, it won't be copy forwarded in this logic since ledgerSeq = 89,999,000 < inFlight = 90,000,000, but now it lives in the treeNodeCache with a cowid==0
T2 - swap. rotate() drops the old archive. H's only on-disk copy is gone. H now exists only in RAM (TreeNodeCache).
T3 - rotation completes. New lastRotated = 90,001,024.
T4 - Now days later at ledger 90,050,000, a transaction made the inner node hash back to H again. Building 90,050,000's state map, SHAMap looks up H, finds the clean cached node from T1, and reuses it (cowid == 0).
T5 - flush. flushDirty writes only dirty nodes (cowid == current). H is clean → skipped. Ledger 90,050,000 is validated and persisted referencing H, which is on no backend.
T6 - TreeNodeCache evicts H, or the node restarts
T7 - The fetch of H misses both backends → SHAMapMissingNode .
The uncertainty is that we can't guarantee all objects in the cache will always come from ledgers within the complete_ledgers range. Neither Sweep nor clearPrior clears objects based on ledger ranges for the TreeNodeCache and we don't do duplicate==true read unless it's during the rotation copy.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The uncertainty is that we can't guarantee all objects in the cache will always come from ledgers within the complete_ledgers range. Neither Sweep nor clearPrior clears objects based on ledger ranges for the TreeNodeCache and we don't do duplicate==true read unless it's during the rotation copy.

🤔 I did an experiment where I requested all the nodes from an ledger that was about to be deleted until I was unable to continue. I had zero missing nodes. I'll need to re-run that experiment in light of this scenario, and see what the results look like. I'll also try to do it with just object indices.

If I see what you're indicating, or if I get unclear results, I have no problem switching back to a bool flag instead of a value. My big concern is someone forcing a large amount of irrelevant data to be copied continuously kept "alive", defeating the purpose of online delete in the first place.

@xrplf-ai-reviewer xrplf-ai-reviewer Bot 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.

Critical bug: rescueNode() silently fails for inner nodes during cache freshening, defeating the safety net against data loss.

{
auto const node = cache.fetch(key);
if (node)
rescueNode(*node);

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.

freshenCache() calls rescueNode() without expectedType; this defaults inner nodes to Unknown, triggering UNREACHABLE instead of persisting the rescue. Pass the node type or infer it from cache metadata:

Suggested change
rescueNode(*node);
rescueNode(*node, NodeObjectType::AccountNode);

@xrplf-ai-reviewer xrplf-ai-reviewer Bot 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.

rescueNode() logic bug risks data loss for inner nodes without expectedType; assert message needs update.

case SHAMapNodeType::TnAccountState:
return NodeObjectType::AccountNode;
// We don't expect to see transaction nodes. The check below will prevent writing them.
case SHAMapNodeType::TnTransactionNm:

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.

rescueNode() from freshenCache() without expectedType for TnInner yields Unknown, fails type check, hits UNREACHABLE (data loss risk). Choose: (a) skip TnInner with debug log, (b) track SHAMap type per cache entry, or (c) silent-skip instead of UNREACHABLE for missing expectedType.

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.

✅ Auto-resolved: the latest review no longer flags this issue, so it appears to have been addressed. If that is not correct, reopen this thread and it will be re-checked on the next review.

return fdRequired_;
}

void

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.

Assert message says 'copyNode' but function is now 'rescueNode'—update label:

Suggested change
void
XRPL_ASSERT(node.cowid() == 0, "SHAMapStoreImp::rescueNode : rescued node must be clean");
Suggested change
void
XRPL_ASSERT(node.cowid() == 0, "SHAMapStoreImp::rescueNode : rescued node must be clean");

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.

✅ Auto-resolved: the latest review no longer flags this issue, so it appears to have been addressed. If that is not correct, reopen this thread and it will be re-checked on the next review.

pratikmankawde and others added 8 commits July 31, 2026 19:43
Bound the number of manifests carried in a single TMManifests message
(kMaxManifestsPerMessage). Trusted manifests are always included and
processed; untrusted gossip is capped per message on both send and
receive, and the sender is charged only when untrusted entries are
actually skipped. Oversized TMManifests messages are dropped without
penalty at the protocol layer so an unpatched peer is not disconnected.

Complements the cache bound from #276/#323.
* release/3.2.x:
  chore: Bump version to 3.2.1
  chore: Bump version to 3.2.1-rc1
  fix: Cap untrusted manifests per message and drop oversized ones
  fix: Reject oversized validator manifest before decoding
  fix: Reduce untrusted manifest cache cap to 100
  fix: Bound untrusted manifest cache

@xrplf-ai-reviewer xrplf-ai-reviewer Bot 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.

Inner nodes rejected during rescue, negating the missing-node fix. See inline.

{
auto const node = cache.fetch(key);
if (node)
rescueNode(*node);

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.

Inner nodes resolve to Unknown type and fail rescueNode's type check. Skip TnInner nodes:

        if (node && node->getType() != SHAMapNodeType::TnInner)
            rescueNode(*node);

{
auto const node = cache.fetch(key);
if (node)
rescueNode(*node);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Severity: HIGH

freshenCache calls rescueNode without an expectedType. For TnInner nodes, the switch yields NodeObjectType::Unknown, which fails the AccountNode guard and silently drops the node. Inner nodes in the tree-node cache that exist only in RAM will be permanently lost during rotation, making entire state subtrees unreachable.
Helpful? Add 👍 / 👎

💡 Fix Suggestion

Suggestion: Pass NodeObjectType::AccountNode as the expectedType argument to rescueNode in the freshenCache call, just as copyNode does at line 326. This ensures that inner nodes are correctly mapped to NodeObjectType::AccountNode (since all state-map inner nodes are part of the account state tree) and will pass the guard condition, allowing them to be stored during rotation instead of being silently dropped.

⚠️ Experimental Feature: This code suggestion is automatically generated. Please review carefully.

Suggested change
rescueNode(*node);
rescueNode(*node, NodeObjectType::AccountNode);

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.

7 participants