Skip to content

Instantly share code, notes, and snippets.

@dangell7
Created August 11, 2026 16:05
Show Gist options
  • Select an option

  • Save dangell7/8bbfa0899c43eb4e6b8a3a283887282d to your computer and use it in GitHub Desktop.

Select an option

Save dangell7/8bbfa0899c43eb4e6b8a3a283887282d to your computer and use it in GitHub Desktop.
XRPL plans 6+7 — flat-state lookup and deferred SHAMap (measured 3-5x apply lift)

Plan 6 — Flat keylet-indexed state lookup (read-side)

Goal: materialize XRPL state as a flat keylet→SLE map. Apply does dual-write (SHAMap stays authoritative, flat map indexes reads). Reads go to the flat map only — no fallback path. This is the pure NoSQL 2-writes-for-1-read pattern. Direct TPS lift is moderate-to-large; indirect value is the larger story — this unlocks downstream consumers (indexers, light clients, hooks, AMM bots, sidecar services) that currently can't query XRPL state at scale.

Why this is Plan 6: the SHAMap is a NoSQL substrate (keylet→SLE Merkle trie) currently being used in a SQL access pattern (descend the index on every read). Plan 6 finishes what the substrate was designed for: materialize the read view. Plan 5 (incremental hashing) becomes mostly obsolete once Plan 7 (deferred SHAMap, the structural follow-on to this plan) lands.


1. The problem

Today every state read in rippled/xrpld descends the SHAMap. The descent is:

  1. Take the root, do a spinlock-protected lookup of branch[nibble₀] → child node
  2. If child is an inner node, recurse to branch[nibble₁]
  3. After ~6–10 levels (practical, log₁₆ of ~10M state entries), reach the leaf
  4. Read the SLE from the leaf

Per descent cost:

  • 6–10 inner-node lookups
  • Each lookup acquires a PackedSpinlock (one bit in an atomic<uint16_t>) — at high contention this is real
  • Each inner node may need to be fetched from the TaggedCache; cache misses go to NodeStore (disk!)
  • ~6–10 cache-line touches even on a warm cache

Reads-per-tx vary by tx type but typical Payment reads ~5–10 SLEs (src AccountRoot, dst AccountRoot, src trustline, dst trustline, src OwnerDir, fee settings, etc.). For 4000 tx/ledger, that's 20k–40k SHAMap descents per ledger close on the apply path alone. RPC + ledger queries multiply this.

The flat lookup replaces each descent with one unordered_map::find().

2. What changes architecturally

Two separate structures:

Structure Role Updates
SHAMap (existing) Authoritative — produces the state root that consensus agrees on. Provides Merkle proofs. On apply; full COW snapshot per closed ledger. Stays as-is.
FlatStateMap (new) Read-side index. unordered_map<uint256, shared_ptr<SLE const>> keyed by SHAMap key. Maintained alongside SHAMap; same lifecycle.

The SHAMap is unchanged. Its hash, its Merkle proof generation, its consensus role, its on-wire serialization — all unchanged. No amendment required.

The FlatStateMap is a purely auxiliary cache. If it's wrong or absent, the system falls back to SHAMap descent. This is the safety property that lets Plan 6 ship without an amendment.

