Concrete implementation design for the fix in online-delete-generational-gc.md. Target:
Transia-RnD/rippled, branch dangell7/online-delete-generational-gc (own work off develop).
Today DatabaseRotating keeps two append-only backends (writable + archive). Every
deleteInterval_ ledgers, SHAMapStoreImp::run() walks the entire current state
(visitNodes(copyNode)) and re-stores every live node into a fresh writable backend, then drops
the old archive. Because the writable is recreated each rotation, the whole live set is re-written
every cycle → O(state) stores per rotation, growing without bound → the AcceptLedger stall / the
20–25M cliff. The store (write) side dominates (~5.4 GB written per rotation observed at 1.3M).
Replace the 2-backend copy-and-swap with a ring of N append-only generations. New nodes append to the newest generation and stay there — we stop re-storing the whole live set every rotation. A generation is dropped only once it holds no live nodes; the few cold survivors are evacuated forward first. Amortized store cost per rotation drops from O(live) to ≈ O(live / N) (a cold node is re-stored once per N rotations, not every rotation). N is a tunable disk⇄copy tradeoff.
generations_: orderedstd::deque<Gen>from oldest → newest. EachGen= oneNodeStore::Backend(a NuDB dirrippledb.gNNNNN) + metadata{firstLedger, sealed}.- Writable =
generations_.back(). Allstore()goes here. - Reads:
fetchNodeObjectsearchesgenerations_newest→oldest; first hit wins (a node lives in the generation where first written). Same "search then optionally duplicate" contract as today, generalized from 2 to N. - Invariant: content-addressed + immutable nodes ⇒ a hash resides in exactly one generation at a time (may be re-stored into a newer gen during evacuation, after which the old copy is droppable).
- Advance (seal + open): at each rotation cadence (
validatedSeq >= lastRotated + deleteInterval_, plus the existingcanDelete_/healthWaitgates), seal the current writable (mark read-only) and open a fresh writable generation. Cheap, O(1). Each sealed gen ≈ one interval's new nodes. - Retire (the GC): while
generations_.size() > N, retire the oldest gen:- Evacuate its live survivors. Walk the current validated state map (
visitNodes). For each node, if it resides in the oldest gen (fetch finds it there and not in a newer gen),store()it into the writable gen. Dead nodes are never visited (not in current state); nodes already in newer gens are skipped. Copy set = cold survivors of the oldest gen only, not the whole live set. - Drop the gen:
setDeletePath()+ close/destroy the backend → physical file removal. freshenCaches()/clearCaches()around the drop so TreeNodeCache / FullBelowCache never reference a removed backend (preserve today's cache invariants; just generalized).
- Evacuate its live survivors. Walk the current validated state map (
- SQL side unchanged:
clearPrior()/clearSql()(Transactions/AccountTransactions batched +backOff_) is independent of the NodeStore ring; keep as-is.
- Cliff: per-rotation stores ≈ live/N and, more importantly, do not require re-storing the whole live set — a cold account node is touched once per N rotations. Tune N up as accounts grow.
- Stall: the evacuation copy set is a small fraction, so the I/O burst against AcceptLedger shrinks by ~N even before incrementalizing.
- v1 (this PR): retire on cadence with the full-state walk to find survivors (O(live) reads,
mostly cache-served; O(survivors) writes). Correct + big win on the write/I/O side that causes the
stall. Gate with
healthWaitexactly as today. - v2 (follow-on): make evacuation incremental — a resumable state-walk cursor advancing a sliver per ledger across the interval, so even the O(live) read-walk is spread out and per-ledger overhead is tiny/constant. And add per-generation liveness hints to avoid the full walk entirely.
SavedState today stores {writableDb, archiveDb, lastRotated}. Generalize to persist the ordered
list of generation dir names + which is writable + lastRotated, so on restart the node re-opens
the whole ring in order. Back-compat: a 2-name legacy state loads as a 2-gen ring. This is the
highest-risk correctness area (a wrong ring on restart = missing nodes) → dedicated tests.
DatabaseRotating/DatabaseRotatingImp: 2 backends →dequeof N; generalizefetchNodeObjectrouting and replacerotate(newBackend, callback)withadvance()(seal+open) andretireOldest().Backend/ NuDB: need create/open/close/setDeletePath(physical delete)/for_each; confirm N simultaneous backends' fd + memory cost (fdRequired) is acceptable (bounds N).SHAMapStoreImp::run(): swap the copy-and-rotate block for advance()+retireOldest()+evacuate.- Caches: preserve
freshenCaches/clearCachessemantics around a drop. SavedStateDB: persist/restore the ring.
online_delete(unchanged): rotation cadence.- New:
online_delete_generations(N, default e.g. 8) — the ring depth / disk⇄copy knob. Larger N = less copying, more transient disk. Validate the sweet spot on the rig.
- Members: replace
writableBackend_+archiveBackend_withstd::deque<std::shared_ptr<Backend>> generations_(front = oldest, back = writable) + astd::shared_ptr<Backend> retiring_(the gen currently being evacuated, or null). store/sync/getName/getWriteLoad/importDatabase→generations_.back()(writable).fetchNodeObject: snapshot the deque under lock; probe back → front, first hit wins. Copy-forward is scoped toretiring_: on a hit, copy the node into the writable backend iffduplicateAND the hit came fromretiring_(or the existingrotationInFlight_window). Hits from other sealed gens are NOT copied — that is the whole O(churn) win (we never re-store gens 1..N-2's live nodes, only the retiring gen's survivors).forEach: iterate allgenerations_.- Replace
rotate(newBackend, f2)with:advance(std::unique_ptr<Backend>&& newWritable, RingPersist const& persist)— push newWritable toback(); the prior writable stays in the deque as a sealed read-only gen. Persist the ring.beginRetire()/endRetire()— set/clearretiring_ = generations_.front()around the SHAMapStore evacuation walk (enables the scoped copy-forward).retireOldest(RingPersist const& persist)—front()->setDeletePath(), hold it past the callback,pop_front(), persist the (now shorter) ring; the held shared_ptr's destruction deletes the dir after persistence (same deferred-delete safety as today's rotate()).RingPersist = std::function<void(std::vector<std::string> const& ringNames /*oldest→newest*/)>.std::size_t generationCount() const;
- Ctor: take
std::vector<std::shared_ptr<Backend>>(the opened ring) instead of the two backends; sumfdRequired()over all.
When readyToRotate:
clearPrior(lastRotated)— unchanged (SQL side).advance(makeBackendRotating(), persistRing)— seal current writable, open a fresh empty one.- If
dbRotating_->generationCount() > N: a.beginRetire()(RAII guard, like the existing RotationExposureGuard). b.visitNodes(copyNode)over the validated state — unchanged call, but now copy-forward is scoped to the retiring gen, so only its survivors are stored to writable (O(churn), not O(state)). c.freshenCaches()— still needed, but now only nodes living in the retiring gen get re-stored (the freshenCachefetchNodeObject(duplicate=true)hits the scoped copy-forward). d.clearCaches(validatedSeq)— unchanged (invalidate FullBelow/ledger caches before the drop). e.retireOldest(persistRing);endRetire(). lastRotated = validatedSeq.
persistRing= lambda writingSavedState{ generations=ringNames, writableDb=ringNames.back(), archiveDb=ringNames.front(), lastRotated }viastateDb_.setState(...).
makeNodeStore: readstate.generations; if empty on first run, create the initial ring (2 gens: an empty writable + it as the sole archive, matching today's bootstrap) and persist. Open each generation viamakeBackendRotating(name); pass the vector to the ctor.dbPaths(): change "delete anything not writableDb/archiveDb, corrupt unless exactly two" to "keep every dir named instate.generations, delete the rest; corrupt unless all N exist."
Keys::kOnlineDeleteGenerations = "online_delete_generations", parsed in the ctor whendeleteInterval_ != 0; memberstd::uint32_t generations_ = 8;(min 2). N is the disk⇄copy knob.
Reproduced already: rotation cost 5s→85s and AcceptLedger 19s stalls as accounts grew to 1.3M.
After this: rotation finished−started duration and evacuation node-count should be flat vs account
count (≈ per-interval churn, not total state), and the AcceptLedger multi-second stalls should
vanish. Overlay the before/after rotation-cost-vs-accounts curve — that's the deliverable proof.