Lifecycle (per Ledger object):

                       ┌──────────────────────────────┐
   Closed Ledger N  ──>│ SHAMap (authoritative)       │
                       │ FlatStateMap (read fast)     │
                       │ Pointers share SLE objects   │
                       └──────────────────────────────┘
                                    │
                                    │  close N+1
                                    ▼
                       ┌──────────────────────────────┐
   Closed Ledger N+1   │ SHAMap (COW from N + diff)   │
                       │ FlatStateMap (COW from N +    │
                       │   diff; shares unchanged SLEs│
                       │   with N's flat map)         │
                       └──────────────────────────────┘

The COW property is preserved via shared SLE pointers. If ledger N+1 only modifies 100 SLEs out of 10M, ledger N+1's FlatStateMap has 100 new pointers and 9,999,900 pointers shared with ledger N (via a persistent / copy-on-write hash map; see §4).

3. The API surface (external value)

ReadView interface stays unchanged. The internal implementation changes; consumers see the speedup transparently:

// Existing API — semantics unchanged, implementation faster
std::shared_ptr<SLE const> ReadView::read(Keylet const& k) const;
bool                        ReadView::exists(Keylet const& k) const;

For external consumers (the "other project" enablement):

// New public API — exposes the flat map directly for bulk consumers
class FlatStateView {
public:
    // O(1) keylet lookup, no SHAMap descent
    std::shared_ptr<SLE const> read(uint256 const& key) const;

    // Iterate all SLEs of a given type — backed by a secondary index
    template <typename F>
    void forEachOfType(LedgerEntryType type, F&& visitor) const;

    // Diff between two ledger versions — for indexers
    StateDiff diff(LedgerIndex from, LedgerIndex to) const;

    // Subscribe to state changes per ledger — for streaming consumers
    Subscription subscribeStateUpdates(StateUpdateCallback);
};

The "other project" likely needs one or more of:

  • Indexers / explorers — bulk read at ledger close
  • Light clients — Merkle proofs (still served from SHAMap) + fast reads (served from flat map)
  • Hooks / smart contract execution — random-access state reads at execution time
  • AMM/DEX bots — sub-millisecond pool state queries
  • Sidecar services mirroring state in a different store (Postgres, Redis, columnar)

The subscribeStateUpdates + StateDiff API gives external services an authoritative incremental feed without polling.

4. Data structure choices

FlatStateMap implementation options

Option Reads Writes COW Memory Notes
A. std::unordered_map, rebuilt per ledger O(1) O(1) Re-allocate everything per ledger High churn Simplest; OK if rebuild cost is small
B. std::unordered_map + COW per ledger via shared_ptr to buckets O(1) O(1) avg, O(N) worst (rehash) Per-bucket COW ~2× pointers Awkward to make truly COW
C. Persistent HAMT (Hash Array Mapped Trie) O(log₃₂ N) ~ effectively O(1) O(log₃₂ N) Structural sharing native +~5–10% pointers Best COW story; Clojure / Scala use this
D. Two-tier: immutable "base" map per closed ledger + mutable overlay for open ledger O(1) O(1) Snapshot-on-close Low overhead Pragmatic; matches existing OpenLedger/Closed semantics

Recommendation: Option D. Matches how XRPL ledgers already work — open ledger is mutable, closed ledger is immutable. The flat map's open-ledger overlay holds in-flight writes; on close, the overlay merges into a new immutable base. Read path: check overlay first, then base.

Option C (HAMT) is the elegant long-term answer if we end up needing many concurrent historical ledger snapshots, but D ships sooner and covers 99% of read traffic.

Secondary indexes (the bonus)

Once we have a flat map, secondary indexes by LedgerEntryType become trivial:

flat_hash_map<LedgerEntryType, intrusive::list<SLE_entry>> by_type_;

This makes queries like "all AMM pools" / "all offers on this book" sub-millisecond, which is exactly what indexer-class consumers need. Currently these queries require full SHAMap traversal.

5. Concurrency model

The flat map is read-mostly during apply. Writes happen from one thread (the apply thread) per tx; reads happen from many threads (RPC, peer-overlay validators verifying proposals, etc.).

For Plan 1 (parallel apply) this becomes multi-writer. The recommended design:

  • Reads always lock-free via std::atomic<std::shared_ptr<FlatStateMap>> swap-on-close. RPC threads load the current pointer once and read against it; reads never block writes.
  • Writes during apply go to a per-worker overlay (matching Plan 1's per-worker journal), merged at end-of-round.

This works because the flat map mirrors the SHAMap, and Plan 1's scheduler already proves conflict-free access sets — so two workers can't both be writing the same flat-map entry.

6. Phasing

Phase 1 (1 month): Eager flat map, write-through, no read fallback

Goal: the flat map is the only read source. Reads never descend the SHAMap. This is the 2w/1r pattern in full.

  • P6.1 Add FlatStateMap data structure (src/libxrpl/ledger/FlatStateMap.{h,cpp}). Option D layout — immutable base per closed ledger + mutable overlay for open ledger.
  • P6.2 Eager startup populate. On node startup (after the most-recent closed ledger is loaded from NodeStore), walk the SHAMap once and build the flat map. Estimated ~60–100 s for current mainnet state (~10M SLEs). Optionally persist the built flat map to a sidecar store keyed by ledger hash so warm restarts skip this work entirely.
  • P6.3 Dual-write on apply. Every view.peek()/update()/insert()/erase() updates the flat-map overlay in lockstep with the SHAMap. This is the "2× write" cost — small (one hash-map insert per mutation vs. ~8 inner-node hashes today).
  • P6.4 Reads go to flat map only. ReadView::read(Keylet) reads from flat.find(key). No SHAMap fallback. A miss is a bug — assert in debug, log + crash in release. The SHAMap and flat map are invariants of each other.
  • P6.5 Differential invariant check runs in CI and in DEBUG builds at runtime: after every OpenLedger::accept(), walk both structures and assert byte-identical (keylet → SLE hash) mappings. This is the non-bypassable correctness gate that replaces the runtime fallback.
  • P6.6 On OpenLedger::accept(), freeze the overlay into a new immutable base for the now-closed ledger.

Deliverable: every read in the system is O(1). SHAMap descent is dead code on the read path — only consulted during startup populate and Merkle-proof generation. Zero behavior change observable to the network.

Why no fallback is the right call:

  • A fallback path hides drift. A flat-map miss that silently SHAMap-descends means a real bug runs undetected, possibly for weeks, until something else exposes the inconsistency.
  • The startup cost is bounded (~minutes) and amortized over node-session lifetime (days/weeks).
  • The differential invariant check (P6.5) catches divergence at the source — at the close boundary — instead of via mysterious read latency spikes.

Phase 2 (1 month): Memory-resident historical ledgers

Goal: keep flat maps for the last N closed ledgers (default N=20) so recent-history queries are fast.

  • P6.6 Closed ledgers retain their immutable flat-map base. Old ledgers evict their flat map (SHAMap stays in the LedgerHistory cache).
  • P6.7 Memory measurement: per closed ledger flat map size = 8 bytes pointer × number of SLEs in ledger ≈ 8 × 10M ≈ 80 MB. For N=20 ledgers retained: ~1.6 GB. Verify against the RippleX memory budget (current SHAMap+cache is ~20 GB; +8% is acceptable).
  • P6.8 Operator config: flat_state_retained_ledgers = 20 (range 5–256).

Deliverable: RPC queries against recent ledgers (~30 seconds of history) are 5–10× faster.

Phase 3 (1 month): Public API + secondary indexes

Goal: expose the flat map to external consumers and provide type-indexed iteration.

  • P6.9 class FlatStateView : public ReadView — public-facing read view with O(1) reads and type-indexed iteration.
  • P6.10 Add secondary indexes by LedgerEntryType. Update on every flat-map write. Expose forEachOfType() and forEachInRange().
  • P6.11 StateDiff(from, to) — returns the set of (keylet, before, after) between two ledger versions. Implementable as flat_map[to].diff(flat_map[from]).
  • P6.12 subscribeStateUpdates() — streaming callback API; called once per ledger close with the StateDiff. The "other project" consumes this.
  • P6.13 RPC endpoint state_diff and state_by_type for off-process consumers.

Deliverable: external consumers can mirror XRPL state at sub-second latency via streaming callbacks or RPC.

Phase 4 (concurrent with Plan 1 if both are funded): Parallel-apply integration

Goal: multi-writer overlay consistent with Plan 1's worker-journal model.

  • P6.14 Per-worker flat-map overlay (matches Plan 1 §3's per-worker WriteJournal).
  • P6.15 Merge per-worker overlays into the round's overlay at end-of-round, in the same deterministic order Plan 1 uses.
  • P6.16 Verify byte-identical SHAMap roots: the flat map's writes are validated against SHAMap writes via the differential CI gate already required by Plan 1.

If Plan 1 ships first, this is a small extension. If Plan 6 ships first, Plan 1 inherits the overlay design.

Phase 5 (1 month): Validation + rollout

  • P6.17 Run the mainnet replay corpus through the new read path. Every ReadView::read() result must be byte-identical to the SHAMap-descent result.
  • P6.18 Devnet/testnet for 4 weeks under load. Measure: read throughput, flat-map hit rate, memory delta, RPC latency distribution.
  • P6.19 Ship as a feature in the next minor release; no amendment needed (the flat map is purely auxiliary).

7. Memory cost (honest accounting)

Per closed ledger, the flat map adds:

overhead per SLE:
  - hash bucket entry:  ~16 bytes (key + pointer + next-bucket)
  - shared_ptr control block: shared with existing SLE (no extra)
  - secondary index node: ~24 bytes
  
total: ~40 bytes per SLE

At ~10M SLEs in current mainnet state: ~400 MB per ledger flat map. For N=20 retained ledgers: ~8 GB.

That's substantial. Mitigations:

  • The SLE objects themselves are shared via shared_ptr across ledger versions; only the pointers + bucket overhead are duplicated
  • Persistent HAMT (Option C in §4) cuts this dramatically by structurally sharing unchanged buckets across versions
  • Lower default flat_state_retained_ledgers to 5 if 8 GB is too much; that's ~2 GB

Trade-off: validators with tight memory budgets can disable Plan 6 entirely (operator config) and fall back to SHAMap descent. The flat map is opt-in (config-gated, not amendment-gated). External consumers running indexer / RPC-heavy nodes get the speedup; validators with memory pressure don't pay for it.

8. Risks

Correctness

  • Synchronization with SHAMap. Every SHAMap mutation must produce the corresponding flat-map mutation. Mitigation: route all writes through a single StateWriter abstraction that updates both; differential CI gate (compare flat_map.read(k) vs shamap.read(k) for all k after every test) catches drift.
  • Lifetime of cached SLEs. Flat map holds shared_ptr<SLE const>. SHAMap also holds them (via SHAMap leaf nodes). Both must agree on lifetime; SLEs are immutable once published, so this is straightforward but easy to get wrong with COW semantics.
  • Subscription correctness. If a subscriber goes silent, do we buffer or drop? Default: drop (lossy stream; consumer reconnects with StateDiff(last_seen_ledger, current)).

Performance

  • Memory growth. Per-ledger flat-map overhead is the dominant cost. Mitigation: low retention default, operator config, persistent HAMT as a v2.
  • Hot-bucket contention. During parallel apply, two workers writing to the same hash bucket (different keys, same bucket) would serialize. Mitigation: per-worker overlays merged at end of round — no concurrent bucket access during apply.

Operational

  • Feature flag complexity. Plan 6 ships as flat_state = enabled (default true). Validators with memory pressure can disable. If disabled, reads fall back to SHAMap descent — no other behavioral change.

9. Interaction with Plan 5 and Plan 7

Plan 5 (incremental SHAMap hashing) addresses the per-write SHAMap-inner-node hash cost. With Plan 6 alone, this cost still exists — apply does dual-write to SHAMap + flat map, and the SHAMap update still walks dirtyUp(). Plan 5's ~1.05–1.10× lift is real on top of Plan 6.

Plan 7 (deferred SHAMap construction) is the structural follow-on: the SHAMap is no longer updated during apply. Writes touch the flat map only; the SHAMap is rebuilt at close from the flat map's modified set. See plan-7-deferred-shamap.md. When Plan 7 ships, Plan 5 becomes obsolete — there's no per-write SHAMap hashing to optimize. The inner-node hashing moves to a single batched bottom-up build at close, which is embarrassingly parallel by subtree.

Recommended sequence:

  1. Plan 6 (this plan) — 2w/1r in full, no fallback. ~1 month. Lifts reads.
  2. Plan 7 — deferred SHAMap. ~3–4 months including amendment. Lifts writes by removing per-write SHAMap maintenance.
  3. Plan 5 — only if Plan 7 doesn't ship for whatever reason. Otherwise deprecated.

10. Comparison to Xahau RWDB

Xahau ships an in-memory database called RWDB (github.com/Xahau/xahaud RWDBFactory.cpp). A reviewer asked whether Plan 6 duplicates it. It does not — they sit at different layers and compose.

Layer What lives here Xahau status This plan
NodeStore backend (storage medium) Compressed node blobs keyed by node hash RWDB replaces NuDB/RocksDB with std::map<uint256, vector<uint8_t>> in RAM. NOT persistent — wiped on restart. Untouched.
SHAMap (Merkle structure) The authoritative state-root trie Unchanged in Xahau — still maintained incrementally per-write Plan 7 makes it deferred.
Read pattern (how an SLE is found) ReadView::read(Keylet) → SHAMap descent → 6–10 spinlocked inner-node fetches Unchanged in Xahau — RWDB still requires SHAMap descent and per-inner-node decompression Plan 6 makes it O(1).

Plan 6 is most impactful on top of RWDB. RWDB removes disk latency but still pays per-inner-node mutex + decompression per read. Plan 6 skips the descent entirely. RWDB + Plan 6 + Plan 7 is the natural endpoint:

  • RWDB: where the bytes live (RAM)
  • Plan 7: when the Merkle authority is built (at close, not per-write)
  • Plan 6: how state is looked up (O(1) flat map, not SHAMap descent)

Three distinct optimizations; Xahau did one; this plan + Plan 7 do the other two.

11. Open questions

  1. External consumer authentication. If the subscribeStateUpdates API is exposed via gRPC or WebSocket, what auth model? Likely defer to existing RPC auth.
  2. Snapshot retention vs. memory. The default flat_state_retained_ledgers = 20 is a guess. Tune from operator feedback in Phase 5.
  3. Persistent HAMT (Option C) vs. snapshot-array (Option D). Option D ships first; Option C is a v2 if memory pressure justifies it.
  4. gRPC streaming vs. WebSocket. Streaming endpoint format for external consumers. gRPC is more efficient for high-throughput indexers; WebSocket matches existing subscribe patterns. Probably both.

Recommended kickoff sequence

  1. Phase 1 (1 month) — biggest win, smallest risk. Ships independently. Lifts every read in the system.
  2. Phase 3 (overlap Phase 1 by 2 weeks) — design the public API alongside the internal change so the "other project" can integrate against the API before Phase 5.
  3. Phase 2 (1 month) — memory-resident historical ledgers. Validates the memory budget assumption.
  4. Phase 5 (1 month) — rollout.

Total: ~3 months for Phases 1+2+3+5 (Phase 4 deferred unless Plan 1 is concurrent).

Smallest meaningful slice if the team can only afford 1 month: Phase 1 alone — every internal read becomes O(1); the external API is deferred. Even this slice delivers a 5–10× speedup on the apply-path read load and ships with zero protocol risk.


Implementation status (this session)

Code-complete and verified at the library level. Default-off. Not yet wired into the live node.

Done + verified (libxrpl)

Piece Where Tests
FlatStateMap (flat keylet→SLE, shared_mutex, deep-copy snapshot()) FlatStateMap.h / .cpp unit + concurrency + bench
Write-through mirror (mirrorRaw*) wired into Ledger::raw{Insert,Replace,Erase} (P6.3) Ledger.cpp mirror + integration
Flat-only read routing in Ledger::read (P6.4) Ledger.cpp LedgerReadFlatPathMatchesShaMapPath
Close-time differential invariant validateFlatStateMapMatchesShaMap (P6.5) Ledger.cpp InvariantFailsOn{Phantom,Missing}Entry (failure-direction proven)
Multi-ledger lifecycle: child inherits an INDEPENDENT snapshot of the parent's map Ledger.cpp:255 ChildInheritsIndependentSnapshot, ChildWritesDoNotCorruptParentSnapshot, PropagationChainsAcrossGenerationsred-proven (sharing instead of snapshotting fails them)

When no map is attached (the default), every path is a no-op ⇒ byte-for-byte the prior behavior. Full libxrpl suite (8 targets) green.

Note on P6.1/P6.2

The v2 design (eager full mirror, reads flat-only) supersedes the original P6.1/P6.2 LRU positive+negative caches — a full materialization needs no separate negative cache (a miss IS absence). Those checklist items are obsolete under v2.

Remaining — NOT completable/verifiable in a coding session

  • Live-app wiring (operator flag → Application startup attachFlatStateMapTo → close-invariant gate in BuildLedger.cpp:46). Touches the consensus-critical close path; compile-only-verifiable here (full-node build), not runtime-verifiable without a running node.
  • P6.7 mainnet replay gate — the non-negotiable safety gate (byte-identical state roots over months of history). Requires the replay harness + infra; cannot be run here. Flat-only reads must NOT be enabled in production before this passes (a flat-map bug = wrong state served = consensus divergence).
  • P6.8/P6.9 devnet→testnet rollout, default-on. Ops + time.
  • Persistent/HAMT map — the deep-copy snapshot() is O(N)/ledger; correct but too costly to enable as-is. A structurally-sharing map is the prerequisite for the feature to be a net win, and is a scoped follow-on data-structure task.

Bottom line: all of Plan 6 that is code and can be proven correct in-repo is done and green. What remains is integration into a running node plus the multi-month replay/rollout validation — gated on infrastructure and time, not on more code.

Plan 7 — Deferred SHAMap construction (revised)

Goal: during apply, change SHAMap structure freely but skip per-mutation hash recomputation. Batch all the hash work into a single bottom-up walk at ledger close, parallelized by top-level subtree. Output is byte-identical to the current incremental path by construction — same tree, same children at each node, same hashes — just computed once at the end instead of cascading on every write.

This document supersedes the original draft. The original was written against a model of SHAMap I had not verified (leaves at depth 64, fully expanded tree). Reading the actual implementation showed that model wrong, which invalidated the algorithm the original doc described. This revision is grounded in verified behavior from the actual source.


Status update (see plan-7-quantify.md)

Two corrections to the "real optimization plan" below, found by reading the source and measuring:

  1. Phase 1's bulk-COW premise is false. SHAMap's cowid copy-on-write already deduplicates clone allocations across a whole ledger round — each inner node is cloned at most once regardless of mutation count (unshareNode only clones when cowid is stale; cowid is bumped only at snapShot, and a round's changes all funnel into one stateMap_). So bulkApply saves only redundant traversal, not allocations. Measured: hashing, not traversal, dominates the per-close cost.

  2. Phase 2 is the real lever and is now BUILT. SHAMap::updateHashesParallel recomputes dirty hashes fanned out by top-level subtree, byte-identical to serial getHash(), TDD-verified, ~6–8× on a 16-core box. Phase 1 (bulkApply) is deprioritized to a small traversal-only optimization. Phase 3 (wire into close behind a flag + differential replay) remains.


What was verified by reading the code

Sources read: src/libxrpl/shamap/SHAMap.cpp, src/libxrpl/shamap/SHAMapInnerNode.cpp.

Insert (SHAMap::addGiveItem, line 768):

  1. walkTowardsKey(key, &stack) descends from root. At each inner node it picks the branch by the next nibble of the key. It stops at the first empty branch or the first leaf (line 147-148).
  2. If stopped on an empty branch: the new leaf is placed at that branch, at whatever depth walkTowardsKey happened to stop. Leaves live at the shallowest depth where their key is unambiguous, not at depth 64.
  3. If stopped on a leaf (key collision in the prefix): allocate new inner nodes and push DOWN through more nibbles until the two keys' nibbles diverge. Place both leaves as children of the deepest new inner node, on their respective diverging branches.

Delete (SHAMap::delItem, line 683):

  1. Walk to the leaf and remove it.
  2. Walk back up the stack. If an inner node ends up with exactly one child after the delete, pull the remaining child up to replace it. Path compression in reverse.

Inner-node hash (SHAMapInnerNode::updateHash, line 191-204):

hash = sha512_half(InnerNodePrefix || child[0].hash || child[1].hash || ... || child[15].hash)

Each child contributes:

  • Empty branch → 32 zero bytes
  • Leaf child → the leaf's hash (computed from the SLE bytes; see SHAMapTxLeafNode::updateHash)
  • Inner-node child → that inner node's own hash

The hash computation does not encode depth. What matters is the actual shape and hashes of the 16 children at that node. So at the same conceptual (depth, prefix) position, two trees with the same children produce the same hash — but a tree with a leaf at branch 5 of root has a fundamentally different root hash than a tree with an inner node at branch 5 of root (even if that inner node eventually leads to the same leaf), because the byte at root's branch-5 slot is different (leaf hash vs. inner hash).


Why this kills the algorithm in the original doc

The original draft described an algorithm that:

  1. Planned every ancestor of every modified leaf from depth 1 to 63.
  2. Used a position-keyed callback uint256(int depth, uint256 const& prefix) to look up child hashes from the parent SHAMap.

This algorithm models a fully expanded radix tree — every key traverses every depth from 1 to 64 before reaching its leaf. Real SHAMap does not do this. So:

  • For a SHAMap with one leaf, the original algorithm produced 64 nested inner-node hashes ending in the leaf. Real SHAMap produces one inner node (root) with the leaf as a direct child. Different bytes.
  • The position-keyed callback can't express child type — a callback returning a hash at (depth=1, prefix) can't distinguish "an inner node lives here" from "a leaf lives here." Real SHAMap structurally distinguishes; the hash bytes are the same but the parent that includes them produces different hashes depending on what type of node sits at that slot. Wait — re-check. The parent's hash is sha512_half(prefix || child0 || ... || child15). The child hashes ARE the byte input. A leaf-at-branch-5 contributes the leaf's hash bytes; an inner-at-branch-5 contributes the inner's hash bytes. Both are 32 bytes. So if the callback happens to return the correct 32-byte hash for whatever lives at that position (leaf or inner), the parent hash IS correct. The issue is not the parent's hashing — it's that my algorithm assumes everything between root and leaf is an inner node and computes hashes for inner nodes that don't exist in the real tree, then feeds those phantom inner-node hashes up to the parent.

Concrete example, verified by the test Plan7DoesNotMatchPathCompressedShaMap_DocumentedLimitation:

  • A Ledger with five SLEs, keys differing in first nibble.
  • Real SHAMap root: an inner node with five leaf children and 11 empty branches.
  • Original algorithm's "root": an inner node with five inner-node children (each representing the start of a 63-deep chain) and 11 empty branches.
  • Root hashes differ. The test asserts inequality and passes.

The original algorithm's claim of byte-identical output was wrong. The library code (planDeferredRebuild, executeRebuildPlan, deferredRebuildRoot, parallel variants) implements that wrong model correctly — its tests pass because they're internally consistent — but it does not match SHAMap.


The correct design

Stop trying to build a free-standing algorithm that mirrors SHAMap behavior from outside. Instead, modify SHAMap to defer the work it already knows how to do.

The optimization claim that the original doc made — defer hash recomputation, batch it at close, parallelize by subtree — is still valid. But it lives inside SHAMap, not above it.

What SHAMap does today

Every addItem / delItem calls dirtyUp(stack, target, terminal) at the end (SHAMap.cpp:100). dirtyUp walks the stack from leaf to root, calling setChild on each ancestor inner node (which marks the node mutable + zeros its cached hash via setChild's implementation in SHAMapInnerNode::setChild line 268-306, which ends with hash_.zero()). Then when getHash() is finally called at close, it walks down and recomputes hashes lazily.

Actually re-reading: dirtyUp doesn't compute hashes per se — it propagates child references up. Hashes are computed on-demand by getHash() (and the updateHashDeep family). The per-write cost is mostly the unshareNode clones and the stack walk, not SHA-512 itself.

This changes the cost model. Let me re-check what dirtyUp actually does:

// SHAMap.cpp:100-130
void SHAMap::dirtyUp(stack, target, child) {
    while (!stack.empty()) {
        auto node = stack.top().first;  // parent inner node
        SHAMapNodeID const nodeID = stack.top().second;
        stack.pop();
        int const branch = selectBranch(nodeID, target);
        node = unshareNode(std::move(node), nodeID);  // COW
        node->setChild(branch, std::move(child));     // zeros parent's hash_
        child = std::move(node);
    }
}

So per mutation, the actual cost is:

  • One COW (unshareNode) per ancestor — allocates a new node, copies child pointers
  • One setChild per ancestor — bit-twiddling + hash invalidation
  • No SHA-512 per mutation. Hash computation is deferred to getHash().

That's a much smaller per-write cost than my original cost model claimed. The 290 ms/close number in the original doc was wrong — it counted SHA-512 work, but that work was already being deferred. The actual per-write cost is COW + pointer chasing, which is maybe ~5 µs per ancestor × ~6-10 ancestors × thousands of writes = tens of milliseconds per close, not hundreds.

What's actually optimizable

Two costs remain on the per-write hot path:

  1. COW clones on the path from leaf to root. These allocate new SHAMapInnerNode objects.
  2. Repeated traversal when consecutive mutations target keys with shared prefixes (each walkTowardsKey redoes work the previous one did).

What can be batched:

  • Bulk COW: if N mutations all share an ancestor at depth D, that ancestor only needs to be COW'd once. Today it's COW'd N times.
  • Bulk traversal: a batch of mutations sorted by key can be applied with a single descent that handles all of them at each shared ancestor.

What the SHA-512 work actually looks like at close (when getHash() is invoked):

  • SHAMapInnerNode::updateHash walks 16 children, hashes 516 bytes total. About 3 µs per inner node.
  • Only nodes with zeroed hashes need recomputation (everything dirty since the last getHash()).
  • The total at close depends on how many unique ancestor nodes were dirtied: typically much less than the total inner node count, because mutations cluster.

For a 4000-tx ledger with say ~3000 unique modified keys and ~6-10 ancestor depth, the worst case is roughly: (modified-leaves × ancestors-per-leaf) inner nodes, deduplicated by position. Practically that's ~thousands of inner nodes, not the original doc's 18,000.

The real optimization plan

Stage 1: bulk traversal + bulk COW.

Instead of calling addItem once per mutation:

  1. Sort the modifications by key.
  2. Walk the SHAMap once, descending into each subtree in order, applying all modifications that target keys in that subtree before backtracking.
  3. COW each ancestor once, regardless of how many modifications go through it.

Expected savings: most of the per-write COW cost. Implementation is a refactor inside src/libxrpl/shamap/, adding a bulkApply method or similar. No new algorithm needed — same SHAMap semantics, more efficient batching.

Stage 2: parallel hash recomputation at close.

When getHash() is called after a batch of modifications, the set of dirtied inner nodes is known (their cached hash was zeroed by setChild). Walking this set bottom-up to recompute hashes can be parallelized by top-level subtree — the root has 16 children; if their subtrees were independently modified (different first nibbles), their hash recomputation can run on 16 threads.

Implementation: a new method on SHAMap, updateHashesParallel(), that fans out by top-level child. The existing updateHashDeep is a serial in-order walker — the parallel version follows the same recursion but dispatches each branch to a worker.

Stage 3: at close, single getHash() call.

The current OpenLedger::accept() path eventually calls getHash() once to compute the ledger header's stateHash. With Stages 1 + 2, that call drives the parallel bulk-rebuild. No further plumbing needed — the existing close pipeline already lands here.

What this is and isn't

  • Same SHAMap structure. No changes to insertion semantics, no changes to path compression, no changes to delete-time collapse.
  • Same hash output by construction. Computing the same children in the same order produces the same bytes.
  • No amendment needed. This is a pure internal optimization. The protocol can't see the difference because the bytes are identical.
  • Plan 6 is still useful but no longer load-bearing for Plan 7. Plan 6 makes reads O(1) by materializing a flat keylet→SLE map. Plan 7 makes writes faster by batching SHAMap mutations and parallelizing hash recomputation. They compose but don't depend on each other.

Phasing (revised)

Phase 1 — Bulk apply inside SHAMap (4-6 weeks)

  • Read addGiveItem and delItem carefully. Identify the per-mutation work that can be amortized across a batch (COW, traversal).
  • Add SHAMap::bulkApply(modifications) where modifications is a sorted sequence of (key, action). Action is one of {insert(sle), replace(sle), erase}.
  • Implement bulk apply as a single descent of the SHAMap, processing all modifications at each ancestor before backtracking. COW each ancestor at most once.
  • Differential test: apply N random modifications via per-mutation addItem/delItem → SHAMap A. Apply the same N via bulkApply → SHAMap B. Assert A.getHash() == B.getHash() for every mutation count from 1 to 10,000 on randomized seeds.
  • Benchmark: per-mutation API vs. bulkApply on workloads of 100, 1k, 10k mutations. Expect ~2-5× speedup from COW deduplication.

Phase 2 — Parallel hash recomputation (3-4 weeks)

  • Identify the dirty-set after a bulkApply — the inner nodes whose cached hash was zeroed.
  • Add SHAMap::updateHashesParallel(workers) that walks the dirty set bottom-up, parallel by top-level subtree.
  • Differential test: getHash() after serial walks vs. updateHashesParallel(N).getHash() for N in {1, 2, 4, 8, 16}. Byte-identical.
  • Benchmark: hash recomputation time at close on a dirty set of 1k, 10k, 50k inner nodes. Target: linear scaling to 8-16 workers.

Phase 3 — Wire into close (2-3 weeks)

  • Identify the right call site for bulkApply in the apply pipeline. The natural one is replacing the per-tx Ledger::raw{Insert,Replace,Erase} SHAMap calls with a batched accumulator that flushes via bulkApply at end-of-round.
  • Feature flag deferred_hash_rebuild (config-level, not amendment-gated). Off by default.
  • Devnet for 4 weeks under the flag, with continuous differential assertion (bulkApply output getHash() vs. per-mutation path output getHash()).

Phase 4 — Roll forward (2-4 weeks)

  • If devnet metrics show the expected speedup and no divergence, flip the flag default on in a point release.
  • Monitor mainnet validators for a release cycle.
  • Retire the per-mutation path eventually (separate amendment), once confidence is established.

Total: 3-4 months engineering. No amendment governance required for Phases 1-3.


Cost model (revised)

The original doc's per-close numbers were wrong because they counted SHA-512 work that's already deferred by SHAMap's current design.

The actual per-write costs that this plan attacks:

  • COW allocation: each unshareNode call along the path from leaf to root creates a new SHAMapInnerNode. For 4000 mutations with ~6-10 depth, that's 24k-40k allocations per close. Bulk apply deduplicates these.
  • Traversal: each walkTowardsKey descends from root. Bulk apply can reuse state across modifications with shared prefixes.

Per-close savings from Phase 1 (bulk apply): plausibly 30-50% of apply-time SHAMap-related work. Single-digit milliseconds per close for typical workloads, scaling with mutation count.

Per-close savings from Phase 2 (parallel hash): the hash work at close itself. For a dirty set of ~10k inner nodes, that's ~30 ms serial → ~5 ms on 8 workers. Useful but not transformative.

Combined per-close lift: ~20-30 ms on a 4000-tx ledger. Not the 290 ms → 7 ms claim from the original doc. The original cost model was wrong; the real one is more modest.

This is still worth doing — multiplied by mainnet's ledger cadence and the throughput context (Plan 2's close-time floors), small per-close savings compound. But the framing should be honest about the order of magnitude.


What's actually in the repo from the previous (incorrect) Plan 7 work

The library code below remains in the tree because the kernel ideas (planning, parallel-by-subtree, LCP-based dedup) are useful patterns even though the algorithm they're applied to is wrong for SHAMap. They're labelled clearly in the source as a depth-64-leaves kernel, not as a SHAMap-compatible implementation.

File Status
include/xrpl/ledger/DeferredRebuild.h Working kernel for a depth-64-leaves model. Not used by anything in production.
src/libxrpl/ledger/DeferredRebuild.cpp Implementation of the above.
src/tests/libxrpl/ledger/DeferredRebuild.cpp 28 tests + 5 benchmarks. They pass for the wrong algorithm. Useful as a reference for the testing patterns (TDD-with-benchmarks, differential vs. a hash oracle) — not as a correctness gate for SHAMap behavior.
src/tests/libxrpl/ledger/FlatStateMapIntegration.cpp::Plan7DoesNotMatchPathCompressedShaMap_DocumentedLimitation The test that documents the gap. Asserts EXPECT_NE between the kernel's output and real SHAMap's root for a path-compressed state.

Recommendation: keep these files as a worked example of the pattern (LCP-based key planning + parallel-by-subtree dispatch), with a clear top-of-file comment that says "this kernel does not match SHAMap and is not used in production." They may be useful for future algorithmic work on tree variants that DO have leaves at uniform depth (e.g., Merkle Patricia Tries with leaf normalization, certain Verkle constructions). For SHAMap itself, the right code lives inside src/libxrpl/shamap/, not at this layer.


Open questions

  1. Is bulk apply worth the SHAMap refactor effort given the modest per-close savings? Genuine question. If the realistic lift is 20-30 ms per close, and Plan 2 (close-time floors) is independently delivering hundreds of milliseconds, Plan 7 may be deprioritizable. Need to measure actual production close times under load to make a sound prioritization call.

  2. Does Plan 1 (parallel apply) change the calculus? If apply runs on many cores, SHAMap mutations are concurrent, and contention on COW + dirtyUp becomes a bottleneck. Plan 7's bulk apply may be a prerequisite for Plan 1's lift to land, not just a standalone optimization.

  3. Is there a meaningful win from making dirtyUp lazier? Currently each mutation does COW + setChild on every ancestor. A lazier version could record "this mutation happened at key K" and process the implications only when getHash() is called. But this changes invariants about what a "live" SHAMap looks like; needs careful thought.

These are real questions the original doc handwaved past. Answering them properly requires measurement on real workloads, not assumption.


Process note

The original draft of this plan was written without reading SHAMap.cpp. The implementation it described was internally consistent and tested cleanly, but tested the wrong algorithm. The failure mode was: "I have a mental model of how this probably works → I can design from first principles → tests passing means the design is correct." The model was wrong, so the design was wrong, and the passing tests proved internal consistency, not external correctness.

The correct workflow is in the new phasing above: read the production code first, identify what's actually optimizable, write differential tests against the production path before writing the optimization, then iterate.

The previous library code stays in the repo as a record of the dead-end. It compiles, passes its own tests, and is documented as a kernel for a tree shape SHAMap doesn't use. Future work on this plan starts inside src/libxrpl/shamap/.

Plans 6 & 7 — Flat State Reads + Deferred SHAMap Hashing

Two complementary optimizations to the XRPL state engine. They compose but do not depend on each other:

  • Plan 6 makes reads O(1) by materializing state as a flat keylet→SLE map.
  • Plan 7 makes writes/close faster by deferring SHAMap hash recomputation and parallelizing it across subtrees at ledger close.

Neither requires an amendment. Both are pure internal optimizations — the protocol cannot observe the difference because the authoritative SHAMap and its hashes are byte-identical to today.


Part I — Plan 6: Flat keylet-indexed state lookup (read-side)

Goal: materialize XRPL state as a flat keylet→SLE map. Apply does dual-write (SHAMap stays authoritative, flat map indexes reads). Reads go to the flat map only — no fallback path. This is the pure NoSQL 2-writes-for-1-read pattern. Direct TPS lift is moderate-to-large; the indirect value is the larger story — this unlocks downstream consumers (indexers, light clients, hooks, AMM bots, sidecar services) that currently can't query XRPL state at scale.

Framing: the SHAMap is a NoSQL substrate (keylet→SLE Merkle trie) currently being used in a SQL access pattern (descend the index on every read). Plan 6 finishes what the substrate was designed for: materialize the read view.

1. The problem

Today every state read in rippled/xrpld descends the SHAMap. The descent is:

  1. Take the root, do a spinlock-protected lookup of branch[nibble₀] → child node
  2. If child is an inner node, recurse to branch[nibble₁]
  3. After ~6–10 levels (practical, log₁₆ of ~10M state entries), reach the leaf
  4. Read the SLE from the leaf

Per descent cost:

  • 6–10 inner-node lookups
  • Each lookup acquires a PackedSpinlock (one bit in an atomic<uint16_t>) — at high contention this is real
  • Each inner node may need to be fetched from the TaggedCache; cache misses go to NodeStore (disk!)
  • ~6–10 cache-line touches even on a warm cache

Reads-per-tx vary by tx type but typical Payment reads ~5–10 SLEs (src AccountRoot, dst AccountRoot, src trustline, dst trustline, src OwnerDir, fee settings, etc.). For 4000 tx/ledger, that's 20k–40k SHAMap descents per ledger close on the apply path alone. RPC + ledger queries multiply this.

The flat lookup replaces each descent with one unordered_map::find().

2. What changes architecturally

Two separate structures:

Structure Role Updates
SHAMap (existing) Authoritative — produces the state root that consensus agrees on. Provides Merkle proofs. On apply; full COW snapshot per closed ledger. Stays as-is.
FlatStateMap (new) Read-side index. unordered_map<uint256, shared_ptr<SLE const>> keyed by SHAMap key. Maintained alongside SHAMap; same lifecycle.

The SHAMap is unchanged. Its hash, its Merkle proof generation, its consensus role, its on-wire serialization — all unchanged. No amendment required.

Lifecycle (per Ledger object):

                       ┌──────────────────────────────┐
   Closed Ledger N  ──>│ SHAMap (authoritative)       │
                       │ FlatStateMap (read fast)     │
                       │ Pointers share SLE objects   │
                       └──────────────────────────────┘
                                    │
                                    │  close N+1
                                    ▼
                       ┌──────────────────────────────┐
   Closed Ledger N+1   │ SHAMap (COW from N + diff)   │
                       │ FlatStateMap (COW from N +    │
                       │   diff; shares unchanged SLEs│
                       │   with N's flat map)         │
                       └──────────────────────────────┘

The COW property is preserved via shared SLE pointers. If ledger N+1 only modifies 100 SLEs out of 10M, ledger N+1's FlatStateMap has 100 new pointers and 9,999,900 pointers shared with ledger N (via a persistent / copy-on-write hash map; see §4).

3. The API surface (external value)

ReadView interface stays unchanged. The internal implementation changes; consumers see the speedup transparently:

// Existing API — semantics unchanged, implementation faster
std::shared_ptr<SLE const> ReadView::read(Keylet const& k) const;
bool                        ReadView::exists(Keylet const& k) const;

For external consumers (the "other project" enablement):

// New public API — exposes the flat map directly for bulk consumers
class FlatStateView {
public:
    // O(1) keylet lookup, no SHAMap descent
    std::shared_ptr<SLE const> read(uint256 const& key) const;

    // Iterate all SLEs of a given type — backed by a secondary index
    template <typename F>
    void forEachOfType(LedgerEntryType type, F&& visitor) const;

    // Diff between two ledger versions — for indexers
    StateDiff diff(LedgerIndex from, LedgerIndex to) const;

    // Subscribe to state changes per ledger — for streaming consumers
    Subscription subscribeStateUpdates(StateUpdateCallback);
};

The "other project" likely needs one or more of:

  • Indexers / explorers — bulk read at ledger close
  • Light clients — Merkle proofs (still served from SHAMap) + fast reads (served from flat map)
  • Hooks / smart contract execution — random-access state reads at execution time
  • AMM/DEX bots — sub-millisecond pool state queries
  • Sidecar services mirroring state in a different store (Postgres, Redis, columnar)

The subscribeStateUpdates + StateDiff API gives external services an authoritative incremental feed without polling.

4. Data structure choices

FlatStateMap implementation options

Option Reads Writes COW Memory Notes
A. std::unordered_map, rebuilt per ledger O(1) O(1) Re-allocate everything per ledger High churn Simplest; OK if rebuild cost is small
B. std::unordered_map + COW per ledger via shared_ptr to buckets O(1) O(1) avg, O(N) worst (rehash) Per-bucket COW ~2× pointers Awkward to make truly COW
C. Persistent HAMT (Hash Array Mapped Trie) O(log₃₂ N) ~ effectively O(1) O(log₃₂ N) Structural sharing native +~5–10% pointers Best COW story; Clojure / Scala use this
D. Two-tier: immutable "base" map per closed ledger + mutable overlay for open ledger O(1) O(1) Snapshot-on-close Low overhead Pragmatic; matches existing OpenLedger/Closed semantics

Recommendation: Option D. Matches how XRPL ledgers already work — open ledger is mutable, closed ledger is immutable. The flat map's open-ledger overlay holds in-flight writes; on close, the overlay merges into a new immutable base. Read path: check overlay first, then base.

Option C (HAMT) is the elegant long-term answer if we end up needing many concurrent historical ledger snapshots, but D ships sooner and covers 99% of read traffic.

Secondary indexes (the bonus)

Once we have a flat map, secondary indexes by LedgerEntryType become trivial:

flat_hash_map<LedgerEntryType, intrusive::list<SLE_entry>> by_type_;

This makes queries like "all AMM pools" / "all offers on this book" sub-millisecond, which is exactly what indexer-class consumers need. Currently these queries require full SHAMap traversal.

5. Concurrency model

The flat map is read-mostly during apply. Writes happen from one thread (the apply thread) per tx; reads happen from many threads (RPC, peer-overlay validators verifying proposals, etc.).

For parallel apply (a separate plan) this becomes multi-writer. The recommended design:

  • Reads always lock-free via std::atomic<std::shared_ptr<FlatStateMap>> swap-on-close. RPC threads load the current pointer once and read against it; reads never block writes.
  • Writes during apply go to a per-worker overlay (matching a per-worker journal), merged at end-of-round.

This works because the flat map mirrors the SHAMap, and a parallel-apply scheduler already proves conflict-free access sets — so two workers can't both be writing the same flat-map entry.

6. Phasing

Phase 1 (1 month): Eager flat map, write-through, no read fallback

Goal: the flat map is the only read source. Reads never descend the SHAMap. This is the 2w/1r pattern in full.

  • P6.1 Add FlatStateMap data structure (src/libxrpl/ledger/FlatStateMap.{h,cpp}). Option D layout — immutable base per closed ledger + mutable overlay for open ledger.
  • P6.2 Eager startup populate. On node startup (after the most-recent closed ledger is loaded from NodeStore), walk the SHAMap once and build the flat map. Estimated ~60–100 s for current mainnet state (~10M SLEs). Optionally persist the built flat map to a sidecar store keyed by ledger hash so warm restarts skip this work entirely.
  • P6.3 Dual-write on apply. Every view.peek()/update()/insert()/erase() updates the flat-map overlay in lockstep with the SHAMap. This is the "2× write" cost — small (one hash-map insert per mutation vs. ~8 inner-node hashes today).
  • P6.4 Reads go to flat map only. ReadView::read(Keylet) reads from flat.find(key). No SHAMap fallback. A miss is a bug — assert in debug, log + crash in release. The SHAMap and flat map are invariants of each other.
  • P6.5 Differential invariant check runs in CI and in DEBUG builds at runtime: after every OpenLedger::accept(), walk both structures and assert byte-identical (keylet → SLE hash) mappings. This is the non-bypassable correctness gate that replaces the runtime fallback.
  • P6.6 On OpenLedger::accept(), freeze the overlay into a new immutable base for the now-closed ledger.

Deliverable: every read in the system is O(1). SHAMap descent is dead code on the read path — only consulted during startup populate and Merkle-proof generation. Zero behavior change observable to the network.

Why no fallback is the right call:

  • A fallback path hides drift. A flat-map miss that silently SHAMap-descends means a real bug runs undetected, possibly for weeks, until something else exposes the inconsistency.
  • The startup cost is bounded (~minutes) and amortized over node-session lifetime (days/weeks).
  • The differential invariant check (P6.5) catches divergence at the source — at the close boundary — instead of via mysterious read latency spikes.

Phase 2 (1 month): Memory-resident historical ledgers

Goal: keep flat maps for the last N closed ledgers (default N=20) so recent-history queries are fast.

  • P6.6 Closed ledgers retain their immutable flat-map base. Old ledgers evict their flat map (SHAMap stays in the LedgerHistory cache).
  • P6.7 Memory measurement: per closed ledger flat map size = 8 bytes pointer × number of SLEs in ledger ≈ 8 × 10M ≈ 80 MB. For N=20 ledgers retained: ~1.6 GB. Verify against the RippleX memory budget (current SHAMap+cache is ~20 GB; +8% is acceptable).
  • P6.8 Operator config: flat_state_retained_ledgers = 20 (range 5–256).

Deliverable: RPC queries against recent ledgers (~30 seconds of history) are 5–10× faster.

Phase 3 (1 month): Public API + secondary indexes

Goal: expose the flat map to external consumers and provide type-indexed iteration.

  • P6.9 class FlatStateView : public ReadView — public-facing read view with O(1) reads and type-indexed iteration.
  • P6.10 Add secondary indexes by LedgerEntryType. Update on every flat-map write. Expose forEachOfType() and forEachInRange().
  • P6.11 StateDiff(from, to) — returns the set of (keylet, before, after) between two ledger versions. Implementable as flat_map[to].diff(flat_map[from]).
  • P6.12 subscribeStateUpdates() — streaming callback API; called once per ledger close with the StateDiff. The "other project" consumes this.
  • P6.13 RPC endpoint state_diff and state_by_type for off-process consumers.

Deliverable: external consumers can mirror XRPL state at sub-second latency via streaming callbacks or RPC.

Phase 4 (concurrent with parallel-apply if both are funded): Parallel-apply integration

Goal: multi-writer overlay consistent with a parallel-apply worker-journal model.

  • P6.14 Per-worker flat-map overlay (matches the per-worker WriteJournal).
  • P6.15 Merge per-worker overlays into the round's overlay at end-of-round, in the same deterministic order parallel apply uses.
  • P6.16 Verify byte-identical SHAMap roots: the flat map's writes are validated against SHAMap writes via the differential CI gate.

Phase 5 (1 month): Validation + rollout

  • P6.17 Run the mainnet replay corpus through the new read path. Every ReadView::read() result must be byte-identical to the SHAMap-descent result.
  • P6.18 Devnet/testnet for 4 weeks under load. Measure: read throughput, flat-map hit rate, memory delta, RPC latency distribution.
  • P6.19 Ship as a feature in the next minor release; no amendment needed (the flat map is purely auxiliary).

7. Memory cost (honest accounting)

Per closed ledger, the flat map adds:

overhead per SLE:
  - hash bucket entry:  ~16 bytes (key + pointer + next-bucket)
  - shared_ptr control block: shared with existing SLE (no extra)
  - secondary index node: ~24 bytes

total: ~40 bytes per SLE

At ~10M SLEs in current mainnet state: ~400 MB per ledger flat map. For N=20 retained ledgers: ~8 GB.

That's substantial. Mitigations:

  • The SLE objects themselves are shared via shared_ptr across ledger versions; only the pointers + bucket overhead are duplicated
  • Persistent HAMT (Option C in §4) cuts this dramatically by structurally sharing unchanged buckets across versions
  • Lower default flat_state_retained_ledgers to 5 if 8 GB is too much; that's ~2 GB

Trade-off: validators with tight memory budgets can disable Plan 6 entirely (operator config) and fall back to SHAMap descent. The flat map is opt-in (config-gated, not amendment-gated). External consumers running indexer / RPC-heavy nodes get the speedup; validators with memory pressure don't pay for it.

8. Risks

Correctness

  • Synchronization with SHAMap. Every SHAMap mutation must produce the corresponding flat-map mutation. Mitigation: route all writes through a single StateWriter abstraction that updates both; differential CI gate (compare flat_map.read(k) vs shamap.read(k) for all k after every test) catches drift.
  • Lifetime of cached SLEs. Flat map holds shared_ptr<SLE const>. SHAMap also holds them (via SHAMap leaf nodes). Both must agree on lifetime; SLEs are immutable once published, so this is straightforward but easy to get wrong with COW semantics.
  • Subscription correctness. If a subscriber goes silent, do we buffer or drop? Default: drop (lossy stream; consumer reconnects with StateDiff(last_seen_ledger, current)).

Performance

  • Memory growth. Per-ledger flat-map overhead is the dominant cost. Mitigation: low retention default, operator config, persistent HAMT as a v2.
  • Hot-bucket contention. During parallel apply, two workers writing to the same hash bucket (different keys, same bucket) would serialize. Mitigation: per-worker overlays merged at end of round — no concurrent bucket access during apply.

Operational

  • Feature flag complexity. Plan 6 ships as flat_state = enabled (default true). Validators with memory pressure can disable. If disabled, reads fall back to SHAMap descent — no other behavioral change.

9. Comparison to Xahau RWDB

Xahau ships an in-memory database called RWDB (github.com/Xahau/xahaud RWDBFactory.cpp). A reviewer asked whether Plan 6 duplicates it. It does not — they sit at different layers and compose.

Layer What lives here Xahau status This plan
NodeStore backend (storage medium) Compressed node blobs keyed by node hash RWDB replaces NuDB/RocksDB with std::map<uint256, vector<uint8_t>> in RAM. NOT persistent — wiped on restart. Untouched.
SHAMap (Merkle structure) The authoritative state-root trie Unchanged in Xahau — still maintained incrementally per-write Plan 7 makes it deferred.
Read pattern (how an SLE is found) ReadView::read(Keylet) → SHAMap descent → 6–10 spinlocked inner-node fetches Unchanged in Xahau — RWDB still requires SHAMap descent and per-inner-node decompression Plan 6 makes it O(1).

Plan 6 is most impactful on top of RWDB. RWDB removes disk latency but still pays per-inner-node mutex + decompression per read. Plan 6 skips the descent entirely. RWDB + Plan 6 + Plan 7 is the natural endpoint:

  • RWDB: where the bytes live (RAM)
  • Plan 7: when the Merkle authority is built (at close, not per-write)
  • Plan 6: how state is looked up (O(1) flat map, not SHAMap descent)

Three distinct optimizations; Xahau did one; this plan + Plan 7 do the other two.

10. Open questions

  1. External consumer authentication. If the subscribeStateUpdates API is exposed via gRPC or WebSocket, what auth model? Likely defer to existing RPC auth.
  2. Snapshot retention vs. memory. The default flat_state_retained_ledgers = 20 is a guess. Tune from operator feedback in Phase 5.
  3. Persistent HAMT (Option C) vs. snapshot-array (Option D). Option D ships first; Option C is a v2 if memory pressure justifies it.
  4. gRPC streaming vs. WebSocket. Streaming endpoint format for external consumers. gRPC is more efficient for high-throughput indexers; WebSocket matches existing subscribe patterns. Probably both.

11. Recommended kickoff sequence (Plan 6)

  1. Phase 1 (1 month) — biggest win, smallest risk. Ships independently. Lifts every read in the system.
  2. Phase 3 (overlap Phase 1 by 2 weeks) — design the public API alongside the internal change so the "other project" can integrate against the API before Phase 5.
  3. Phase 2 (1 month) — memory-resident historical ledgers. Validates the memory budget assumption.
  4. Phase 5 (1 month) — rollout.

Total: ~3 months for Phases 1+2+3+5 (Phase 4 deferred unless parallel apply is concurrent).

Smallest meaningful slice if the team can only afford 1 month: Phase 1 alone — every internal read becomes O(1); the external API is deferred. Even this slice delivers a 5–10× speedup on the apply-path read load and ships with zero protocol risk.


Part II — Plan 7: Deferred SHAMap construction (revised)

Goal: during apply, change SHAMap structure freely but skip per-mutation hash recomputation. Batch all the hash work into a single bottom-up walk at ledger close, parallelized by top-level subtree. Output is byte-identical to the current incremental path by construction — same tree, same children at each node, same hashes — just computed once at the end instead of cascading on every write.

This part supersedes an earlier draft. The original was written against a model of SHAMap that had not been verified against source (leaves at depth 64, fully expanded tree). Reading the actual implementation showed that model wrong, which invalidated the algorithm the original doc described. This revision is grounded in verified behavior from the actual source.

Status (verified + built this session)

Two corrections to the "real optimization plan" below, found by reading the source and measuring:

  1. Phase 1's bulk-COW premise is false. SHAMap's cowid copy-on-write already deduplicates clone allocations across a whole ledger round — each inner node is cloned at most once regardless of mutation count (unshareNode only clones when cowid is stale; cowid is bumped only at snapShot, and a round's changes all funnel into one stateMap_). So bulkApply saves only redundant traversal, not allocations. Measured: hashing, not traversal, dominates the per-close cost.

  2. Phase 2 is the real lever and is now BUILT. SHAMap::updateHashesParallel recomputes dirty hashes fanned out by top-level subtree, byte-identical to serial getHash(), TDD-verified, ~6–8× on a 16-core box. Phase 1 (bulkApply) is deprioritized to a small traversal-only optimization. Phase 3 (wire into close behind a flag + differential replay) remains.

What was verified by reading the code

Sources read: src/libxrpl/shamap/SHAMap.cpp, src/libxrpl/shamap/SHAMapInnerNode.cpp.

Insert (SHAMap::addGiveItem, line 768):

  1. walkTowardsKey(key, &stack) descends from root. At each inner node it picks the branch by the next nibble of the key. It stops at the first empty branch or the first leaf (line 147-148).
  2. If stopped on an empty branch: the new leaf is placed at that branch, at whatever depth walkTowardsKey happened to stop. Leaves live at the shallowest depth where their key is unambiguous, not at depth 64.
  3. If stopped on a leaf (key collision in the prefix): allocate new inner nodes and push DOWN through more nibbles until the two keys' nibbles diverge. Place both leaves as children of the deepest new inner node, on their respective diverging branches.

Delete (SHAMap::delItem, line 683):

  1. Walk to the leaf and remove it.
  2. Walk back up the stack. If an inner node ends up with exactly one child after the delete, pull the remaining child up to replace it. Path compression in reverse.

Inner-node hash (SHAMapInnerNode::updateHash, line 191-204):

hash = sha512_half(InnerNodePrefix || child[0].hash || child[1].hash || ... || child[15].hash)

Each child contributes:

  • Empty branch → 32 zero bytes
  • Leaf child → the leaf's hash (computed from the SLE bytes; see SHAMapTxLeafNode::updateHash)
  • Inner-node child → that inner node's own hash

The hash computation does not encode depth. What matters is the actual shape and hashes of the 16 children at that node. So at the same conceptual (depth, prefix) position, two trees with the same children produce the same hash — but a tree with a leaf at branch 5 of root has a fundamentally different root hash than a tree with an inner node at branch 5 of root (even if that inner node eventually leads to the same leaf), because the byte at root's branch-5 slot is different (leaf hash vs. inner hash).

Why this killed the algorithm in the original draft

The original draft described an algorithm that:

  1. Planned every ancestor of every modified leaf from depth 1 to 63.
  2. Used a position-keyed callback uint256(int depth, uint256 const& prefix) to look up child hashes from the parent SHAMap.

This algorithm models a fully expanded radix tree — every key traverses every depth from 1 to 64 before reaching its leaf. Real SHAMap does not do this. The issue is that the algorithm assumes everything between root and leaf is an inner node and computes hashes for inner nodes that don't exist in the real tree, then feeds those phantom inner-node hashes up to the parent.

Concrete example, verified by the test Plan7DoesNotMatchPathCompressedShaMap_DocumentedLimitation:

  • A Ledger with five SLEs, keys differing in first nibble.
  • Real SHAMap root: an inner node with five leaf children and 11 empty branches.
  • Original algorithm's "root": an inner node with five inner-node children (each representing the start of a 63-deep chain) and 11 empty branches.
  • Root hashes differ. The test asserts inequality and passes.

The original algorithm's claim of byte-identical output was wrong. The library code (planDeferredRebuild, executeRebuildPlan, deferredRebuildRoot, parallel variants) implements that wrong model correctly — its tests pass because they're internally consistent — but it does not match SHAMap.

The correct design

Stop trying to build a free-standing algorithm that mirrors SHAMap behavior from outside. Instead, modify SHAMap to defer the work it already knows how to do.

The optimization claim the original doc made — defer hash recomputation, batch it at close, parallelize by subtree — is still valid. But it lives inside SHAMap, not above it.

What SHAMap does today

Every addItem / delItem calls dirtyUp(stack, target, terminal) at the end (SHAMap.cpp:100). dirtyUp walks the stack from leaf to root, calling setChild on each ancestor inner node, which zeros its cached hash. Then when getHash() is finally called at close, it walks down and recomputes hashes lazily.

// SHAMap.cpp:100-130
void SHAMap::dirtyUp(stack, target, child) {
    while (!stack.empty()) {
        auto node = stack.top().first;  // parent inner node
        SHAMapNodeID const nodeID = stack.top().second;
        stack.pop();
        int const branch = selectBranch(nodeID, target);
        node = unshareNode(std::move(node), nodeID);  // COW
        node->setChild(branch, std::move(child));     // zeros parent's hash_
        child = std::move(node);
    }
}

So per mutation, the actual cost is:

  • One COW (unshareNode) per ancestor — allocates a new node, copies child pointers
  • One setChild per ancestor — bit-twiddling + hash invalidation
  • No SHA-512 per mutation. Hash computation is deferred to getHash().

That's a much smaller per-write cost than the original cost model claimed. The 290 ms/close number in the original doc was wrong — it counted SHA-512 work, but that work was already being deferred. The actual per-write cost is COW + pointer chasing.

What's actually optimizable

Two costs remain on the per-write hot path:

  1. COW clones on the path from leaf to root. These allocate new SHAMapInnerNode objects.
  2. Repeated traversal when consecutive mutations target keys with shared prefixes (each walkTowardsKey redoes work the previous one did).

What can be batched:

  • Bulk COW: if N mutations all share an ancestor at depth D, that ancestor only needs to be COW'd once. (NOTE: measurement showed cowid already does this — see Phase 0 below.)
  • Bulk traversal: a batch of mutations sorted by key can be applied with a single descent that handles all of them at each shared ancestor.

What the SHA-512 work actually looks like at close (when getHash() is invoked):

  • SHAMapInnerNode::updateHash walks 16 children, hashes 516 bytes total. About 3 µs per inner node.
  • Only nodes with zeroed hashes need recomputation (everything dirty since the last getHash()).
  • The total at close depends on how many unique ancestor nodes were dirtied: typically much less than the total inner node count, because mutations cluster.

The real optimization plan

Stage 1: bulk traversal + bulk COW. Instead of calling addItem once per mutation: sort modifications by key, walk the SHAMap once descending into each subtree in order, apply all modifications targeting keys in that subtree before backtracking, COW each ancestor once. Same SHAMap semantics, more efficient batching.

Stage 2: parallel hash recomputation at close. When getHash() is called after a batch of modifications, the set of dirtied inner nodes is known (their cached hash was zeroed by setChild). Walking this set bottom-up can be parallelized by top-level subtree — the root has 16 children; if their subtrees were independently modified (different first nibbles), their hash recomputation can run on 16 threads. Implementation: updateHashesParallel(), fanning out by top-level child.

Stage 3: at close, single getHash() call. The current OpenLedger::accept() path eventually calls getHash() once to compute the ledger header's stateHash. With Stages 1 + 2, that call drives the parallel bulk-rebuild. No further plumbing needed.

What this is and isn't

  • Same SHAMap structure. No changes to insertion semantics, path compression, or delete-time collapse.
  • Same hash output by construction. Computing the same children in the same order produces the same bytes.
  • No amendment needed. Pure internal optimization. The protocol can't see the difference.
  • Composes with Plan 6 but doesn't depend on it. Plan 6 makes reads O(1); Plan 7 makes writes/close faster. Independent levers.

Plan 7 — Phase 0: Quantify before building

Decision made (this session): before building either Phase-1 (bulkApply) or Phase-2 (updateHashesParallel), measure where per-close SHAMap cost actually goes. Driven by a verified finding that invalidates Phase-1's stated premise.

The finding that triggered this

The revised plan justifies Phase-1 ("bulk apply") on bulk COW dedup. Reading the source, that dedup already happens via SHAMap's copy-on-write:

  • unshareNode clones only when node->cowid() != cowid_ (SHAMap.cpp:420-433).
  • cowid_ is constant across a whole ledger round — bumped only at snapShot (SHAMap.cpp:79).
  • All of a round's changes funnel into one stateMap_ via RawStateTable::apply → rawInsert/rawErase/rawReplace (RawStateTable.cpp:170-186).

⇒ Each inner node is cloned at most once per round regardless of how many mutations pass through it. Bulk apply yields zero allocation savings; it can only save redundant traversal (re-descending shared prefixes) + per-mutation stack churn.

Phase-2 (parallel hash recompute at close) is independent and attacks the serial bottom-up walkSubTree/updateHashDeep at getHash() (SHAMap.cpp:842-852,1079).

What we measured

A gated micro-benchmark (SHAMAP_BENCH=1) that splits per-close cost into three buckets on a realistic state SHAMap (N random TnAccountState entries, then M replacements):

Bucket What it is Optimized by
COW inner+leaf clone allocations on first touch already deduped (status quo)
traversal+dirty descent + setItem/setChild per mutation Phase-1 bulkApply (only the redundant re-descent part)
serial hashing unshare() bottom-up hash recompute at close Phase-2 updateHashesParallel

Isolation method:

  • tCold = time M replaces on a fresh snapShot(true) (traversal + COW + dirty).
  • tWarm = time M replaces again on an already-warm snapshot (no clone) ⇒ traversal + dirty.
  • tCOW = tCold − tWarm; traversal+dirty ≈ tWarm.
  • tHash = time unshare() (== the serial recompute getHash() performs); also yields the dirtied-node count.

Results — Run 1 (Debug build)

Median over iterations; µs for the whole batch of M replaces:

        N      M |  travrsl       COW    s.hash |  dirty   hash/trav
  ----------------------------------------------------------------------
      50000   1000 |    5435.2    1403.0    4619.3 |    2724       0.8
      50000   3000 |   16281.8    3501.6   10886.5 |    7139       0.7
     200000   1000 |    5898.3    2051.0    6270.8 |    3276       1.1
     200000   3000 |   17684.4    5508.8   15725.5 |    8757       0.9

Reading the numbers:

  • COW is real but not a Phase-1 target. It's 20–30% of cold apply and is already deduped by cowid — bulk apply cannot reduce it. Confirms the finding.
  • Serial hashing is already ~0.7–1.1× of all traversal+dirty work, and Phase-1 can only remove the redundant re-descent slice of the traversal column. Phase-1's realistic ceiling is a fraction of travrsl, while Phase-2 can parallelize most of s.hash (≈ 1−1/W). At W=8 on the 200k/3000 row: Phase-2 saves ~13.7 ms vs Phase-1's optimistic ~3–6 ms.

Debug→Release bias (rigorous): SHA-512 is OpenSSL (digest.cpp:37-54) — an always-optimized external binary. So the hash bucket has a fast fixed core while the traversal bucket is entirely our code compiled -O0. Release speeds traversal more than hashing ⇒ hash/trav rises ⇒ Phase-2's case only strengthens. These Debug numbers are a conservative lower bound for the parallel-hashing argument.

Call: Phase-2 (updateHashesParallel) is the lever; build it first. Robust across the measured (Debug) regime and the analyzed Release direction. Phase-1 bulkApply is a small traversal-only optimization — defer it.

Phase 2 — BUILT (this session)

SHAMap::updateHashesParallel(int workers) added (SHAMap.cpp, SHAMap.h): fans the bottom-up hash recompute out by top-level subtree, root computed serially after. Byte-identical to serial getHash() by construction (a node hash is a pure function of its children's hashes; dirty nodes are uniquely owned per-cowid so disjoint subtrees never race).

  • Differential test, byte-identical vs serial getHash() over replace/insert/erase/mixed/empty/clean workloads × workers {1,2,4,8,16} (UpdateHashesParallel.cpp) — 5/5 pass.
  • Red step verified: sabotaging updateHashDeep makes the test fail; reverting restores green.
  • Full libxrpl suite (8 targets incl. ledger) green — no regression.

Measured speedup (Debug, 16-core, 8 workers):

        N      M | travrsl      COW    s.hash   p.hash |  dirty  speedup
      50000   1000 |   5465.8   1804.0    4622.9     780.8 |   2724      5.9x
      50000   3000 |  17050.7   3999.0   11494.1    1864.3 |   7139      6.2x
     200000   1000 |   6127.9   2337.8    6396.5    1069.3 |   3276      6.0x
     200000   3000 |  18346.9  10263.3   15967.3    1986.0 |   8757      8.0x

Serial close-time hash recompute (the dominant per-close bucket) drops ~6–8× (15.97 ms → 1.99 ms on the 200k/3000 row). Release would widen this further.

Phasing (revised)

Phase 1 — Bulk apply inside SHAMap (4-6 weeks) — DEPRIORITIZED

Traversal-only optimization (COW dedup already exists). Defer behind Phase 2.

  • Add SHAMap::bulkApply(modifications) where modifications is a sorted sequence of (key, action) ∈ {insert(sle), replace(sle), erase}.
  • Implement as a single descent processing all modifications at each ancestor before backtracking.
  • Differential test: per-mutation path vs. bulkApply, assert equal getHash() for mutation counts 1 → 10,000 on randomized seeds.
  • Benchmark vs. per-mutation API on 100/1k/10k workloads.

Phase 2 — Parallel hash recomputation (3-4 weeks) — BUILT

See "Phase 2 — BUILT" above. Done except the close-path wiring (Phase 3).

Phase 3 — Wire into close (2-3 weeks) — REMAINING

  • Identify the right call site: replace the per-tx Ledger::raw{Insert,Replace,Erase} SHAMap calls with a batched accumulator that flushes at end-of-round, then call updateHashesParallel where getHash() currently drives the serial settle.
  • Feature flag deferred_hash_rebuild (config-level, not amendment-gated). Off by default.
  • Devnet for 4 weeks under the flag, with continuous differential assertion (updateHashesParallel output getHash() vs. serial path output getHash()).
  • (optional) RelWithDebInfo confirmation run for hard numbers.

Phase 4 — Roll forward (2-4 weeks)

  • If devnet metrics show the expected speedup and no divergence, flip the flag default on in a point release.
  • Monitor mainnet validators for a release cycle.
  • Retire the per-mutation path eventually (separate amendment), once confidence is established.

Total: 3-4 months engineering. No amendment governance required for Phases 1-3.

Cost model (revised, honest)

The original doc's per-close numbers were wrong because they counted SHA-512 work that's already deferred by SHAMap's current design.

  • Phase 1 (bulk apply): traversal-only; realistic ceiling is a fraction of the traversal column. Single-digit ms per close at typical workloads.
  • Phase 2 (parallel hash): the dominant per-close bucket; ~6–8× on the serial hash recompute (e.g. 15.97 ms → 1.99 ms on the 200k/3000 row), widening under Release.

Combined per-close lift: ~15–20 ms on a 4000-tx ledger. Not the 290 ms → 7 ms claim from the original doc — that cost model was wrong. Still worth doing: multiplied by mainnet's ledger cadence and the broader throughput context, small per-close savings compound, and Phase 2 is a prerequisite for parallel apply (concurrent SHAMap mutations make serial hashing a bottleneck).

What's in the repo from the previous (incorrect) Plan 7 work

The library code below remains in the tree because the kernel ideas (planning, parallel-by-subtree, LCP-based dedup) are useful patterns even though the algorithm they're applied to is wrong for SHAMap. They're labelled clearly in the source as a depth-64-leaves kernel, not a SHAMap-compatible implementation.

File Status
include/xrpl/ledger/DeferredRebuild.h Working kernel for a depth-64-leaves model. Not used by anything in production.
src/libxrpl/ledger/DeferredRebuild.cpp Implementation of the above.
src/tests/libxrpl/ledger/DeferredRebuild.cpp 28 tests + 5 benchmarks. Pass for the wrong algorithm; useful as a reference for the testing patterns, not as a SHAMap correctness gate.
FlatStateMapIntegration.cpp::Plan7DoesNotMatchPathCompressedShaMap_DocumentedLimitation The test that documents the gap (EXPECT_NE between the kernel's output and real SHAMap's root for a path-compressed state).

Recommendation: keep these files as a worked example of the pattern, with a clear top-of-file comment that says "this kernel does not match SHAMap and is not used in production." For SHAMap itself, the right code lives inside src/libxrpl/shamap/.

Open questions (Plan 7)

  1. Is bulk apply (Phase 1) worth the refactor given the modest traversal-only savings? Measure actual production close times under load before committing.
  2. Does parallel apply change the calculus? If apply runs on many cores, SHAMap mutations are concurrent and contention on COW + dirtyUp becomes a bottleneck — Phase 2 may be a prerequisite for parallel apply's lift to land, not just a standalone optimization.
  3. Is there a win from making dirtyUp lazier? Recording "mutation happened at key K" and processing implications only at getHash() time changes invariants about what a "live" SHAMap looks like; needs careful thought.

How the two plans fit together

Plan 6 Plan 7
Attacks Read path (SHAMap descent per lookup) Write/close path (hash recompute)
Mechanism Flat keylet→SLE map, O(1) reads Defer + parallelize hash recompute at close
SHAMap role Unchanged (authoritative + proofs) Unchanged structure, same hashes by construction
Amendment None (auxiliary cache) None (internal optimization)
Status Designed, not yet built Phase 2 built + benchmarked; Phase 3 (close wiring) remaining
Dependency Independent Independent

Together with Xahau's RWDB (RAM-resident node bytes) they form three orthogonal levers: where bytes live (RWDB), how state is looked up (Plan 6), when Merkle authority is built (Plan 7).

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