diff --git a/include/xrpl/ledger/DeferredRebuild.h b/include/xrpl/ledger/DeferredRebuild.h new file mode 100644 index 0000000000..7e3d4f23ac --- /dev/null +++ b/include/xrpl/ledger/DeferredRebuild.h @@ -0,0 +1,229 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +namespace xrpl { + +/** An inner-node position in a SHAMap that needs hash recomputation. + + Plan 7's deferred-rebuild algorithm walks bottom-up, recomputing + each affected inner node's hash from its children. `AffectedNode` + identifies one such node by: + * depth: 0 = root, 1..63 = inner nodes, 64 = leaf (not included + in the plan output — only inner nodes are rebuilt) + * prefix: the leaf-key's first `depth*4` bits, with the remainder + zeroed. Two nodes at the same depth with the same + prefix are the same node. +*/ +struct AffectedNode +{ + int depth; + uint256 prefix; + + [[nodiscard]] bool + operator==(AffectedNode const& other) const noexcept + { + return depth == other.depth && prefix == other.prefix; + } +}; + +/** Plan which inner nodes need rebuild for a given set of leaf changes. + + Returns the union of ancestor paths of all `modifiedKeys`, sorted + by depth descending so a bottom-up rebuild can iterate the result + and find each level's nodes before the level above. + + The returned plan contains only INNER nodes (depths 0..63). The + leaves themselves are at depth 64 and are not in the plan — they + are the modifications, not nodes to be rebuilt. + + Complexity: O(K * 64) where K is `modifiedKeys.size()`. For real + workloads (~thousands of modifications), this is microseconds. +*/ +[[nodiscard]] std::vector +planDeferredRebuild(std::vector const& modifiedKeys); + +/** Compute the hash of a SHAMap inner node from its 16 children. + + Byte-identical to `SHAMapInnerNode::updateHash()`: + + sha512_half( + HashPrefix::InnerNode (4 bytes, big-endian) || + child[0] (32 bytes) || + child[1] (32 bytes) || + ... + child[15] (32 bytes)) + + This is the elementary operation of plan-7's bottom-up rebuild: + given the (already-computed) 16 child hashes of an inner node, + produce that node's hash. Empty branches are passed as zero + uint256 — the same convention SHAMap uses. + + Pure function; no SHAMap state, no allocation beyond a stack buffer. +*/ +[[nodiscard]] uint256 +computeInnerNodeHash(std::array const& childHashes); + +/** Hash combiner for AffectedNode keys in unordered_map. */ +struct AffectedNodeHash +{ + [[nodiscard]] std::size_t + operator()(AffectedNode const& n) const noexcept + { + // Mix depth into the high bits of a hash of prefix. + std::size_t h = 0; + for (auto b : n.prefix) + h = h * 31 + b; + return h ^ (static_cast(n.depth) << 56); + } +}; + +/** Map of recomputed inner-node hashes keyed by (depth, prefix). */ +using RebuildResult = + std::unordered_map; + +/** Walk a depth-descending plan and compute each affected node's new hash. + + For each AffectedNode in the plan (deepest first), collect its 16 + child hashes: + * If a child position is itself in the plan (and already + computed, since we walk deepest-first), use the computed hash. + * Otherwise, fall back to `getOriginalChildHash` — the callback + is expected to walk the parent SHAMap to find the hash at that + position. + + Then `computeInnerNodeHash` over the 16 children yields the + affected node's new hash. The result map contains every entry in + the plan keyed by its (depth, prefix). + + @param plan + Depth-descending plan from `planDeferredRebuild`. + @param getOriginalChildHash + Callable `uint256(int depth, uint256 const& prefix)` returning + the hash at the given position in the parent SHAMap. May + return uint256{} for absent positions. + + @note Pure function; safe to call concurrently with disjoint plans. +*/ +template +[[nodiscard]] RebuildResult +executeRebuildPlan( + std::vector const& plan, + GetChildHashFn getOriginalChildHash); + +namespace detail { + +// Forwarder for template instantiation; declared here, defined in .cpp. +[[nodiscard]] RebuildResult +executeRebuildPlanImpl( + std::vector const& plan, + std::function getOriginalChildHash); + +} // namespace detail + +template +[[nodiscard]] RebuildResult +executeRebuildPlan( + std::vector const& plan, + GetChildHashFn getOriginalChildHash) +{ + return detail::executeRebuildPlanImpl( + plan, + std::function( + std::move(getOriginalChildHash))); +} + +/** End-to-end deferred rebuild: produce the new SHAMap root hash. + + Combines `planDeferredRebuild` + `executeRebuildPlan` into one call. + This is the consumer-facing API — the integration site only needs + to supply the set of modified keys and a callback that reads the + parent SHAMap. No AffectedNode plumbing is exposed. + + @param modifiedKeys + Keys whose leaves have been added/replaced/deleted in this + ledger close. Empty → no rebuild; returns the existing root + via the callback at (depth=0, prefix=zero). + @param getOriginalChildHash + Callable `uint256(int depth, uint256 const& prefix)` returning + the hash at the given position in the parent SHAMap. May return + uint256{} for absent positions. + @return The new SHAMap root hash. +*/ +template +[[nodiscard]] uint256 +deferredRebuildRoot( + std::vector const& modifiedKeys, + GetChildHashFn getOriginalChildHash) +{ + if (modifiedKeys.empty()) + return getOriginalChildHash(0, uint256{}); + + auto const plan = planDeferredRebuild(modifiedKeys); + auto const result = executeRebuildPlan(plan, std::move(getOriginalChildHash)); + AffectedNode const rootKey{0, uint256{}}; + auto const it = result.find(rootKey); + if (it == result.end()) + return uint256{}; + return it->second; +} + +/** Partition modified keys by their first nibble (0..15). + + At depth 1 the root has 16 child subtrees, one per first-nibble + value. Keys in different subtrees rebuild independently, so this + partition is the basis for plan-7 P7.3's parallel-by-subtree + rebuild. + + Returns 16 buckets, one per first-nibble value, each containing + only the keys whose first nibble matches the bucket index. +*/ +[[nodiscard]] std::array, 16> +partitionByFirstNibble(std::vector const& modifiedKeys); + +namespace detail { + +[[nodiscard]] uint256 +deferredRebuildRootParallelImpl( + std::vector const& modifiedKeys, + std::function getOriginalChildHash); + +} // namespace detail + +/** Parallel deferred rebuild: partition by first nibble, rebuild each + of the 16 subtrees in parallel, combine into the root. + + Equivalent to `deferredRebuildRoot` in output; differs only in + execution strategy. Useful when the parent SHAMap is large enough + that the rebuild cost matters per-close. For small workloads + (handful of modifications), the serial path is faster — threading + overhead exceeds the work saved. + + @param modifiedKeys + Keys whose leaves have been added/replaced/deleted. + @param getOriginalChildHash + Thread-safe callable; will be invoked concurrently from + multiple subtree workers. + @return The new SHAMap root hash, byte-identical to + `deferredRebuildRoot` over the same inputs. +*/ +template +[[nodiscard]] uint256 +deferredRebuildRootParallel( + std::vector const& modifiedKeys, + GetChildHashFn getOriginalChildHash) +{ + return detail::deferredRebuildRootParallelImpl( + modifiedKeys, + std::function( + std::move(getOriginalChildHash))); +} + +} // namespace xrpl diff --git a/include/xrpl/ledger/FlatStateMap.h b/include/xrpl/ledger/FlatStateMap.h new file mode 100644 index 0000000000..ec3acfdf6f --- /dev/null +++ b/include/xrpl/ledger/FlatStateMap.h @@ -0,0 +1,347 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +/** Flat keylet-indexed materialization of XRPL state. + + The SHAMap is XRPL's authoritative state structure — it produces + the state root that consensus agrees on. But on its own, it forces + every state read to walk the trie: ~6–10 spinlocked inner-node + fetches per `read(Keylet)`. + + `FlatStateMap` materializes the same keylet → SLE mapping into a + flat hash table. Once populated, lookups are a single + `unordered_map::find()` — nanoseconds, not microseconds. + + This is the 2-writes-for-1-read pattern (Plan 6): + * On apply, the apply path dual-writes to SHAMap and FlatStateMap. + * On read, only FlatStateMap is consulted. + + The flat map is purely auxiliary. The SHAMap remains authoritative, + can always be rebuilt from the underlying NodeStore, and is what the + network's state-root commitment is computed from. If FlatStateMap is + wrong, the differential invariant check at ledger close (Plan 6 P6.5) + catches it; there is **no runtime fallback to SHAMap descent on + miss** — a miss is a bug. + + Thread-safe via a shared_mutex. Concurrent reads do not block each + other; writes are exclusive. Future phases may replace this with + a lock-free atomic-pointer-swap design. +*/ +class FlatStateMap +{ +public: + using key_type = uint256; + using value_type = std::shared_ptr; + + FlatStateMap() = default; + ~FlatStateMap() = default; + + // Non-copyable, non-movable. The map owns a shared_mutex (not movable) + // and a potentially large hash table; callers that need ownership + // transfer should wrap in std::unique_ptr. + FlatStateMap(FlatStateMap const&) = delete; + FlatStateMap& + operator=(FlatStateMap const&) = delete; + FlatStateMap(FlatStateMap&&) = delete; + FlatStateMap& + operator=(FlatStateMap&&) = delete; + + /** Look up an SLE by its SHAMap key. + + Returns nullptr if the key is not in the map. In Plan 6's pure + 2w/1r model, callers reading state that *should* exist treat + nullptr as a precondition violation — there is no fallback path + that would recover from a missed entry. + */ + [[nodiscard]] value_type + read(key_type const& key) const; + + /** Test whether an SLE is present. O(1). */ + [[nodiscard]] bool + exists(key_type const& key) const; + + /** Insert a new SLE. Replaces any prior entry under the same key. + + Used by both: + * the apply path's `view.insert()` (new ledger object), and + * the apply path's `view.update()` (mutating an existing SLE + produces a new shared_ptr value). + */ + void + insert(key_type const& key, value_type sle); + + /** Remove an SLE. No-op if the key is absent. */ + void + erase(key_type const& key); + + /** Number of SLEs currently materialized. */ + [[nodiscard]] std::size_t + size() const; + + /** Test whether the map is empty. O(1). */ + [[nodiscard]] bool + empty() const; + + /** Remove every entry. Used by tests and by snapshot reset. */ + void + clear(); + + /** Deep-copy snapshot of the current state. + + The snapshot is a frozen FlatStateMap (returned by value-like + unique_ptr) that shares the underlying SLE objects via + shared_ptr but has its own hash table. Subsequent writes to the + source FlatStateMap do not affect the snapshot. + + Snapshot cost is O(N) in entry count — ~one pointer copy per + entry plus bucket allocation. For current mainnet (~10M SLEs) + this is ~100–200 ms; expensive enough that snapshots should be + per-ledger-close, not per-transaction. + + A future phase will replace this with a persistent / HAMT + structure that gives O(log N) snapshot and structural sharing + across versions. + */ + [[nodiscard]] std::unique_ptr + snapshot() const; + + /** Visit every (key, SLE) pair under a single shared lock. + + @param visitor Called as `void(key_type const&, value_type const&)`. + + The lock is held for the duration of the iteration; visitors + must not call back into the same FlatStateMap (deadlock / + recursive shared_lock UB). Visitors that want to mutate state + should collect keys first and apply mutations after iteration + returns. + */ + template + void + forEach(F&& visitor) const + { + std::shared_lock lock(mutex_); + for (auto const& [key, sle] : map_) + visitor(key, sle); + } + +private: + using HashFn = HardenedHash<>; + using MapType = std::unordered_map; + + mutable std::shared_mutex mutex_; + MapType map_; +}; + +// Forward declarations to avoid pulling heavy headers into this file. +class ReadView; +class Ledger; + +/** Populate a FlatStateMap from every SLE in a ReadView. + + Used at node startup to build the flat materialization from a + SHAMap-backed authoritative ledger. Cost is O(N) iterations of + `view.sles`, each of which descends the SHAMap, so this is + expected to take seconds-to-minutes for a current mainnet-sized + ledger (~10M SLEs). Run once on startup; subsequent ledgers are + maintained incrementally via dual-write at apply time (P6.3). + + @pre `target` is empty. (Not enforced — replacing existing entries + is well-defined, but mixing populated state with an externally- + provided ReadView is a bug-shaped pattern; assert in DEBUG.) +*/ +void +populateFromReadView(FlatStateMap& target, ReadView const& source); + +/** Populate a FlatStateMap from any forward range of `shared_ptr`. + + Lower-level building block underlying `populateFromReadView`. Useful + in tests (the range can be a `std::vector>`) + and in non-ReadView contexts (e.g., reloading a persisted flat-map + sidecar at startup). + + The range element type must be `shared_ptr` (or + implicitly convertible). Each element's `.key()` becomes the + FlatStateMap key. +*/ +template +void +populateFromRange(FlatStateMap& target, Range const& sles) +{ + for (auto const& sle : sles) + target.insert(sle->key(), sle); +} + +// --------------------------------------------------------------------------- +// Mirror helpers (P6.3). +// +// The xrpld `RawView` interface defines three pure-virtual methods that +// every state mutation flows through: `rawInsert`, `rawReplace`, and +// `rawErase`. In plan-6's 2-writes-for-1-read pattern, every such call +// must also update the flat map. These helpers perform the flat-map side +// of that dual-write — they exist as standalone functions (rather than +// methods on FlatStateMap) so the Ledger integration is a one-line +// addition at each `raw*` override site, with no FlatStateMap class +// surface added for purely Ledger-specific semantics. +// +// Permissive semantics: `mirrorRawReplace` on an absent key inserts; +// `mirrorRawErase` on an absent key is a no-op. The SHAMap side enforces +// the precondition (replace requires existence); the flat mirror ensures +// the post-state matches whatever the SHAMap committed. If the caller +// gets it wrong, the differential invariant check at close (P6.5) is the +// stop-the-line gate. +// --------------------------------------------------------------------------- + +void +mirrorRawInsert(FlatStateMap& map, std::shared_ptr sle); + +void +mirrorRawReplace(FlatStateMap& map, std::shared_ptr sle); + +void +mirrorRawErase(FlatStateMap& map, std::shared_ptr const& sle); + +void +mirrorRawErase(FlatStateMap& map, uint256 const& key); + +// --------------------------------------------------------------------------- +// Keylet-aware read (P6.4). +// +// This is the read-side counterpart to `mirrorRaw*` — the testable unit +// underlying `Ledger::read(Keylet)`'s flat-map path. It looks up the +// SLE by `k.key` and verifies the SLE matches the keylet's expected +// type via `Keylet::check`. On either a miss or a type mismatch, it +// returns nullptr — matching the contract of `Ledger::read`. +// +// Plan 6 v2 semantics: this function does not consult a SHAMap or any +// other source on miss. When a FlatStateMap is the read source of +// truth, a miss IS the answer. The differential invariant check at +// close (P6.5) is what makes that safe. +// --------------------------------------------------------------------------- + +std::shared_ptr +readFromFlatStateMap(FlatStateMap const& map, Keylet const& k); + +// --------------------------------------------------------------------------- +// Differential invariant (P6.5). +// +// At every ledger close, the flat map's key-set must match the +// SHAMap's key-set. `diffFlatStateKeys` produces both sides of the +// disagreement; `flatStateMapMatches` is the boolean predicate the +// hot-path integration calls (and fails the close if it returns false). +// +// Content drift (right keys, wrong SLE bodies) is a separate, stronger +// invariant. It's prevented by construction: the mirror helpers write +// exactly the SLE the caller passed to raw*. If mirror helpers and +// wiring are both correct, the membership check above is sufficient. +// --------------------------------------------------------------------------- + +struct FlatStateKeyDiff +{ + std::vector missingFromFlat; + std::vector extraInFlat; +}; + +template +[[nodiscard]] FlatStateKeyDiff +diffFlatStateKeys(FlatStateMap const& flat, SourceRange const& sourceKeys) +{ + FlatStateKeyDiff diff; + + // Pass 1: walk source keys; collect any absent from flat. Record + // which keys we've seen so pass 2 can spot phantoms. + std::unordered_set> seen; + seen.reserve(static_cast(std::distance( + std::begin(sourceKeys), std::end(sourceKeys)))); + + for (auto const& key : sourceKeys) + { + seen.insert(key); + if (!flat.exists(key)) + diff.missingFromFlat.push_back(key); + } + + // Pass 2: walk flat; anything not in `seen` is a phantom. + flat.forEach( + [&seen, &diff](uint256 const& key, auto const& /*sle*/) { + if (!seen.contains(key)) + diff.extraInFlat.push_back(key); + }); + + return diff; +} + +template +[[nodiscard]] bool +flatStateMapMatches(FlatStateMap const& flat, SourceRange const& sourceKeys) +{ + auto const diff = diffFlatStateKeys(flat, sourceKeys); + return diff.missingFromFlat.empty() && diff.extraInFlat.empty(); +} + +/** Compare a FlatStateMap against a SHAMap-like source. + + `ShaMapLike` is any range whose elements expose a `key()` accessor + returning a `uint256`. The real `SHAMap` satisfies this contract + (its iterators yield `SHAMapItem`s with `.key()`), as does the + `MockShaMapItem` used in tests. + + This is the helper the Ledger integration calls to run the P6.5 + differential invariant. It extracts keys into a transient buffer + (O(N) allocation, ~N pointers' worth of memory) and forwards to + `flatStateMapMatches`. The transient buffer is acceptable at close + cadence; the integration can later optimize by walking the SHAMap + in-place once profiling shows the allocation matters. +*/ +template +[[nodiscard]] bool +flatStateMapMatchesShaMap(FlatStateMap const& flat, ShaMapLike const& shaMap) +{ + std::vector keys; + for (auto const& item : shaMap) + keys.push_back(item.key()); + return flatStateMapMatches(flat, keys); +} + +// --------------------------------------------------------------------------- +// A-phase integration: attach a populated FlatStateMap to a Ledger. +// +// This is the public entry point a node or test uses to "turn on" the +// flat-map read path for a Ledger. The function: +// 1. Allocates a new FlatStateMap. +// 2. Eagerly populates it by walking every SLE in the Ledger. +// 3. Attaches it via `Ledger::setFlatStateMap`. +// +// After this call: +// * `Ledger::flatStateMap()` returns the populated map +// * `Ledger::read(keylet)` routes through the flat map (no SHAMap +// descent on the hot path; see P6.4 wiring) +// * `Ledger::raw{Insert,Replace,Erase}` mirror writes to the flat +// map alongside the SHAMap (see P6.3 wiring) +// * `Ledger::validateFlatStateMapMatchesShaMap()` returns true +// +// Repeat calls discard the prior map and produce a fresh one. +// +// Cost: O(N) walk over the Ledger's state SHAMap to populate the flat +// map. For mainnet-scale state, this is bounded by SHAMap traversal +// speed — typically minutes once. Run at node startup or whenever a +// Ledger first becomes "live" (the one apply writes to). +// --------------------------------------------------------------------------- + +void +attachFlatStateMapTo(Ledger& ledger); + +} // namespace xrpl diff --git a/include/xrpl/ledger/Ledger.h b/include/xrpl/ledger/Ledger.h index 351f7d80e5..ca725ba869 100644 --- a/include/xrpl/ledger/Ledger.h +++ b/include/xrpl/ledger/Ledger.h @@ -14,6 +14,7 @@ namespace xrpl { +class FlatStateMap; class ServiceRegistry; class Job; class TransactionMaster; @@ -364,6 +365,38 @@ public: std::shared_ptr peek(Keylet const& k) const; + // + // Flat-state mirror (Plan 6 P6.3). + // + // When a FlatStateMap is attached, every successful `raw*` call below + // mirrors the operation into the map via the matching `mirrorRaw*` + // helper. With no map attached (the default), the ledger behaves + // exactly as before — no allocation, no mutex, no observable change. + // The map's lifetime is managed by the caller (typically the + // Application owns the live ledger's map). + // + + void + setFlatStateMap(std::shared_ptr map); + + [[nodiscard]] std::shared_ptr + flatStateMap() const; + + /** Run the Plan 6 P6.5 differential invariant. + + Returns true iff (a) no FlatStateMap is attached (vacuously + true — there is no second source of truth to disagree), or + (b) the attached FlatStateMap's key-set matches the SHAMap's + key-set exactly. + + Intended to be called at ledger close, before the new state + root is published. A false return is a stop-the-line bug — + the integrator should crash rather than publish a state root + that disagrees with the read path. + */ + [[nodiscard]] bool + validateFlatStateMapMatchesShaMap() const; + private: class SlesIterImpl; class TxsIterImpl; @@ -400,6 +433,11 @@ private: // A SHAMap containing the state objects for this ledger. SHAMap mutable stateMap_; + // Optional flat keylet→SLE mirror. When non-null, every successful + // raw* state mutation is mirrored into this map. See FlatStateMap.h + // and the Plan 6 docs in tasks/. + std::shared_ptr mutable flatStateMap_; + // Protects fee variables std::mutex mutable mutex_; diff --git a/include/xrpl/shamap/SHAMap.h b/include/xrpl/shamap/SHAMap.h index f63fc95b27..8540b42758 100644 --- a/include/xrpl/shamap/SHAMap.h +++ b/include/xrpl/shamap/SHAMap.h @@ -178,6 +178,31 @@ public: SHAMapHash getHash() const; + /** Recompute dirty node hashes in parallel and return the root hash. + + Plan 7 Phase 2. Equivalent in output to `getHash()` on a freshly + mutated map: it recomputes the hash of every node dirtied since the + last hash settle, bottom-up, then returns the root hash. The work is + fanned out by top-level subtree — the root's up-to-16 children are + independent (a dirty node under one branch is never shared with + another), so their subtree recomputations run concurrently with no + synchronization, and only the root hash is computed serially after. + + Byte-identical to the serial path by construction: a node's hash is a + pure function of its children's hashes, so computation order is + irrelevant as long as children precede parents — which the bottom-up + walk guarantees. + + Unlike `getHash()`/`unshare()`, this does NOT convert nodes to shared + (cowid stays as-is) or flush to the nodestore; it only refreshes hash + caches. A subsequent `getHash()` therefore returns immediately. + + @param workers Maximum concurrent subtree recomputations. <= 1 runs + serially. Defaults to the hardware concurrency. + */ + SHAMapHash + updateHashesParallel(int workers = 0); + // save a copy if you have a temporary anyway bool updateGiveItem(SHAMapNodeType type, boost::intrusive_ptr item); diff --git a/src/libxrpl/ledger/DeferredRebuild.cpp b/src/libxrpl/ledger/DeferredRebuild.cpp new file mode 100644 index 0000000000..30c97553b3 --- /dev/null +++ b/src/libxrpl/ledger/DeferredRebuild.cpp @@ -0,0 +1,329 @@ +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +namespace { + +// Zero the trailing (64 - depth) nibbles of `key`, keeping the first +// `depth` nibbles. Returns the resulting prefix uint256. +// +// SHAMap stores keys big-endian. The most significant nibble of the +// key is the depth-1 branch from root. So at depth N we want to keep +// the top N nibbles = N*4 bits and zero the rest. +[[nodiscard]] uint256 +prefixAtDepth(uint256 const& key, int depth) noexcept +{ + if (depth >= 64) + return key; + if (depth <= 0) + return uint256{}; + + uint256 result = key; + auto const totalNibbles = 64; + auto const nibblesToZero = totalNibbles - depth; + + // Zero `nibblesToZero` nibbles starting from the least-significant + // end. uint256 has 32 bytes (each holds two nibbles, high then low). + // Byte index 31 holds the two lowest nibbles; byte 0 holds the two + // highest. + int nibblesZeroed = 0; + for (int byteIdx = uint256::kBytes - 1; + byteIdx >= 0 && nibblesZeroed < nibblesToZero; + --byteIdx) + { + if (nibblesZeroed + 2 <= nibblesToZero) + { + // Zero both nibbles in this byte + result.data()[byteIdx] = 0; + nibblesZeroed += 2; + } + else + { + // Zero only the low nibble in this byte + result.data()[byteIdx] &= 0xF0; + nibblesZeroed += 1; + } + } + return result; +} + +} // namespace + +namespace { + +// Longest common prefix in NIBBLES between two uint256 keys. +// Returns 0..64. 64 means the keys are identical. +[[nodiscard]] int +lcpNibbles(uint256 const& a, uint256 const& b) noexcept +{ + for (int byteIdx = 0; byteIdx < uint256::kBytes; ++byteIdx) + { + if (a.data()[byteIdx] != b.data()[byteIdx]) + { + // They agree in 2*byteIdx whole nibbles. Check whether + // the high nibble of this byte also agrees. + std::uint8_t aHigh = a.data()[byteIdx] >> 4; + std::uint8_t bHigh = b.data()[byteIdx] >> 4; + if (aHigh == bHigh) + return 2 * byteIdx + 1; + return 2 * byteIdx; + } + } + return 64; +} + +} // namespace + +std::vector +planDeferredRebuild(std::vector const& modifiedKeys) +{ + if (modifiedKeys.empty()) + return {}; + + // LCP-based dedup. For sorted keys, the inner-node ancestors are + // grouped by shared prefix; once a key has contributed its + // ancestors at depths 0..63, the NEXT sorted key only adds NEW + // ancestors at depths > LCP(prev, current). Everything at depth + // ≤ LCP is already in the plan from `prev`. + // + // This replaces O(K × 64) prefix computations with O(K log K) + // sort + ~O(K) for sequential-key workloads (where LCP ≈ 63). + std::vector sorted = modifiedKeys; + std::sort(sorted.begin(), sorted.end()); + + std::vector plan; + // Upper bound for arbitrary inputs is K * 64, but realistic + // workloads (sequential / clustered) produce ~K entries. + plan.reserve(sorted.size() * 2); + + // First key contributes ancestors at every depth. + for (int d = 0; d <= 63; ++d) + plan.push_back({d, prefixAtDepth(sorted[0], d)}); + + // Subsequent keys contribute only ancestors at depths > LCP with + // their predecessor. + for (std::size_t i = 1; i < sorted.size(); ++i) + { + // Identical adjacent keys: nothing new to contribute. + if (sorted[i] == sorted[i - 1]) + continue; + int const lcp = lcpNibbles(sorted[i - 1], sorted[i]); + for (int d = lcp + 1; d <= 63; ++d) + plan.push_back({d, prefixAtDepth(sorted[i], d)}); + } + + // Sort depth-descending so a bottom-up rebuild can iterate in + // order. Within a depth, ascending prefix for determinism. + std::sort( + plan.begin(), + plan.end(), + [](AffectedNode const& a, AffectedNode const& b) { + if (a.depth != b.depth) + return a.depth > b.depth; + return a.prefix < b.prefix; + }); + + return plan; +} + +namespace { + +// Given a parent inner node at (parentDepth, parentPrefix), compute the +// child prefix at the given branch (0..15). The child is at depth +// (parentDepth + 1); its prefix sets the nibble at position parentDepth +// to the branch value. +[[nodiscard]] uint256 +childPrefixOf(uint256 const& parentPrefix, int parentDepth, std::uint8_t branch) +{ + uint256 result = parentPrefix; + int const byteIdx = parentDepth / 2; + bool const isHighNibble = (parentDepth % 2) == 0; + if (isHighNibble) + result.data()[byteIdx] = + (result.data()[byteIdx] & 0x0F) | + static_cast((branch & 0x0F) << 4); + else + result.data()[byteIdx] = + (result.data()[byteIdx] & 0xF0) | + static_cast(branch & 0x0F); + return result; +} + +} // namespace + +namespace detail { + +RebuildResult +executeRebuildPlanImpl( + std::vector const& plan, + std::function getOriginalChildHash) +{ + RebuildResult result; + result.reserve(plan.size()); + + // Plan is depth-descending; deepest nodes processed first. Their + // hashes are visible to shallower nodes in this same walk. + for (auto const& node : plan) + { + std::array children; + for (std::uint8_t b = 0; b < 16; ++b) + { + AffectedNode const childPos{ + node.depth + 1, childPrefixOf(node.prefix, node.depth, b)}; + auto it = result.find(childPos); + if (it != result.end()) + children[b] = it->second; + else + children[b] = getOriginalChildHash(childPos.depth, childPos.prefix); + } + result.emplace(node, computeInnerNodeHash(children)); + } + + return result; +} + +} // namespace detail + +std::array, 16> +partitionByFirstNibble(std::vector const& modifiedKeys) +{ + std::array, 16> buckets; + for (auto const& key : modifiedKeys) + { + // First nibble = high nibble of byte 0 + std::uint8_t const firstNibble = (key.data()[0] >> 4) & 0x0F; + buckets[firstNibble].push_back(key); + } + return buckets; +} + +namespace detail { + +uint256 +deferredRebuildRootParallelImpl( + std::vector const& modifiedKeys, + std::function getOriginalChildHash) +{ + if (modifiedKeys.empty()) + return getOriginalChildHash(0, uint256{}); + + auto const buckets = partitionByFirstNibble(modifiedKeys); + + // For each subtree, compute the new depth-1 hash (if any keys + // changed in that subtree) in parallel. Empty subtrees fall back + // to the parent's depth-1 hash, which is read from the callback. + // + // Each future captures the relevant bucket and runs an independent + // plan-and-execute over just that subtree's keys. The result is + // the new hash of the depth-1 inner node rooting that subtree + // (which corresponds to the root's branch-b child). + std::array, 16> futures; + for (std::uint8_t b = 0; b < 16; ++b) + { + if (buckets[b].empty()) + continue; + + futures[b] = std::async( + std::launch::async, + [bucket = buckets[b], &getOriginalChildHash, b]() -> uint256 { + // Plan + execute for this subtree's keys. The depth-1 + // node is the rooting node; we want its new hash. + auto const plan = planDeferredRebuild(bucket); + auto const result = + executeRebuildPlan(plan, getOriginalChildHash); + + // The depth-1 prefix for this subtree has its first + // nibble set to b, rest zero. + uint256 prefix{}; + prefix.data()[0] = + static_cast(b << 4); + + AffectedNode const subtreeRoot{1, prefix}; + auto const it = result.find(subtreeRoot); + if (it == result.end()) + return uint256{}; + return it->second; + }); + } + + // Gather: 16 child hashes for the root. + std::array rootChildren; + for (std::uint8_t b = 0; b < 16; ++b) + { + if (buckets[b].empty()) + { + // Untouched subtree — read original depth-1 hash from + // parent SHAMap via callback. + uint256 prefix{}; + prefix.data()[0] = static_cast(b << 4); + rootChildren[b] = getOriginalChildHash(1, prefix); + } + else + { + rootChildren[b] = futures[b].get(); + } + } + + return computeInnerNodeHash(rootChildren); +} + +} // namespace detail + +uint256 +computeInnerNodeHash(std::array const& childHashes) +{ + // SHAMapInnerNode::updateHash short-circuits to a zero hash when + // every branch is empty (isBranch_ == 0). Match that convention — + // a "node with no children" hashes to zero, not to SHA-512 of a + // zero-filled buffer. + bool anyNonZero = false; + for (auto const& h : childHashes) + { + if (h.isNonZero()) + { + anyNonZero = true; + break; + } + } + if (!anyNonZero) + return uint256{}; + + // Layout: 4-byte big-endian HashPrefix::InnerNode || 16 × 32-byte + // child hashes. Total: 516 bytes. Matches SHAMapInnerNode::updateHash + // byte-for-byte; differential-tested against it. + constexpr std::size_t kBufSize = 4 + 16 * uint256::kBytes; + alignas(64) std::array buf{}; + + auto const prefix = static_cast(HashPrefix::InnerNode); + buf[0] = static_cast(prefix >> 24); + buf[1] = static_cast(prefix >> 16); + buf[2] = static_cast(prefix >> 8); + buf[3] = static_cast(prefix); + + std::uint8_t* out = buf.data() + 4; + for (auto const& h : childHashes) + { + std::memcpy(out, h.data(), uint256::kBytes); + out += uint256::kBytes; + } + + return sha512Half(Slice{buf.data(), buf.size()}); +} + +} // namespace xrpl diff --git a/src/libxrpl/ledger/FlatStateMap.cpp b/src/libxrpl/ledger/FlatStateMap.cpp new file mode 100644 index 0000000000..fdbff6bae8 --- /dev/null +++ b/src/libxrpl/ledger/FlatStateMap.cpp @@ -0,0 +1,130 @@ +#include + +#include +#include + +#include +#include +#include +#include + +namespace xrpl { + +FlatStateMap::value_type +FlatStateMap::read(key_type const& key) const +{ + std::shared_lock lock(mutex_); + auto const it = map_.find(key); + if (it == map_.end()) + return nullptr; + return it->second; +} + +bool +FlatStateMap::exists(key_type const& key) const +{ + std::shared_lock lock(mutex_); + return map_.find(key) != map_.end(); +} + +void +FlatStateMap::insert(key_type const& key, value_type sle) +{ + std::unique_lock lock(mutex_); + map_.insert_or_assign(key, std::move(sle)); +} + +void +FlatStateMap::erase(key_type const& key) +{ + std::unique_lock lock(mutex_); + map_.erase(key); +} + +std::size_t +FlatStateMap::size() const +{ + std::shared_lock lock(mutex_); + return map_.size(); +} + +bool +FlatStateMap::empty() const +{ + std::shared_lock lock(mutex_); + return map_.empty(); +} + +void +FlatStateMap::clear() +{ + std::unique_lock lock(mutex_); + map_.clear(); +} + +std::unique_ptr +FlatStateMap::snapshot() const +{ + auto out = std::make_unique(); + std::shared_lock lock(mutex_); + // Reserve to avoid rehash during the bulk copy. + out->map_.reserve(map_.size()); + for (auto const& entry : map_) + out->map_.insert(entry); + return out; +} + +void +populateFromReadView(FlatStateMap& target, ReadView const& source) +{ + populateFromRange(target, source.sles); +} + +void +attachFlatStateMapTo(Ledger& ledger) +{ + auto map = std::make_shared(); + populateFromReadView(*map, ledger); + ledger.setFlatStateMap(std::move(map)); +} + +void +mirrorRawInsert(FlatStateMap& map, std::shared_ptr sle) +{ + auto const key = sle->key(); + map.insert(key, std::move(sle)); +} + +void +mirrorRawReplace(FlatStateMap& map, std::shared_ptr sle) +{ + auto const key = sle->key(); + map.insert(key, std::move(sle)); // insert == insert_or_assign here +} + +void +mirrorRawErase( + FlatStateMap& map, + std::shared_ptr const& sle) +{ + map.erase(sle->key()); +} + +void +mirrorRawErase(FlatStateMap& map, uint256 const& key) +{ + map.erase(key); +} + +std::shared_ptr +readFromFlatStateMap(FlatStateMap const& map, Keylet const& k) +{ + auto sle = map.read(k.key); + if (!sle) + return nullptr; + if (!k.check(*sle)) + return nullptr; + return sle; +} + +} // namespace xrpl diff --git a/src/libxrpl/ledger/Ledger.cpp b/src/libxrpl/ledger/Ledger.cpp index fe7db9a158..5defdc211e 100644 --- a/src/libxrpl/ledger/Ledger.cpp +++ b/src/libxrpl/ledger/Ledger.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -276,6 +277,19 @@ Ledger::Ledger(Ledger const& prevLedger, NetClock::time_point closeTime) { header_.closeTime = prevLedger.header_.closeTime + header_.closeTimeResolution; } + + // Plan 6 lifecycle: if the parent carries a flat-state mirror, the child + // inherits an independent deep-copy snapshot of it. This mirrors the COW + // snapshot of `stateMap_` above: the child starts from the parent's final + // state and then diverges as the round's transactions mirror into it, + // while the parent's map is left untouched. When no map is attached (the + // default), this is a no-op and the ledger behaves exactly as before. + // + // The snapshot is O(N) in entry count today; a persistent/HAMT structure + // (see FlatStateMap.h) is the follow-on that makes per-ledger propagation + // cheap enough to enable in production. + if (prevLedger.flatStateMap_) + flatStateMap_ = prevLedger.flatStateMap_->snapshot(); } Ledger::Ledger(LedgerHeader const& info, Rules rules, Family& family) @@ -411,6 +425,14 @@ Ledger::read(Keylet const& k) const return nullptr; // LCOV_EXCL_STOP } + + // Plan 6 P6.4: when a FlatStateMap is attached, it is the read + // source of truth. No SHAMap fallback — drift between the two + // is caught at close by the differential invariant check (P6.5), + // not by silently re-reading from SHAMap. + if (flatStateMap_) + return readFromFlatStateMap(*flatStateMap_, k); + auto const& item = stateMap_.peekItem(k.key); if (!item) return nullptr; @@ -490,6 +512,8 @@ Ledger::rawErase(std::shared_ptr const& sle) { if (!stateMap_.delItem(sle->key())) logicError("Ledger::rawErase: key not found"); + if (flatStateMap_) + mirrorRawErase(*flatStateMap_, sle); } void @@ -497,6 +521,8 @@ Ledger::rawErase(uint256 const& key) { if (!stateMap_.delItem(key)) logicError("Ledger::rawErase: key not found"); + if (flatStateMap_) + mirrorRawErase(*flatStateMap_, key); } void @@ -509,6 +535,8 @@ Ledger::rawInsert(std::shared_ptr const& sle) { logicError("Ledger::rawInsert: key already exists"); } + if (flatStateMap_) + mirrorRawInsert(*flatStateMap_, sle); } void @@ -521,6 +549,28 @@ Ledger::rawReplace(std::shared_ptr const& sle) { logicError("Ledger::rawReplace: key not found"); } + if (flatStateMap_) + mirrorRawReplace(*flatStateMap_, sle); +} + +void +Ledger::setFlatStateMap(std::shared_ptr map) +{ + flatStateMap_ = std::move(map); +} + +std::shared_ptr +Ledger::flatStateMap() const +{ + return flatStateMap_; +} + +bool +Ledger::validateFlatStateMapMatchesShaMap() const +{ + if (!flatStateMap_) + return true; // nothing to validate against + return flatStateMapMatchesShaMap(*flatStateMap_, stateMap_); } void diff --git a/src/libxrpl/shamap/SHAMap.cpp b/src/libxrpl/shamap/SHAMap.cpp index d3a7d49da6..be5133c6da 100644 --- a/src/libxrpl/shamap/SHAMap.cpp +++ b/src/libxrpl/shamap/SHAMap.cpp @@ -26,13 +26,17 @@ #include +#include +#include #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -851,6 +855,98 @@ SHAMap::getHash() const return hash; } +namespace { + +// Recompute hashes bottom-up for the subtree rooted at `node`, descending +// only into dirty (cowid != 0) resident children — the hash side of +// walkSubTree, without flushing/sharing. `node` must itself be dirty. +// +// Thread-safety: dirty nodes are uniquely owned by the current cowid, and the +// subtrees hanging off distinct branches are disjoint, so two callers handed +// children of different branches never touch the same node. Reads of clean +// (cowid 0) children's cached hashes via updateHashDeep are read-only and safe +// to race. +void +recomputeSubtreeHashes(SHAMapTreeNode* node) +{ + if (node->isLeaf()) + { + node->updateHash(); + return; + } + + auto* inner = safeDowncast(node); + for (int branch = 0; branch < SHAMapInnerNode::kBranchFactor; ++branch) + { + if (inner->isEmptyBranch(branch)) + continue; + auto* child = inner->getChildPointer(branch); + if (child && (child->cowid() != 0)) + recomputeSubtreeHashes(child); + } + inner->updateHashDeep(); +} + +} // namespace + +SHAMapHash +SHAMap::updateHashesParallel(int workers) +{ + // Nothing dirtied since the last settle: the cached root hash is current. + // (root_ is always present, matching getHash()'s invariant.) + if (root_->cowid() == 0) + return root_->getHash(); + + if (root_->isLeaf()) + { + root_->updateHash(); + return root_->getHash(); + } + + auto* rootInner = safeDowncast(root_.get()); + + // Gather the root's dirty, resident top-level subtrees. These are + // independent and can be recomputed concurrently. + std::vector subtrees; + for (int branch = 0; branch < kBranchFactor; ++branch) + { + if (rootInner->isEmptyBranch(branch)) + continue; + auto* child = rootInner->getChildPointer(branch); + if (child && (child->cowid() != 0)) + subtrees.push_back(child); + } + + if (workers <= 0) + workers = static_cast(std::thread::hardware_concurrency()); + + if (workers <= 1 || subtrees.size() <= 1) + { + for (auto* s : subtrees) + recomputeSubtreeHashes(s); + } + else + { + int const nthreads = std::min(workers, static_cast(subtrees.size())); + std::atomic next{0}; + std::vector> tasks; + tasks.reserve(nthreads); + for (int t = 0; t < nthreads; ++t) + { + tasks.push_back(std::async(std::launch::async, [&subtrees, &next] { + for (std::size_t i = next++; i < subtrees.size(); i = next++) + recomputeSubtreeHashes(subtrees[i]); + })); + } + for (auto& task : tasks) + task.get(); + } + + // All top-level subtree hashes are now current; finish at the root. + rootInner->updateHashDeep(); + return rootInner->getHash(); +} + bool SHAMap::updateGiveItem(SHAMapNodeType type, boost::intrusive_ptr item) { diff --git a/src/tests/libxrpl/CMakeLists.txt b/src/tests/libxrpl/CMakeLists.txt index ee07698519..4b3c08cc44 100644 --- a/src/tests/libxrpl/CMakeLists.txt +++ b/src/tests/libxrpl/CMakeLists.txt @@ -35,6 +35,14 @@ xrpl_add_test(json) target_link_libraries(xrpl.test.json PRIVATE xrpl.imports.test) add_dependencies(xrpl.tests xrpl.test.json) +xrpl_add_test(ledger) +target_link_libraries(xrpl.test.ledger PRIVATE xrpl.imports.test) +add_dependencies(xrpl.tests xrpl.test.ledger) + +xrpl_add_test(shamap) +target_link_libraries(xrpl.test.shamap PRIVATE xrpl.imports.test) +add_dependencies(xrpl.tests xrpl.test.shamap) + xrpl_add_test(tx) target_link_libraries(xrpl.test.tx PRIVATE xrpl.imports.test) add_dependencies(xrpl.tests xrpl.test.tx) diff --git a/src/tests/libxrpl/ledger/DeferredRebuild.cpp b/src/tests/libxrpl/ledger/DeferredRebuild.cpp new file mode 100644 index 0000000000..be68329db4 --- /dev/null +++ b/src/tests/libxrpl/ledger/DeferredRebuild.cpp @@ -0,0 +1,927 @@ +// Tests for the Plan 7 deferred-SHAMap rebuild planning kernel. +// +// The full plan-7 algorithm (bottom-up parallel rebuild of a SHAMap +// from a parent SHAMap + delta) decomposes into two layers: +// +// 1. PLAN — given a set of modified leaf keys, compute which inner +// nodes need their hash recomputed. Pure algorithm; no SHAMap. +// 2. EXECUTE — given the plan + a parent SHAMap + the delta, produce +// the new SHAMap with byte-identical root hash. +// +// This file tests (1). Layer (2) requires SHAMap fixtures (Family, +// NodeStore) and lands in a follow-up. + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +using namespace xrpl; + +namespace { + +// Inhibit dead-code elimination in benchmark loops. +template +inline void +benchmark_use(T const& v) +{ +#if defined(__clang__) || defined(__GNUC__) + asm volatile("" : : "r,m"(v) : "memory"); +#else + (void)v; +#endif +} + +[[nodiscard]] uint256 +keyOf(std::uint64_t v) +{ + return uint256{v}; +} + +// Build a key whose first `nibblesIntoKey` nibbles match a given pattern, +// remaining nibbles set to 0. SHAMap stores keys big-endian; the most +// significant nibble of the key is the depth-1 branch from root. +[[nodiscard]] uint256 +keyWithPrefix(std::vector const& prefixNibbles) +{ + uint256 k; // zero-initialised + // Each byte of uint256 holds two nibbles, high nibble first. + for (std::size_t i = 0; i < prefixNibbles.size(); ++i) + { + auto const byteIdx = i / 2; + auto const isHighNibble = (i % 2) == 0; + if (byteIdx >= uint256::kBytes) + break; + auto const shift = isHighNibble ? 4 : 0; + k.data()[byteIdx] |= static_cast( + (prefixNibbles[i] & 0x0F) << shift); + } + return k; +} + +} // namespace + +// --------------------------------------------------------------------------- +// Empty + single-key shape +// --------------------------------------------------------------------------- + +TEST(DeferredRebuild_Plan, EmptyKeySetYieldsEmptyPlan) +{ + std::vector keys; + auto const plan = planDeferredRebuild(keys); + EXPECT_TRUE(plan.empty()); +} + +TEST(DeferredRebuild_Plan, SingleKeyTouchesEveryDepth) +{ + // A single 256-bit key has 64 nibbles, so the path from root to + // the leaf passes through 64 inner nodes (depth 1 through 64). + // Plus the root itself at depth 0 — that gives 65 affected + // ancestor positions. + // + // Actually the leaf at depth 64 is the SLE itself, not an inner + // node. The inner nodes along the path are at depths 0 (root) + // through 63. So 64 inner-node positions total. + std::vector keys{keyOf(1)}; + auto const plan = planDeferredRebuild(keys); + EXPECT_EQ(plan.size(), 64u); +} + +TEST(DeferredRebuild_Plan, PlanIsDepthDescending) +{ + // Bottom-up rebuild walks deepest nodes first. The plan must be + // ordered so the consumer can iterate and find each level before + // its parent. + std::vector keys{keyOf(1)}; + auto const plan = planDeferredRebuild(keys); + + for (std::size_t i = 1; i < plan.size(); ++i) + { + EXPECT_LE(plan[i].depth, plan[i - 1].depth) + << "Plan not depth-descending at index " << i; + } + EXPECT_EQ(plan.front().depth, 63); // deepest inner node + EXPECT_EQ(plan.back().depth, 0); // root +} + +// --------------------------------------------------------------------------- +// Multiple keys — ancestor sharing +// --------------------------------------------------------------------------- + +TEST(DeferredRebuild_Plan, DisjointKeysShareOnlyRoot) +{ + // Two keys that differ in their very first nibble share only one + // ancestor: the root (depth 0). Each contributes 63 unique + // ancestors at depths 1..63, plus the shared root. + // + // Total affected nodes: 63 + 63 + 1 = 127. + auto const keyA = keyWithPrefix({0x0}); // first nibble = 0 + auto const keyB = keyWithPrefix({0xF}); // first nibble = 15 + + auto const plan = planDeferredRebuild({keyA, keyB}); + EXPECT_EQ(plan.size(), 127u); +} + +TEST(DeferredRebuild_Plan, KeysSharingPrefixShareAncestors) +{ + // Two keys that share their first 3 nibbles share their prefixes + // at depths 0, 1, 2, AND 3 — at depth N the prefix is the first N + // nibbles, so shared-first-3-nibbles means shared at depths 0..3. + // + // They diverge at depth 4 (the prefix at depth 4 includes the 4th + // nibble, which differs). So each contributes unique ancestors at + // depths 4..63 = 60 levels. + // + // Total: 4 shared (depths 0..3) + 60*2 unique = 124. + auto const keyA = keyWithPrefix({0x1, 0x2, 0x3, 0x4}); + auto const keyB = keyWithPrefix({0x1, 0x2, 0x3, 0x5}); + + auto const plan = planDeferredRebuild({keyA, keyB}); + EXPECT_EQ(plan.size(), 124u); +} + +TEST(DeferredRebuild_Plan, IdenticalKeysCountedOnce) +{ + // Two identical keys produce the same plan as a single key — the + // delta is "this key changed", duplicated or not. + auto const k = keyOf(7); + auto const plan = planDeferredRebuild({k, k}); + EXPECT_EQ(plan.size(), 64u); +} + +// --------------------------------------------------------------------------- +// Correctness of node-identity +// --------------------------------------------------------------------------- + +TEST(DeferredRebuild_Plan, NodesAtSameDepthHaveDistinctPrefixesWhenKeysDiffer) +{ + auto const keyA = keyWithPrefix({0x0}); + auto const keyB = keyWithPrefix({0xF}); + + auto const plan = planDeferredRebuild({keyA, keyB}); + + // At depth 1, the two keys yield distinct inner-node positions — + // they branch at nibble 0 vs nibble F. + int depth1NodeCount = 0; + for (auto const& node : plan) + if (node.depth == 1) + ++depth1NodeCount; + EXPECT_EQ(depth1NodeCount, 2); +} + +TEST(DeferredRebuild_Plan, RootAlwaysPresent) +{ + // Every non-empty plan includes the root (depth 0). + std::vector keys{keyOf(1), keyOf(2), keyOf(3)}; + auto const plan = planDeferredRebuild(keys); + + auto const rootCount = std::count_if( + plan.begin(), + plan.end(), + [](AffectedNode const& n) { return n.depth == 0; }); + EXPECT_EQ(rootCount, 1); +} + +// --------------------------------------------------------------------------- +// Benchmarks. TDD with benchmarks: gate the plan-generation cost so a +// regression shows up as a failed test, not a mainnet incident. +// +// `planDeferredRebuild` runs at every ledger close to determine which +// inner nodes to recompute. At realistic mainnet workload (~3000 SLEs +// modified per ledger), this must be cheap — well under a millisecond. +// --------------------------------------------------------------------------- + +TEST(DeferredRebuild_Bench, PlanGenerationAtLedgerScale) +{ + // 3000 modified keys ≈ realistic 1500-TPS-target ledger. + constexpr std::size_t N = 3'000; + std::vector keys; + keys.reserve(N); + for (std::uint64_t i = 0; i < N; ++i) + keys.push_back(keyOf(i)); + + auto const t0 = std::chrono::high_resolution_clock::now(); + auto const plan = planDeferredRebuild(keys); + auto const elapsed = std::chrono::high_resolution_clock::now() - t0; + auto const us = + std::chrono::duration_cast(elapsed).count(); + + std::printf( + " planDeferredRebuild N=%zu modified keys : %lld µs (plan size %zu)\n", + N, + static_cast(us), + plan.size()); + + // Post-LCP-optimization: measured ~1.5 ms locally. 15 ms is ~10× + // measured — generous for slow CI but tight enough to catch real + // regressions (e.g., accidental return to O(K*64) prefix ops). + EXPECT_LT(us, 15'000) + << "Plan generation at 3000 modified keys should be under 15 ms"; +} + +TEST(DeferredRebuild_Bench, PlanGenerationAtTenKKeys) +{ + // Stress-test at 10x typical to catch O(N²) regressions early. + constexpr std::size_t N = 30'000; + std::vector keys; + keys.reserve(N); + for (std::uint64_t i = 0; i < N; ++i) + keys.push_back(keyOf(i)); + + auto const t0 = std::chrono::high_resolution_clock::now(); + auto const plan = planDeferredRebuild(keys); + auto const elapsed = std::chrono::high_resolution_clock::now() - t0; + auto const us = + std::chrono::duration_cast(elapsed).count(); + + std::printf( + " planDeferredRebuild N=%zu modified keys : %lld µs (plan size %zu)\n", + N, + static_cast(us), + plan.size()); + + // Post-LCP: measured ~16 ms locally. 150 ms threshold = ~10×. + EXPECT_LT(us, 150'000) + << "Plan generation at 30k keys should be under 150 ms"; +} + +TEST(DeferredRebuild_Plan, NoDuplicateNodes) +{ + // A given inner node must appear at most once in the plan, even if + // many leaves share it as an ancestor. + std::vector keys; + for (std::uint64_t i = 0; i < 100; ++i) + keys.push_back(keyOf(i)); + + auto const plan = planDeferredRebuild(keys); + + for (std::size_t i = 0; i < plan.size(); ++i) + for (std::size_t j = i + 1; j < plan.size(); ++j) + EXPECT_FALSE( + plan[i].depth == plan[j].depth && + plan[i].prefix == plan[j].prefix) + << "Duplicate at indices " << i << " and " << j; +} + +// --------------------------------------------------------------------------- +// Inner-node hash computation (P7.2.1). +// +// The bottom-up rebuild's elementary operation is: given the 16 child +// hashes of an inner node, compute the inner node's own hash. This is +// the pure function `computeInnerNodeHash` — the kernel of plan-7's +// "EXECUTE" layer. +// +// We test it against the production code path as oracle: +// `SHAMapInnerNode::makeFullInner` deserializes 16 hashes from a +// 512-byte slice and calls `updateHash()` to compute the node's hash. +// Our function must produce byte-identical output to that path — the +// safety property that makes plan-7 a pure internal optimization +// (no protocol change). +// --------------------------------------------------------------------------- + +namespace { + +// Pack 16 child hashes into the 512-byte buffer SHAMapInnerNode expects. +[[nodiscard]] std::vector +packChildHashes(std::array const& children) +{ + std::vector buf; + buf.reserve(16 * 32); + for (auto const& h : children) + for (auto b : h) + buf.push_back(b); + return buf; +} + +// Use SHAMapInnerNode's makeFullInner factory to produce the canonical +// hash. The returned tree node has updateHash() already invoked. +[[nodiscard]] uint256 +oracleHash(std::array const& children) +{ + auto const buf = packChildHashes(children); + auto node = SHAMapInnerNode::makeFullInner( + Slice{buf.data(), buf.size()}, SHAMapHash{}, /*hashValid=*/false); + return node->getHash().asUInt256(); +} + +} // namespace + +TEST(DeferredRebuild_InnerHash, AllZeroChildrenMatchesOracle) +{ + // An inner node with all-zero children is the same byte pattern + // an empty branch produces; computing its hash via either path + // must agree. + std::array children{}; // all zero + auto const oracle = oracleHash(children); + auto const ours = computeInnerNodeHash(children); + EXPECT_EQ(ours, oracle); +} + +TEST(DeferredRebuild_InnerHash, SingleChildMatchesOracle) +{ + std::array children{}; + children[7] = uint256{0xDEADBEEF}; + auto const oracle = oracleHash(children); + auto const ours = computeInnerNodeHash(children); + EXPECT_EQ(ours, oracle); +} + +TEST(DeferredRebuild_InnerHash, AllChildrenSetMatchesOracle) +{ + std::array children; + for (std::size_t i = 0; i < 16; ++i) + children[i] = uint256{0x100ULL + i}; + auto const oracle = oracleHash(children); + auto const ours = computeInnerNodeHash(children); + EXPECT_EQ(ours, oracle); +} + +TEST(DeferredRebuild_InnerHash, SwappingChildrenChangesHash) +{ + // Order matters — branch position is part of the hash input. + std::array a; + for (std::size_t i = 0; i < 16; ++i) + a[i] = uint256{0x200ULL + i}; + + std::array b = a; + std::swap(b[3], b[11]); + + EXPECT_NE(computeInnerNodeHash(a), computeInnerNodeHash(b)); + EXPECT_EQ(computeInnerNodeHash(a), oracleHash(a)); + EXPECT_EQ(computeInnerNodeHash(b), oracleHash(b)); +} + +TEST(DeferredRebuild_InnerHash, Deterministic) +{ + std::array children; + for (std::size_t i = 0; i < 16; ++i) + children[i] = uint256{0x300ULL + i * 0x1234}; + + auto const h1 = computeInnerNodeHash(children); + auto const h2 = computeInnerNodeHash(children); + auto const h3 = computeInnerNodeHash(children); + EXPECT_EQ(h1, h2); + EXPECT_EQ(h2, h3); +} + +TEST(DeferredRebuild_InnerHash, ChangingOneChildChangesHash) +{ + std::array base; + for (std::size_t i = 0; i < 16; ++i) + base[i] = uint256{0x400ULL + i}; + + for (std::size_t branchToBump = 0; branchToBump < 16; ++branchToBump) + { + auto modified = base; + modified[branchToBump] = uint256{0xCAFEBABEULL + branchToBump}; + EXPECT_NE(computeInnerNodeHash(base), computeInnerNodeHash(modified)) + << "Hash unchanged when modifying branch " << branchToBump; + } +} + +// --------------------------------------------------------------------------- +// Bottom-up plan execution (P7.2.2). +// +// Given a depth-descending plan and a callback that supplies "original" +// child hashes from the parent SHAMap, walk the plan, compute each +// affected node's new hash, and return them in a map. Hashes computed +// earlier in the walk (deeper nodes) are visible to later (shallower) +// nodes that have them as children. +// +// The pure-function design takes a callback rather than a SHAMap +// reference so the algorithm can be tested without SHAMap fixtures. +// Real integration will pass a callback that walks the actual SHAMap. +// --------------------------------------------------------------------------- + +namespace { + +// Given a parent inner node at (parentDepth, parentPrefix), compute the +// child prefix at the given branch (0..15). The child is at depth +// (parentDepth + 1) and its prefix has the nibble at position +// parentDepth set to the branch value. +[[nodiscard]] uint256 +childPrefixOf(uint256 const& parentPrefix, int parentDepth, std::uint8_t branch) +{ + uint256 result = parentPrefix; + int const byteIdx = parentDepth / 2; + bool const isHighNibble = (parentDepth % 2) == 0; + if (isHighNibble) + result.data()[byteIdx] = + (result.data()[byteIdx] & 0x0F) | + static_cast((branch & 0x0F) << 4); + else + result.data()[byteIdx] = + (result.data()[byteIdx] & 0xF0) | + static_cast(branch & 0x0F); + return result; +} + +} // namespace + +TEST(DeferredRebuild_ChildPrefix, BranchZeroPreservesPrefix) +{ + auto const parent = keyWithPrefix({0xA, 0xB}); + // Setting nibble 2 to 0 — that's already the case + auto const child = childPrefixOf(parent, 2, 0); + EXPECT_EQ(child, parent); +} + +TEST(DeferredRebuild_ChildPrefix, SetsCorrectNibblePosition) +{ + uint256 const empty{}; + // Parent at depth 0, branch 5 → nibble 0 of result = 5 + auto const child = childPrefixOf(empty, 0, 5); + auto const expected = keyWithPrefix({5}); + EXPECT_EQ(child, expected); +} + +TEST(DeferredRebuild_ChildPrefix, DepthOneSetsNibbleOne) +{ + auto const parent = keyWithPrefix({0xA}); + // Parent at depth 1 (first nibble set to A), branch 7 + // → child has nibbles (A, 7, 0, 0, ...) + auto const child = childPrefixOf(parent, 1, 7); + auto const expected = keyWithPrefix({0xA, 0x7}); + EXPECT_EQ(child, expected); +} + +TEST(DeferredRebuild_ChildPrefix, DepthSixtyThreeIsLastNibble) +{ + uint256 parent{}; + for (int i = 0; i < uint256::kBytes; ++i) + parent.data()[i] = 0xAB; // arbitrary fill + // Parent at depth 63 → set the last nibble (low nibble of last byte) + auto const child = childPrefixOf(parent, 63, 0xC); + uint256 expected = parent; + expected.data()[31] = (expected.data()[31] & 0xF0) | 0x0C; + EXPECT_EQ(child, expected); +} + +// --------------------------------------------------------------------------- +// Plan execution — the actual rebuild walk +// --------------------------------------------------------------------------- + +TEST(DeferredRebuild_Execute, EmptyPlanReturnsEmptyResult) +{ + std::vector plan; + auto const result = executeRebuildPlan( + plan, + [](int /*depth*/, uint256 const& /*prefix*/) { return uint256{}; }); + EXPECT_TRUE(result.empty()); +} + +TEST(DeferredRebuild_Execute, SingleRootNodePlanComputesRootHash) +{ + // Plan: just the root at depth 0. All 16 children come from the + // parent SHAMap (none are themselves affected). The rebuild + // should produce a root hash equal to computeInnerNodeHash over + // those children. + std::vector plan{{0, uint256{}}}; + + // Mock parent: child branch b has hash 0x100 + b + auto const parentLookup = [](int depth, uint256 const& prefix) { + if (depth != 1) + return uint256{}; + // Branch is the high nibble of the first byte of prefix + std::uint8_t branch = (prefix.data()[0] >> 4) & 0x0F; + return uint256{0x100ULL + branch}; + }; + + auto const result = executeRebuildPlan(plan, parentLookup); + ASSERT_EQ(result.size(), 1u); + + // Expected: compute the root hash from the 16 child hashes + std::array children; + for (std::uint8_t b = 0; b < 16; ++b) + children[b] = uint256{0x100ULL + b}; + auto const expectedRoot = computeInnerNodeHash(children); + + AffectedNode const rootKey{0, uint256{}}; + auto it = result.find(rootKey); + ASSERT_NE(it, result.end()); + EXPECT_EQ(it->second, expectedRoot); +} + +TEST(DeferredRebuild_Execute, ChildInPlanShadowsParentLookup) +{ + // Plan contains: + // - depth 1, prefix (5,0,0,...) — this child of root is affected + // - depth 0, root — the root, which uses the depth-1 result as + // its branch-5 child + // + // The plan is depth-descending so the depth-1 node is processed + // first, and its computed hash is used as branch-5 of root. + // + // For the depth-1 node, all 16 of ITS children come from parent + // lookup (depth=2). + auto const depth1Prefix = keyWithPrefix({0x5}); + std::vector plan{{1, depth1Prefix}, {0, uint256{}}}; + + // Mock parent: + // - depth 1 children (depth 2 lookup): return distinct hashes per branch + // - depth 0 children OTHER THAN branch 5 (depth 1 lookup): return + // distinct hashes per branch + int parentLookupCalls = 0; + auto const parentLookup = + [&parentLookupCalls](int depth, uint256 const& prefix) -> uint256 { + ++parentLookupCalls; + if (depth == 2) + { + // High nibble of byte 0 is the parent's prefix nibble (5), + // low nibble of byte 0 is the branch within that parent. + std::uint8_t branch = prefix.data()[0] & 0x0F; + return uint256{0xA00ULL + branch}; + } + if (depth == 1) + { + std::uint8_t branch = (prefix.data()[0] >> 4) & 0x0F; + return uint256{0xB00ULL + branch}; + } + return uint256{}; + }; + + auto const result = executeRebuildPlan(plan, parentLookup); + ASSERT_EQ(result.size(), 2u); + + // Compute expected depth-1 hash: 16 children from depth-2 lookup + std::array depth1Children; + for (std::uint8_t b = 0; b < 16; ++b) + depth1Children[b] = uint256{0xA00ULL + b}; + auto const expectedDepth1Hash = computeInnerNodeHash(depth1Children); + + AffectedNode const depth1Key{1, depth1Prefix}; + auto it1 = result.find(depth1Key); + ASSERT_NE(it1, result.end()); + EXPECT_EQ(it1->second, expectedDepth1Hash); + + // Compute expected root hash: branch 5 is the depth-1 result, all + // other branches come from parentLookup at depth 1 + std::array rootChildren; + for (std::uint8_t b = 0; b < 16; ++b) + rootChildren[b] = (b == 5) ? expectedDepth1Hash : uint256{0xB00ULL + b}; + auto const expectedRoot = computeInnerNodeHash(rootChildren); + + AffectedNode const rootKey{0, uint256{}}; + auto it0 = result.find(rootKey); + ASSERT_NE(it0, result.end()); + EXPECT_EQ(it0->second, expectedRoot); + + // Sanity: the parentLookup should NOT have been called for the + // branch-5 child of root (depth=1, prefix=depth1Prefix), because + // that child IS the affected depth-1 node. Verify by counting: + // - depth 2 lookups: 16 (one per branch of the depth-1 node) + // - depth 1 lookups: 15 (all branches of root EXCEPT branch 5) + // - total: 31 + EXPECT_EQ(parentLookupCalls, 31); +} + +TEST(DeferredRebuild_Execute, DeterministicAcrossRuns) +{ + auto const depth1Prefix = keyWithPrefix({0x3}); + std::vector plan{{1, depth1Prefix}, {0, uint256{}}}; + + auto const parentLookup = [](int depth, uint256 const& prefix) { + std::uint64_t v = 0; + for (int i = 0; i < 8; ++i) + v = (v << 8) | prefix.data()[i]; + return uint256{v + static_cast(depth) * 0xABCDEF}; + }; + + auto const r1 = executeRebuildPlan(plan, parentLookup); + auto const r2 = executeRebuildPlan(plan, parentLookup); + auto const r3 = executeRebuildPlan(plan, parentLookup); + EXPECT_EQ(r1, r2); + EXPECT_EQ(r2, r3); +} + +// --------------------------------------------------------------------------- +// Unified entry point: deferredRebuildRoot +// +// The consumer-facing API: given (modified keys, parent-state callback), +// return the new SHAMap root hash. Equivalent to plan + execute + pluck +// the depth-0 entry, but exposed as a single call so the integration +// site doesn't have to know about the AffectedNode plumbing. +// --------------------------------------------------------------------------- + +TEST(DeferredRebuild_Root, EmptyKeysReturnsExistingRootFromCallback) +{ + // No modifications → no rebuild needed → the new root equals the + // existing root, which the parent-state callback supplies at + // (depth=0, prefix=zero). + uint256 const expectedExistingRoot{0xDEADBEEF}; + auto const parentLookup = [&expectedExistingRoot]( + int depth, uint256 const& prefix) { + if (depth == 0 && prefix == uint256{}) + return expectedExistingRoot; + return uint256{}; + }; + + auto const newRoot = deferredRebuildRoot({}, parentLookup); + EXPECT_EQ(newRoot, expectedExistingRoot); +} + +TEST(DeferredRebuild_Root, SingleKeyMatchesPlanAndExecuteComposition) +{ + // The unified entry point must be byte-identical to the explicit + // plan + execute composition. This is the safety guarantee that + // consumers can switch to the unified entry point with no + // observable change. + std::vector keys{keyOf(42)}; + + auto const parentLookup = [](int depth, uint256 const& prefix) { + std::uint64_t v = static_cast(depth) * 0x1000; + for (int i = 0; i < 4; ++i) + v += prefix.data()[i]; + return uint256{v}; + }; + + // Via composition + auto const plan = planDeferredRebuild(keys); + auto const result = executeRebuildPlan(plan, parentLookup); + AffectedNode const rootKey{0, uint256{}}; + auto const composedRoot = result.at(rootKey); + + // Via unified API + auto const unifiedRoot = deferredRebuildRoot(keys, parentLookup); + + EXPECT_EQ(unifiedRoot, composedRoot); +} + +TEST(DeferredRebuild_Root, ManyKeysMatchesPlanAndExecuteComposition) +{ + std::vector keys; + for (std::uint64_t i = 0; i < 100; ++i) + keys.push_back(keyOf(i * 13)); + + auto const parentLookup = [](int depth, uint256 const& prefix) { + std::uint64_t v = static_cast(depth); + for (int i = 0; i < 8; ++i) + v = (v * 257) + prefix.data()[i]; + return uint256{v}; + }; + + auto const plan = planDeferredRebuild(keys); + auto const result = executeRebuildPlan(plan, parentLookup); + AffectedNode const rootKey{0, uint256{}}; + auto const composedRoot = result.at(rootKey); + + auto const unifiedRoot = deferredRebuildRoot(keys, parentLookup); + + EXPECT_EQ(unifiedRoot, composedRoot); +} + +TEST(DeferredRebuild_Root, IdenticalInputsProduceIdenticalRoots) +{ + std::vector keys{keyOf(1), keyOf(2), keyOf(3)}; + auto const parentLookup = [](int depth, uint256 const& prefix) { + return uint256{ + static_cast(depth) * 1000 + prefix.data()[0]}; + }; + + auto const a = deferredRebuildRoot(keys, parentLookup); + auto const b = deferredRebuildRoot(keys, parentLookup); + EXPECT_EQ(a, b); +} + +// --------------------------------------------------------------------------- +// End-to-end benchmark +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Parallel rebuild by subtree (P7.3). +// +// The root has 16 children at depth 1 — one per first-nibble value of +// the keys. Modified keys partition disjointly into these 16 subtrees; +// each subtree's rebuild touches only its own ancestor paths and never +// reads or writes another subtree's nodes. So we can rebuild all 16 +// subtrees in parallel, then combine the 16 child hashes into the +// root in one final step. +// +// The combine step needs: +// * For each non-empty subtree: the new depth-1 hash from the rebuild +// * For each empty subtree: the existing depth-1 hash from the +// parent SHAMap (unchanged) +// — then compute the root via computeInnerNodeHash. +// +// The pure-function design keeps parallelism orthogonal to correctness: +// the caller chooses serial or parallel execution, but the result must +// be byte-identical to the single-threaded deferredRebuildRoot. +// --------------------------------------------------------------------------- + +TEST(DeferredRebuild_Partition, EmptyKeysProduceEmptyBuckets) +{ + auto const buckets = partitionByFirstNibble({}); + for (auto const& b : buckets) + EXPECT_TRUE(b.empty()); +} + +TEST(DeferredRebuild_Partition, KeysGoToCorrectBucketByFirstNibble) +{ + auto const keyA = keyWithPrefix({0x0}); + auto const keyB = keyWithPrefix({0x5}); + auto const keyC = keyWithPrefix({0xF}); + + auto const buckets = partitionByFirstNibble({keyA, keyB, keyC}); + + EXPECT_EQ(buckets[0x0].size(), 1u); + EXPECT_EQ(buckets[0x5].size(), 1u); + EXPECT_EQ(buckets[0xF].size(), 1u); + for (std::size_t i = 0; i < 16; ++i) + if (i != 0x0 && i != 0x5 && i != 0xF) + EXPECT_TRUE(buckets[i].empty()) << "Bucket " << i << " unexpectedly populated"; +} + +TEST(DeferredRebuild_Partition, MultipleKeysShareBuckets) +{ + auto const keyA = keyWithPrefix({0x3, 0x1}); + auto const keyB = keyWithPrefix({0x3, 0x2}); + auto const keyC = keyWithPrefix({0x7, 0x0}); + + auto const buckets = partitionByFirstNibble({keyA, keyB, keyC}); + + EXPECT_EQ(buckets[0x3].size(), 2u); + EXPECT_EQ(buckets[0x7].size(), 1u); +} + +TEST(DeferredRebuild_Parallel, EmptyKeysMatchSerial) +{ + auto const parentLookup = [](int depth, uint256 const& prefix) { + if (depth == 0 && prefix == uint256{}) + return uint256{0xDEAD}; + return uint256{}; + }; + + EXPECT_EQ( + deferredRebuildRootParallel({}, parentLookup), + deferredRebuildRoot({}, parentLookup)); +} + +TEST(DeferredRebuild_Parallel, SingleKeyMatchesSerial) +{ + std::vector keys{keyOf(42)}; + + auto const parentLookup = [](int depth, uint256 const& prefix) { + std::uint64_t v = static_cast(depth) * 0x1000; + for (int i = 0; i < 4; ++i) + v += prefix.data()[i]; + return uint256{v}; + }; + + EXPECT_EQ( + deferredRebuildRootParallel(keys, parentLookup), + deferredRebuildRoot(keys, parentLookup)); +} + +TEST(DeferredRebuild_Parallel, ManyKeysAcrossManySubtreesMatchSerial) +{ + // Keys spread across all 16 first-nibble buckets to exercise the + // multi-subtree case. + std::vector keys; + for (std::uint64_t n = 0; n < 16; ++n) + { + for (std::uint64_t j = 0; j < 30; ++j) + { + // First nibble = n, rest scattered + auto k = keyWithPrefix({static_cast(n)}); + // Mix in some entropy for nibbles 1+ + for (int i = 1; i < 8; ++i) + k.data()[i / 2] ^= static_cast(j * 0x37 + i); + keys.push_back(k); + } + } + + auto const parentLookup = [](int depth, uint256 const& prefix) { + std::uint64_t v = static_cast(depth); + for (int i = 0; i < 8; ++i) + v = v * 257 + prefix.data()[i]; + return uint256{v}; + }; + + auto const serialRoot = deferredRebuildRoot(keys, parentLookup); + auto const parallelRoot = deferredRebuildRootParallel(keys, parentLookup); + EXPECT_EQ(parallelRoot, serialRoot); +} + +TEST(DeferredRebuild_Parallel, AllKeysInSingleSubtreeMatchSerial) +{ + // Adversarial case: every key has the same first nibble, so 15 + // subtrees are empty and the workload doesn't parallelize. The + // parallel implementation must still produce the same result. + std::vector keys; + for (std::uint64_t j = 0; j < 50; ++j) + { + auto k = keyWithPrefix({0x7}); // all keys start with 7 + for (int i = 1; i < 8; ++i) + k.data()[i / 2] ^= static_cast(j * 0x11 + i); + keys.push_back(k); + } + + auto const parentLookup = [](int depth, uint256 const& prefix) { + std::uint64_t v = static_cast(depth) * 0xABCDEF; + for (int i = 0; i < 8; ++i) + v += prefix.data()[i]; + return uint256{v}; + }; + + EXPECT_EQ( + deferredRebuildRootParallel(keys, parentLookup), + deferredRebuildRoot(keys, parentLookup)); +} + +TEST(DeferredRebuild_Bench, EndToEndAtLedgerScale) +{ + // Realistic mainnet workload: ~3000 modifications per ledger. + // Measure the full plan + execute pipeline as a single number, + // since that's what production close-time will pay. + // + // The parent lookup is a constant-time computation — in production + // it's a SHAMap walk, which dominates the cost. This benchmark + // measures only the algorithmic overhead of the deferred rebuild + // itself, isolated from SHAMap traversal cost. + constexpr std::size_t N = 3'000; + std::vector keys; + keys.reserve(N); + for (std::uint64_t i = 0; i < N; ++i) + keys.push_back(keyOf(i)); + + auto const parentLookup = [](int depth, uint256 const& prefix) { + return uint256{ + static_cast(depth) * 0xABCDEF + + prefix.data()[0] * 0x100 + prefix.data()[1]}; + }; + + auto const t0 = std::chrono::high_resolution_clock::now(); + auto const newRoot = deferredRebuildRoot(keys, parentLookup); + auto const elapsed = std::chrono::high_resolution_clock::now() - t0; + auto const us = + std::chrono::duration_cast(elapsed).count(); + + std::printf( + " deferredRebuildRoot N=%zu keys : %lld µs total\n", + N, + static_cast(us)); + + benchmark_use(newRoot); + + // Post-LCP: measured ~2.3 ms locally — comfortably within + // plan-7's ~7 ms close-time budget. 25 ms threshold = ~10×. + EXPECT_LT(us, 25'000) + << "End-to-end rebuild at 3k keys exceeded 25 ms (plan-7 budget ~7 ms)"; +} + +TEST(DeferredRebuild_Bench, ParallelVsSerialAtLedgerScale) +{ + constexpr std::size_t N = 3'000; + std::vector keys; + keys.reserve(N); + // Spread keys across first-nibble buckets so all 16 subtrees see work. + for (std::uint64_t i = 0; i < N; ++i) + { + auto k = keyOf(i); + // Force first nibble to vary by i % 16 + k.data()[0] = (k.data()[0] & 0x0F) | + static_cast((i % 16) << 4); + keys.push_back(k); + } + + auto const parentLookup = [](int depth, uint256 const& prefix) { + return uint256{ + static_cast(depth) * 0xABCDEF + + prefix.data()[0] * 0x100 + prefix.data()[1]}; + }; + + auto const t0 = std::chrono::high_resolution_clock::now(); + auto const serialRoot = deferredRebuildRoot(keys, parentLookup); + auto const t1 = std::chrono::high_resolution_clock::now(); + auto const parallelRoot = deferredRebuildRootParallel(keys, parentLookup); + auto const t2 = std::chrono::high_resolution_clock::now(); + + auto const serialUs = + std::chrono::duration_cast(t1 - t0).count(); + auto const parallelUs = + std::chrono::duration_cast(t2 - t1).count(); + + std::printf( + " serial : %lld µs\n" + " parallel : %lld µs (speedup %.2fx)\n", + static_cast(serialUs), + static_cast(parallelUs), + static_cast(serialUs) / std::max(1, parallelUs)); + + EXPECT_EQ(serialRoot, parallelRoot); + // Parallel must not be slower than serial by more than 2× (which + // would indicate the threading overhead dominates the work and + // we're shipping the wrong implementation). + EXPECT_LT(parallelUs, serialUs * 2) + << "Parallel slower than 2× serial — threading overhead unexpected"; +} diff --git a/src/tests/libxrpl/ledger/FlatStateMap.cpp b/src/tests/libxrpl/ledger/FlatStateMap.cpp new file mode 100644 index 0000000000..09b89ef615 --- /dev/null +++ b/src/tests/libxrpl/ledger/FlatStateMap.cpp @@ -0,0 +1,995 @@ +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace xrpl; + +namespace { + +// Construct a synthetic SLE for testing. The contents are not meaningful — +// we only need a distinct shared_ptr per key to exercise +// the map's storage and retrieval semantics. +[[nodiscard]] std::shared_ptr +makeSle(std::uint64_t keyValue) +{ + uint256 key{keyValue}; + return std::make_shared(ltACCOUNT_ROOT, key); +} + +[[nodiscard]] uint256 +keyOf(std::uint64_t v) +{ + return uint256{v}; +} + +} // namespace + +// --------------------------------------------------------------------------- +// Basic read/write semantics +// --------------------------------------------------------------------------- + +TEST(FlatStateMap, EmptyOnConstruction) +{ + FlatStateMap m; + EXPECT_TRUE(m.empty()); + EXPECT_EQ(m.size(), 0u); + EXPECT_FALSE(m.exists(keyOf(0))); + EXPECT_EQ(m.read(keyOf(0)), nullptr); +} + +TEST(FlatStateMap, InsertThenRead) +{ + FlatStateMap m; + auto const sle = makeSle(42); + + m.insert(keyOf(42), sle); + + EXPECT_FALSE(m.empty()); + EXPECT_EQ(m.size(), 1u); + EXPECT_TRUE(m.exists(keyOf(42))); + EXPECT_EQ(m.read(keyOf(42)), sle); +} + +TEST(FlatStateMap, ReadMissReturnsNullptr) +{ + FlatStateMap m; + m.insert(keyOf(1), makeSle(1)); + EXPECT_EQ(m.read(keyOf(2)), nullptr); + EXPECT_FALSE(m.exists(keyOf(2))); +} + +TEST(FlatStateMap, InsertReplacesPriorEntry) +{ + // update() semantics in apply: mutating an SLE produces a new shared_ptr + // value; insert() must replace the prior pointer cleanly. + FlatStateMap m; + auto const first = makeSle(1); + auto const second = makeSle(1); // same key, different SLE object + + m.insert(keyOf(1), first); + EXPECT_EQ(m.read(keyOf(1)), first); + + m.insert(keyOf(1), second); + EXPECT_EQ(m.size(), 1u); + EXPECT_EQ(m.read(keyOf(1)), second); + EXPECT_NE(m.read(keyOf(1)), first); +} + +TEST(FlatStateMap, EraseRemovesEntry) +{ + FlatStateMap m; + m.insert(keyOf(7), makeSle(7)); + EXPECT_TRUE(m.exists(keyOf(7))); + + m.erase(keyOf(7)); + EXPECT_FALSE(m.exists(keyOf(7))); + EXPECT_EQ(m.read(keyOf(7)), nullptr); + EXPECT_TRUE(m.empty()); +} + +TEST(FlatStateMap, EraseAbsentKeyIsNoop) +{ + FlatStateMap m; + m.insert(keyOf(1), makeSle(1)); + m.erase(keyOf(99)); // no-op; must not throw, must not affect other keys + EXPECT_EQ(m.size(), 1u); + EXPECT_TRUE(m.exists(keyOf(1))); +} + +TEST(FlatStateMap, Clear) +{ + FlatStateMap m; + for (std::uint64_t i = 0; i < 100; ++i) + m.insert(keyOf(i), makeSle(i)); + EXPECT_EQ(m.size(), 100u); + + m.clear(); + EXPECT_TRUE(m.empty()); + EXPECT_EQ(m.size(), 0u); + EXPECT_FALSE(m.exists(keyOf(50))); +} + +// --------------------------------------------------------------------------- +// Iteration +// --------------------------------------------------------------------------- + +TEST(FlatStateMap, ForEachVisitsAllEntries) +{ + FlatStateMap m; + std::vector> inserted; + constexpr std::size_t N = 50; + for (std::uint64_t i = 0; i < N; ++i) + { + auto sle = makeSle(i); + inserted.push_back(sle); + m.insert(keyOf(i), sle); + } + + std::size_t visited = 0; + m.forEach([&](uint256 const& /*key*/, auto const& /*sle*/) { ++visited; }); + EXPECT_EQ(visited, N); +} + +// --------------------------------------------------------------------------- +// Snapshot semantics +// --------------------------------------------------------------------------- + +TEST(FlatStateMap, SnapshotPreservesEntries) +{ + FlatStateMap source; + for (std::uint64_t i = 0; i < 10; ++i) + source.insert(keyOf(i), makeSle(i)); + + auto snap = source.snapshot(); + ASSERT_NE(snap, nullptr); + EXPECT_EQ(snap->size(), 10u); + for (std::uint64_t i = 0; i < 10; ++i) + { + ASSERT_TRUE(snap->exists(keyOf(i))); + EXPECT_EQ(snap->read(keyOf(i)), source.read(keyOf(i))); // shared SLE + } +} + +TEST(FlatStateMap, SnapshotIsIndependentOfSubsequentWrites) +{ + FlatStateMap source; + source.insert(keyOf(1), makeSle(1)); + source.insert(keyOf(2), makeSle(2)); + + auto snap = source.snapshot(); + + // Mutate source after snapshot. + source.insert(keyOf(3), makeSle(3)); + source.erase(keyOf(1)); + source.insert(keyOf(2), makeSle(99)); // replace key 2 with a different SLE + + // Snapshot must reflect state at snapshot time, not source's current state. + EXPECT_EQ(snap->size(), 2u); + EXPECT_TRUE(snap->exists(keyOf(1))); + EXPECT_TRUE(snap->exists(keyOf(2))); + EXPECT_FALSE(snap->exists(keyOf(3))); + EXPECT_NE(snap->read(keyOf(2)), source.read(keyOf(2))); +} + +TEST(FlatStateMap, SnapshotSharesUnderlyingSleObjects) +{ + // Snapshot performs a shallow copy of the map (shared_ptr values). + // It does NOT deep-copy SLE bodies; both source and snapshot point at + // the same immutable SLE instance. + FlatStateMap source; + auto const sle = makeSle(1); + source.insert(keyOf(1), sle); + + auto snap = source.snapshot(); + EXPECT_EQ(snap->read(keyOf(1)).get(), sle.get()); + EXPECT_EQ(source.read(keyOf(1)).get(), sle.get()); +} + +// --------------------------------------------------------------------------- +// Ownership: FlatStateMap is non-copyable and non-movable (owns a mutex). +// Callers that need ownership transfer wrap in std::unique_ptr. +// --------------------------------------------------------------------------- + +TEST(FlatStateMap, UniquePtrOwnershipTransfer) +{ + auto a = std::make_unique(); + a->insert(keyOf(1), makeSle(1)); + a->insert(keyOf(2), makeSle(2)); + + auto b = std::move(a); // pointer move, not map move + ASSERT_NE(b, nullptr); + EXPECT_EQ(a, nullptr); // a is now null + EXPECT_EQ(b->size(), 2u); + EXPECT_TRUE(b->exists(keyOf(1))); + EXPECT_TRUE(b->exists(keyOf(2))); +} + +// --------------------------------------------------------------------------- +// Population from a range of SLEs (P6.2). The ReadView-taking overload +// `populateFromReadView` is the same one-line forwarder; we cover the +// templated range form directly so the test doesn't need a live ReadView. +// --------------------------------------------------------------------------- + +TEST(FlatStateMap, PopulateFromRange) +{ + std::vector> sles; + constexpr std::size_t N = 25; + for (std::uint64_t i = 0; i < N; ++i) + sles.push_back(makeSle(i)); + + FlatStateMap m; + populateFromRange(m, sles); + + EXPECT_EQ(m.size(), N); + for (std::size_t i = 0; i < N; ++i) + { + ASSERT_TRUE(m.exists(sles[i]->key())); + EXPECT_EQ(m.read(sles[i]->key()).get(), sles[i].get()); + } +} + +TEST(FlatStateMap, PopulateFromRangeOnEmptyRangeLeavesMapEmpty) +{ + std::vector> empty; + FlatStateMap m; + populateFromRange(m, empty); + EXPECT_TRUE(m.empty()); +} + +TEST(FlatStateMap, PopulateFromRangePreservesSleIdentity) +{ + // The map must store the exact shared_ptr the caller provided — + // not a deep copy of the SLE. This matters because SLE objects are + // logically immutable; any "copy" would risk subtle observer drift. + std::vector> sles{makeSle(1)}; + auto const expected = sles[0].get(); + + FlatStateMap m; + populateFromRange(m, sles); + + EXPECT_EQ(m.read(sles[0]->key()).get(), expected); +} + +// --------------------------------------------------------------------------- +// Dual-write mirroring (P6.3 from plan-6). +// +// In plan-6's 2-writes-for-1-read pattern, every state mutation must go to +// both the SHAMap (authoritative for the state root) and the FlatStateMap +// (read-side materialization). The integration point in xrpld is the +// RawView interface — every state mutation goes through one of three +// methods: rawInsert(sle), rawReplace(sle), or rawErase(sle). +// +// `mirrorRawInsert/mirrorRawReplace/mirrorRawErase` are the testable +// units that perform the flat-map side of the dual write. They take a +// FlatStateMap and an SLE (or key, for erase) and update the map to +// reflect the operation. The Ledger integration (a separate change) +// wires each `raw*` override to call the matching `mirror*` helper. +// +// These tests describe the contract: +// * mirrorRawInsert(map, sle) — adds the sle keyed by sle->key() +// * mirrorRawReplace(map, sle) — replaces the sle for sle->key() +// * mirrorRawErase(map, sle) — removes sle->key() from the map +// * mirrorRawErase(map, key) — removes the key from the map +// +// All four are write-side ops; they take a unique_lock under the hood. +// --------------------------------------------------------------------------- + +TEST(FlatStateMap_Mirror, MirrorRawInsertAddsEntry) +{ + FlatStateMap map; + auto const sle = makeSle(1); + + mirrorRawInsert(map, sle); + + EXPECT_EQ(map.size(), 1u); + EXPECT_EQ(map.read(sle->key()), sle); +} + +TEST(FlatStateMap_Mirror, MirrorRawReplaceReplacesEntry) +{ + FlatStateMap map; + auto const original = makeSle(1); + auto const replacement = makeSle(1); // same key, distinct object + map.insert(original->key(), original); + + mirrorRawReplace(map, replacement); + + EXPECT_EQ(map.size(), 1u); + EXPECT_EQ(map.read(original->key()), replacement); + EXPECT_NE(map.read(original->key()), original); +} + +TEST(FlatStateMap_Mirror, MirrorRawEraseBySleRemovesEntry) +{ + FlatStateMap map; + auto const sle = makeSle(1); + map.insert(sle->key(), sle); + + mirrorRawErase(map, sle); + + EXPECT_TRUE(map.empty()); + EXPECT_FALSE(map.exists(sle->key())); +} + +TEST(FlatStateMap_Mirror, MirrorRawEraseByKeyRemovesEntry) +{ + FlatStateMap map; + auto const sle = makeSle(1); + map.insert(sle->key(), sle); + + mirrorRawErase(map, sle->key()); + + EXPECT_TRUE(map.empty()); +} + +TEST(FlatStateMap_Mirror, MirrorOpsAreNoopOnAbsentKeys) +{ + FlatStateMap map; + // Erasing keys not in the map must not throw and must not alter the map. + mirrorRawErase(map, keyOf(99)); + EXPECT_TRUE(map.empty()); + + // Replacing a key not in the map is semantically equivalent to an + // insert: in xrpld, rawReplace asserts the prior SLE exists in the + // SHAMap, so the SHAMap side handles the precondition. The flat + // mirror is permissive — it ensures the post-state matches what the + // SHAMap will have. If the caller upstream got it wrong, the + // differential invariant check at close (P6.5) is what catches it. + auto const sle = makeSle(5); + mirrorRawReplace(map, sle); + EXPECT_EQ(map.size(), 1u); + EXPECT_EQ(map.read(sle->key()), sle); +} + +TEST(FlatStateMap_Mirror, MirroredSequenceMatchesIntendedState) +{ + // Simulate a sequence of raw operations as they would happen during + // a transaction's apply path, and assert the flat map ends in the + // state matching the SHAMap-equivalent view. + FlatStateMap map; + + auto const a = makeSle(1); + auto const b = makeSle(2); + auto const c = makeSle(3); + auto const aPrime = makeSle(1); // updated version of a + + mirrorRawInsert(map, a); + mirrorRawInsert(map, b); + mirrorRawInsert(map, c); + mirrorRawReplace(map, aPrime); + mirrorRawErase(map, b); + + // Expected end state: { 1 -> aPrime, 3 -> c } + EXPECT_EQ(map.size(), 2u); + EXPECT_EQ(map.read(a->key()), aPrime); + EXPECT_FALSE(map.exists(b->key())); + EXPECT_EQ(map.read(c->key()), c); +} + +// --------------------------------------------------------------------------- +// Keylet-aware read (P6.4). +// +// `readFromFlatStateMap(map, keylet)` is the testable unit underlying +// the `Ledger::read(Keylet)` integration. It performs three steps: +// 1. lookup by keylet.key in the FlatStateMap +// 2. if missing, return nullptr (no SLE under that key) +// 3. if present, verify the SLE matches the keylet's expected type via +// Keylet::check; on mismatch, return nullptr +// +// The type check mirrors the existing Ledger::read behavior — a keylet +// query for the wrong type returns nullptr, not the wrong-typed SLE. +// This preserves the contract: callers ask "is there an X at this key?" +// and the read either yields an X or yields nothing. +// --------------------------------------------------------------------------- + +TEST(FlatStateMap_KeyletRead, HitReturnsSle) +{ + FlatStateMap map; + auto const sle = makeSle(1); + map.insert(sle->key(), sle); + + Keylet const k{ltACCOUNT_ROOT, sle->key()}; + auto const result = readFromFlatStateMap(map, k); + EXPECT_EQ(result, sle); +} + +TEST(FlatStateMap_KeyletRead, MissReturnsNullptr) +{ + FlatStateMap map; + Keylet const k{ltACCOUNT_ROOT, keyOf(42)}; + + auto const result = readFromFlatStateMap(map, k); + EXPECT_EQ(result, nullptr); +} + +TEST(FlatStateMap_KeyletRead, TypeMismatchReturnsNullptr) +{ + // SLE stored with ltACCOUNT_ROOT, queried as ltRIPPLE_STATE: must + // return nullptr, not the wrong-typed SLE. + FlatStateMap map; + auto const sle = makeSle(1); // ltACCOUNT_ROOT (see makeSle helper) + map.insert(sle->key(), sle); + + Keylet const wrongType{ltRIPPLE_STATE, sle->key()}; + auto const result = readFromFlatStateMap(map, wrongType); + EXPECT_EQ(result, nullptr); +} + +TEST(FlatStateMap_KeyletRead, AbsenceIsAuthoritativeUnderPlan6V2) +{ + // Plan 6 v2 semantics: when a FlatStateMap is the read source of + // truth, a miss IS the answer. No fallback. The differential + // invariant check at close (P6.5) is what makes this safe. + // + // This test exists to document the contract: a populated map that + // doesn't contain key K reports nullptr for K, period. No probing + // into a SHAMap or other source. + FlatStateMap map; + auto const sleA = makeSle(1); + auto const sleB = makeSle(2); + map.insert(sleA->key(), sleA); + map.insert(sleB->key(), sleB); + + Keylet const absent{ltACCOUNT_ROOT, keyOf(99)}; + auto const result = readFromFlatStateMap(map, absent); + EXPECT_EQ(result, nullptr); + // Map state unchanged (no implicit population on read miss). + EXPECT_EQ(map.size(), 2u); + EXPECT_FALSE(map.exists(absent.key)); +} + +// --------------------------------------------------------------------------- +// Concurrency: many concurrent readers do not block each other; writes +// interleave with reads safely. We're not benchmarking, just checking that +// no race trips a sanitizer and that final state is consistent. +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Differential invariant (P6.5). +// +// Once the flat map is the read source of truth (P6.4), the safety +// property that lets the no-fallback design ship is: at every ledger +// close, the flat map and the SHAMap must agree on which keys are +// present. `diffFlatStateKeys(flat, sourceKeys)` performs that check — +// returning the sets of (a) keys in the source that are missing from +// the flat map and (b) keys in the flat map that aren't in the source. +// +// Both lists empty == invariant holds. Anything else is a stop-the-line +// bug — the Ledger integration crashes rather than publishing a state +// root that disagrees with reality. +// +// Content drift (right keys, wrong SLE bodies) is a separate, stronger +// invariant. It's prevented by construction: the mirror helpers (tested +// in isolation) write exactly the SLE the caller passed to raw*. If the +// mirror helpers are correct and the wiring is correct, content can't +// drift. P6.5 catches the membership-drift case where wiring is broken. +// --------------------------------------------------------------------------- + +TEST(FlatStateMap_Diff, EmptyVsEmptyHasNoDiff) +{ + FlatStateMap flat; + std::vector sourceKeys; + + auto const diff = diffFlatStateKeys(flat, sourceKeys); + EXPECT_TRUE(diff.missingFromFlat.empty()); + EXPECT_TRUE(diff.extraInFlat.empty()); +} + +TEST(FlatStateMap_Diff, IdenticalKeySetsHaveNoDiff) +{ + FlatStateMap flat; + std::vector sourceKeys; + for (std::uint64_t i = 0; i < 50; ++i) + { + auto const sle = makeSle(i); + flat.insert(sle->key(), sle); + sourceKeys.push_back(sle->key()); + } + + auto const diff = diffFlatStateKeys(flat, sourceKeys); + EXPECT_TRUE(diff.missingFromFlat.empty()); + EXPECT_TRUE(diff.extraInFlat.empty()); +} + +TEST(FlatStateMap_Diff, KeysInSourceButNotFlatAreFlagged) +{ + FlatStateMap flat; + auto const sleA = makeSle(1); + auto const sleB = makeSle(2); + auto const sleC = makeSle(3); + flat.insert(sleA->key(), sleA); + // sleB intentionally not in flat + flat.insert(sleC->key(), sleC); + + std::vector sourceKeys{sleA->key(), sleB->key(), sleC->key()}; + + auto const diff = diffFlatStateKeys(flat, sourceKeys); + ASSERT_EQ(diff.missingFromFlat.size(), 1u); + EXPECT_EQ(diff.missingFromFlat[0], sleB->key()); + EXPECT_TRUE(diff.extraInFlat.empty()); +} + +TEST(FlatStateMap_Diff, KeysInFlatButNotSourceAreFlagged) +{ + FlatStateMap flat; + auto const sleA = makeSle(1); + auto const sleB = makeSle(2); + auto const sleC = makeSle(3); + flat.insert(sleA->key(), sleA); + flat.insert(sleB->key(), sleB); // phantom — not in source + flat.insert(sleC->key(), sleC); + + std::vector sourceKeys{sleA->key(), sleC->key()}; + + auto const diff = diffFlatStateKeys(flat, sourceKeys); + EXPECT_TRUE(diff.missingFromFlat.empty()); + ASSERT_EQ(diff.extraInFlat.size(), 1u); + EXPECT_EQ(diff.extraInFlat[0], sleB->key()); +} + +TEST(FlatStateMap_Diff, BothSidesFlaggedSimultaneously) +{ + FlatStateMap flat; + auto const inBoth = makeSle(1); + auto const onlyFlat = makeSle(2); + auto const onlySource = makeSle(3); + flat.insert(inBoth->key(), inBoth); + flat.insert(onlyFlat->key(), onlyFlat); + + std::vector sourceKeys{inBoth->key(), onlySource->key()}; + + auto const diff = diffFlatStateKeys(flat, sourceKeys); + ASSERT_EQ(diff.missingFromFlat.size(), 1u); + EXPECT_EQ(diff.missingFromFlat[0], onlySource->key()); + ASSERT_EQ(diff.extraInFlat.size(), 1u); + EXPECT_EQ(diff.extraInFlat[0], onlyFlat->key()); +} + +TEST(FlatStateMap_Diff, FlatMapsAgreeReturnsTrueWhenNoDiff) +{ + // Convenience predicate built on diffFlatStateKeys for the hot + // path: at every close, the integration calls this. Returns true + // iff both diff lists are empty. + FlatStateMap flat; + std::vector sourceKeys; + for (std::uint64_t i = 0; i < 10; ++i) + { + auto const sle = makeSle(i); + flat.insert(sle->key(), sle); + sourceKeys.push_back(sle->key()); + } + + EXPECT_TRUE(flatStateMapMatches(flat, sourceKeys)); + + // After divergence, must return false. + flat.erase(sourceKeys[0]); + EXPECT_FALSE(flatStateMapMatches(flat, sourceKeys)); +} + +// --------------------------------------------------------------------------- +// SHAMap-like adapter (P6.5 integration). +// +// The Ledger integration of the differential invariant needs to compare +// the FlatStateMap against the live SHAMap's key-set. SHAMap iterators +// yield `SHAMapItem` objects, not raw `uint256`s — so we need a thin +// adapter that walks any "SHAMap-like" range (anything with begin()/ +// end() yielding items with a `.key()` method) and feeds the keys +// through `flatStateMapMatches`. +// +// This is the helper Ledger::validateFlatStateMapMatchesShaMap() will +// call. It's testable here with a mock SHAMap-like, so the Ledger +// integration becomes a one-line forwarder. +// --------------------------------------------------------------------------- + +namespace { + +// Minimal mock that satisfies the contract: +// * iterable via begin/end +// * each element exposes a `key()` returning uint256 +struct MockShaMapItem +{ + uint256 k; + [[nodiscard]] uint256 const& + key() const noexcept + { + return k; + } +}; + +} // namespace + +TEST(FlatStateMap_ShaMapAdapter, EmptyShaMapMatchesEmptyFlat) +{ + FlatStateMap flat; + std::vector shaMap; + EXPECT_TRUE(flatStateMapMatchesShaMap(flat, shaMap)); +} + +TEST(FlatStateMap_ShaMapAdapter, IdenticalContentsMatch) +{ + FlatStateMap flat; + std::vector shaMap; + for (std::uint64_t i = 0; i < 25; ++i) + { + auto const sle = makeSle(i); + flat.insert(sle->key(), sle); + shaMap.push_back({sle->key()}); + } + EXPECT_TRUE(flatStateMapMatchesShaMap(flat, shaMap)); +} + +TEST(FlatStateMap_ShaMapAdapter, MissingFromFlatFails) +{ + FlatStateMap flat; + auto const sleA = makeSle(1); + flat.insert(sleA->key(), sleA); + + std::vector shaMap{ + {sleA->key()}, {keyOf(99)}}; // 99 is in shaMap, missing from flat + EXPECT_FALSE(flatStateMapMatchesShaMap(flat, shaMap)); +} + +TEST(FlatStateMap_ShaMapAdapter, PhantomInFlatFails) +{ + FlatStateMap flat; + auto const sleA = makeSle(1); + auto const phantom = makeSle(99); + flat.insert(sleA->key(), sleA); + flat.insert(phantom->key(), phantom); + + std::vector shaMap{{sleA->key()}}; // phantom isn't there + EXPECT_FALSE(flatStateMapMatchesShaMap(flat, shaMap)); +} + +// --------------------------------------------------------------------------- +// Benchmarks. TDD with benchmarks: assert performance regressions fail +// the test, not just correctness regressions. Thresholds are set +// generously (10x slack vs. measured locally) so CI on under-spec +// machines doesn't flake. Reported numbers are printed so a real +// regression shows up as a measured slowdown even before the threshold +// trips. +// +// What we're proving: +// * read() is O(1) — average latency does not grow with map size +// * write throughput is bounded but not pathological +// * readFromFlatStateMap (the Keylet-typed read) adds negligible +// overhead over the bare map.read() call +// +// All benchmarks measure on a single thread; concurrent scaling is +// covered by ConcurrentReadersAndWritersAreConsistent. +// --------------------------------------------------------------------------- + +namespace { + +// Inhibit dead-code elimination of the value `v` in benchmark loops. +// The empty inline-asm "uses" v as an input, forcing the compiler to +// materialize it. Cheap; no observable side effect. +template +inline void +benchmark_use(T const& v) +{ +#if defined(__clang__) || defined(__GNUC__) + asm volatile("" : : "r,m"(v) : "memory"); +#else + (void)v; +#endif +} + +struct BenchResult +{ + double nsPerOp; + std::size_t ops; +}; + +template +BenchResult +timeOps(std::size_t opCount, Fn&& fn) +{ + auto const t0 = std::chrono::high_resolution_clock::now(); + for (std::size_t i = 0; i < opCount; ++i) + fn(i); + auto const t1 = std::chrono::high_resolution_clock::now(); + auto const elapsedNs = + std::chrono::duration_cast(t1 - t0).count(); + return {static_cast(elapsedNs) / static_cast(opCount), + opCount}; +} + +void +populate(FlatStateMap& m, std::size_t n) +{ + for (std::uint64_t i = 0; i < n; ++i) + m.insert(keyOf(i), makeSle(i)); +} + +} // namespace + +TEST(FlatStateMap_Bench, ReadIsO1AtVariousSizes) +{ + // Measure read() average latency at three map sizes. With O(1) + // semantics (hash table), the per-op time should be roughly flat. + constexpr std::size_t kOpsPerRun = 100'000; + std::vector sizes{1'000, 10'000, 100'000}; + std::vector nsPerOpAtSize; + + for (auto const n : sizes) + { + FlatStateMap m; + populate(m, n); + + // Shuffle the access pattern so we don't accidentally + // measure a sequential cache-friendly access pattern. + std::vector keys; + keys.reserve(n); + for (std::uint64_t i = 0; i < n; ++i) + keys.push_back(keyOf(i)); + std::mt19937_64 rng(12345); + std::shuffle(keys.begin(), keys.end(), rng); + + auto const result = timeOps(kOpsPerRun, [&](std::size_t i) { + auto sle = m.read(keys[i % n]); + // Prevent the compiler from optimizing the read away. + benchmark_use(sle); + }); + + std::printf( + " FlatStateMap::read at N=%zu : %.1f ns/op (%zu ops)\n", + n, + result.nsPerOp, + result.ops); + nsPerOpAtSize.push_back(result.nsPerOp); + + // Regression gate: even on a slow CI box, hash-map reads of a + // 100k-entry map should be well under 1 µs. We pick 2 µs as a + // generous threshold (~10x measured local) to avoid flakes. + EXPECT_LT(result.nsPerOp, 2000.0) + << "Read latency at N=" << n << " exceeded 2 µs/op"; + } + + // O(1) sanity: read at 100k should not be more than 4x slower than + // read at 1k (cache effects + memory bandwidth give some headroom, + // but not a real log-factor). 4x is generous; tighten if it's + // stable in CI. + EXPECT_LT(nsPerOpAtSize.back(), nsPerOpAtSize.front() * 4.0) + << "Read latency grew super-constantly with map size — " + << "expected O(1), got " << nsPerOpAtSize.front() << " ns at N=1k vs " + << nsPerOpAtSize.back() << " ns at N=100k"; +} + +TEST(FlatStateMap_Bench, KeyletReadOverheadIsSmall) +{ + // readFromFlatStateMap adds a Keylet::check call on top of map.read. + // The overhead should be a small constant — well under 100 ns — + // because Keylet::check is just a type tag comparison. + constexpr std::size_t N = 10'000; + constexpr std::size_t kOpsPerRun = 100'000; + FlatStateMap m; + populate(m, N); + + std::vector keylets; + keylets.reserve(N); + for (std::uint64_t i = 0; i < N; ++i) + keylets.emplace_back(ltACCOUNT_ROOT, keyOf(i)); + + auto const bare = timeOps(kOpsPerRun, [&](std::size_t i) { + auto sle = m.read(keylets[i % N].key); + benchmark_use(sle); + }); + + auto const wrapped = timeOps(kOpsPerRun, [&](std::size_t i) { + auto sle = readFromFlatStateMap(m, keylets[i % N]); + benchmark_use(sle); + }); + + std::printf( + " bare map.read : %.1f ns/op\n" + " readFromFlatStateMap : %.1f ns/op (+%.1f ns)\n", + bare.nsPerOp, + wrapped.nsPerOp, + wrapped.nsPerOp - bare.nsPerOp); + + EXPECT_LT(wrapped.nsPerOp - bare.nsPerOp, 500.0) + << "Keylet check added more overhead than expected"; +} + +TEST(FlatStateMap_Bench, WriteThroughput) +{ + // Insert throughput is bounded by the cost of an unordered_map + // insert under a unique_lock. We're not optimizing this; we're + // gating it so a regression in the lock or allocator shows up. + constexpr std::size_t N = 100'000; + FlatStateMap m; + + std::vector> sles; + sles.reserve(N); + for (std::uint64_t i = 0; i < N; ++i) + sles.push_back(makeSle(i)); + + auto const result = + timeOps(N, [&](std::size_t i) { m.insert(sles[i]->key(), sles[i]); }); + + std::printf( + " FlatStateMap::insert : %.1f ns/op (%zu ops)\n", + result.nsPerOp, + result.ops); + + EXPECT_LT(result.nsPerOp, 5000.0) << "Insert latency exceeded 5 µs/op"; + EXPECT_EQ(m.size(), N); +} + +TEST(FlatStateMap_Bench, SnapshotCostAtLedgerScale) +{ + // P6.6 runs snapshot() at every close — capturing the live open + // ledger's flat map as the immutable base for the new closed + // ledger. The cost is O(N) in entry count (shallow copy of N + // shared_ptrs) and must be a small fraction of the close budget. + // + // Mainnet target: ~10M SLEs. We measure at 100k here and report + // ns/entry so extrapolation is clear. With ~50 ns/entry, 10M + // ledger snapshots in ~500 ms — borderline; a persistent HAMT + // (Plan 6 follow-on) is the long-term answer if this proves too + // expensive. + constexpr std::size_t N = 100'000; + FlatStateMap source; + for (std::uint64_t i = 0; i < N; ++i) + source.insert(keyOf(i), makeSle(i)); + + auto const start = std::chrono::high_resolution_clock::now(); + auto snap = source.snapshot(); + auto const elapsed = std::chrono::high_resolution_clock::now() - start; + auto const ms = + std::chrono::duration_cast(elapsed).count(); + auto const nsPerEntry = + std::chrono::duration_cast(elapsed).count() / + static_cast(N); + + std::printf( + " FlatStateMap::snapshot N=%zu : %lld ms total (%.1f ns/entry)\n", + N, + static_cast(ms), + nsPerEntry); + + ASSERT_NE(snap, nullptr); + EXPECT_EQ(snap->size(), N); + EXPECT_LT(ms, 500) + << "Snapshot at 100k entries should complete under 500 ms"; +} + +TEST(FlatStateMap_Bench, DifferentialInvariantCheckIsCheap) +{ + // P6.5 runs the diff at every ledger close. If it's slow it adds + // latency to the close path, defeating the point of Plan 6. + // Threshold: a 100k-entry map must validate in well under 100 ms + // on a typical validator. Real mainnet has ~10M SLEs, so this + // extrapolates to ~10 s at 100M-mapping. That would be too slow + // for real deployment; we'll need a partial / incremental check + // for production scale, but at this layer we just want a bounded + // O(N) walk. + constexpr std::size_t N = 100'000; + FlatStateMap m; + std::vector sourceKeys; + sourceKeys.reserve(N); + for (std::uint64_t i = 0; i < N; ++i) + { + auto const sle = makeSle(i); + m.insert(sle->key(), sle); + sourceKeys.push_back(sle->key()); + } + + auto const start = std::chrono::high_resolution_clock::now(); + bool const ok = flatStateMapMatches(m, sourceKeys); + auto const elapsed = std::chrono::high_resolution_clock::now() - start; + auto const ms = + std::chrono::duration_cast(elapsed).count(); + auto const nsPerKey = + std::chrono::duration_cast(elapsed).count() / + static_cast(N); + + std::printf( + " flatStateMapMatches N=%zu : %lld ms total (%.1f ns/key)\n", + N, + static_cast(ms), + nsPerKey); + + EXPECT_TRUE(ok); + EXPECT_LT(ms, 200) << "Diff at 100k entries should complete under 200 ms"; +} + +TEST(FlatStateMap_Bench, MirrorOverheadOverDirectInsert) +{ + // mirrorRawInsert forwards to map.insert with an extra shared_ptr + // load to extract the key. The wrapper overhead should be near + // zero — within noise of the bare insert. + constexpr std::size_t N = 50'000; + + std::vector> sles; + sles.reserve(N); + for (std::uint64_t i = 0; i < N; ++i) + sles.push_back(makeSle(i)); + + FlatStateMap direct; + auto const bareResult = timeOps( + N, [&](std::size_t i) { direct.insert(sles[i]->key(), sles[i]); }); + + FlatStateMap mirrored; + auto const mirrorResult = + timeOps(N, [&](std::size_t i) { mirrorRawInsert(mirrored, sles[i]); }); + + std::printf( + " direct insert : %.1f ns/op\n" + " mirrorRawInsert : %.1f ns/op (%+.1f ns)\n", + bareResult.nsPerOp, + mirrorResult.nsPerOp, + mirrorResult.nsPerOp - bareResult.nsPerOp); + + // Mirror wrapper should not double the insert cost; 50% slack is + // very generous given they do the same thing. + EXPECT_LT(mirrorResult.nsPerOp, bareResult.nsPerOp * 1.5) + << "mirrorRawInsert wrapper added more overhead than expected"; + EXPECT_EQ(mirrored.size(), N); +} + +// --------------------------------------------------------------------------- + +TEST(FlatStateMap, ConcurrentReadersAndWritersAreConsistent) +{ + FlatStateMap m; + constexpr std::size_t N = 1000; + + // Pre-populate with even keys. + for (std::uint64_t i = 0; i < N; i += 2) + m.insert(keyOf(i), makeSle(i)); + + std::atomic stop{false}; + std::atomic readsObserved{0}; + + auto reader = [&] { + while (!stop.load(std::memory_order_relaxed)) + { + for (std::uint64_t i = 0; i < N; i += 2) + { + if (m.exists(keyOf(i))) + readsObserved.fetch_add(1, std::memory_order_relaxed); + } + } + }; + + auto writer = [&] { + // Insert odd keys; do not modify the even keys readers observe. + for (std::uint64_t i = 1; i < N; i += 2) + m.insert(keyOf(i), makeSle(i)); + }; + + std::vector readers; + for (int i = 0; i < 4; ++i) + readers.emplace_back(reader); + + std::thread w(writer); + w.join(); + stop.store(true, std::memory_order_relaxed); + for (auto& r : readers) + r.join(); + + // Every pre-populated even key must still be present. + for (std::uint64_t i = 0; i < N; i += 2) + EXPECT_TRUE(m.exists(keyOf(i))); + // Every written odd key must be present. + for (std::uint64_t i = 1; i < N; i += 2) + EXPECT_TRUE(m.exists(keyOf(i))); + EXPECT_EQ(m.size(), N); + EXPECT_GT(readsObserved.load(), 0u); // readers made progress +} diff --git a/src/tests/libxrpl/ledger/FlatStateMapIntegration.cpp b/src/tests/libxrpl/ledger/FlatStateMapIntegration.cpp new file mode 100644 index 0000000000..c908f007bd --- /dev/null +++ b/src/tests/libxrpl/ledger/FlatStateMapIntegration.cpp @@ -0,0 +1,596 @@ +// Plan 6 A-phase integration tests. +// +// Exercises the public `attachFlatStateMapTo(Ledger&)` helper — +// the one explicit entry point a node or test integration uses to +// turn on the flat-map read path for a given Ledger. After attach: +// * Ledger::flatStateMap() returns non-null +// * Ledger::validateFlatStateMapMatchesShaMap() returns true +// * Ledger::read(keylet) consults the flat map (verified by the +// existing P6.4 wiring + null-safety regression tests) +// +// These are real-Ledger tests, not data-structure unit tests. They +// catch wiring issues the libxrpl/ledger/FlatStateMap.cpp tests +// cannot — bugs that only surface when an actual Ledger walks its +// SHAMap to build the flat map. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +namespace xrpl::test { + +class FlatStateMapIntegration : public ::testing::Test +{ +protected: + TestFamily family_{beast::Journal{beast::Journal::getNullSink()}}; + + [[nodiscard]] std::shared_ptr + makeGenesisLedger() + { + // Genesis ledger with default rules (no amendments enabled). + // This ledger is IMMUTABLE — suitable for read-side tests but + // not for raw{Insert,Replace,Erase} which require a mutable + // SHAMap. For write-side tests, use `makeMutableChildLedger`. + Rules const rules{std::unordered_set>{}}; + Fees const fees{XRPAmount{10}, XRPAmount{10'000'000}, XRPAmount{2'000'000}}; + std::vector const amendments; + + return std::make_shared( + kCreateGenesis, rules, fees, amendments, family_); + } + + [[nodiscard]] std::shared_ptr + makeMutableChildLedger() + { + // Build a child Ledger atop genesis. Child ledgers are + // constructed mutable so the apply path can write to them. + // This matches the production lifecycle: closed ledger N is + // immutable; closed ledger N+1 is built from N (mutable until + // it's itself closed). + auto genesis = makeGenesisLedger(); + return std::make_shared( + *genesis, + NetClock::time_point{NetClock::duration{0}}); + } +}; + +TEST_F(FlatStateMapIntegration, NoFlatMapByDefault) +{ + auto const ledger = makeGenesisLedger(); + EXPECT_EQ(ledger->flatStateMap(), nullptr); + EXPECT_TRUE(ledger->validateFlatStateMapMatchesShaMap()); +} + +TEST_F(FlatStateMapIntegration, AttachPopulatesAndMatches) +{ + auto const ledger = makeGenesisLedger(); + + attachFlatStateMapTo(*ledger); + + auto const map = ledger->flatStateMap(); + ASSERT_NE(map, nullptr); + EXPECT_TRUE(ledger->validateFlatStateMapMatchesShaMap()); + + // The flat map should have one entry per SLE in the ledger. + std::size_t countViaWalk = 0; + for (auto const& sle : ledger->sles) + { + (void)sle; + ++countViaWalk; + } + EXPECT_EQ(map->size(), countViaWalk); + EXPECT_GT(map->size(), 0u) + << "Genesis ledger should have at least amendment+fee SLEs"; +} + +TEST_F(FlatStateMapIntegration, ReadsAfterAttachReturnSameSles) +{ + auto const ledger = makeGenesisLedger(); + + // Collect SLE pointers via SHAMap-descent path (pre-attach). + std::vector> preAttachReads; + for (auto const& sle : ledger->sles) + preAttachReads.push_back(sle); + + attachFlatStateMapTo(*ledger); + + // After attach, reading via the flat map must yield SLEs that + // serialize byte-equal to the originals. + for (auto const& origSle : preAttachReads) + { + auto const fromMap = ledger->flatStateMap()->read(origSle->key()); + ASSERT_NE(fromMap, nullptr) << "Missing key: " << origSle->key(); + EXPECT_EQ(fromMap->getFullText(), origSle->getFullText()); + } +} + +TEST_F(FlatStateMapIntegration, RepeatedAttachReplaces) +{ + auto const ledger = makeGenesisLedger(); + + attachFlatStateMapTo(*ledger); + auto const firstMap = ledger->flatStateMap(); + ASSERT_NE(firstMap, nullptr); + + attachFlatStateMapTo(*ledger); + auto const secondMap = ledger->flatStateMap(); + ASSERT_NE(secondMap, nullptr); + + EXPECT_NE(firstMap.get(), secondMap.get()); + EXPECT_EQ(firstMap->size(), secondMap->size()); + EXPECT_TRUE(ledger->validateFlatStateMapMatchesShaMap()); +} + +// --------------------------------------------------------------------------- +// Write-path verification — the P6.3 mirror wiring through Ledger::raw* +// --------------------------------------------------------------------------- + +namespace { + +// Construct a minimal SLE we can rawInsert into a Ledger for testing. +// We use ltACCOUNT_ROOT with a unique key — content doesn't need to be +// realistic; we're testing the mirror plumbing, not transactor logic. +[[nodiscard]] std::shared_ptr +makeTestSle(std::uint64_t keyValue) +{ + uint256 const key{keyValue}; + return std::make_shared(ltACCOUNT_ROOT, key); +} + +} // namespace + +TEST_F(FlatStateMapIntegration, RawInsertMirrorsToFlatMap) +{ + auto const ledger = makeMutableChildLedger(); + attachFlatStateMapTo(*ledger); + auto const map = ledger->flatStateMap(); + ASSERT_NE(map, nullptr); + + auto const sle = makeTestSle(0xDEAD'BEEFu); + auto const sizeBefore = map->size(); + + ledger->rawInsert(sle); + + EXPECT_EQ(map->size(), sizeBefore + 1u); + auto const fromMap = map->read(sle->key()); + ASSERT_NE(fromMap, nullptr); + EXPECT_EQ(fromMap.get(), sle.get()) + << "Flat map should hold the exact same shared_ptr we inserted"; + + // Differential invariant must still hold after the write. + EXPECT_TRUE(ledger->validateFlatStateMapMatchesShaMap()); +} + +TEST_F(FlatStateMapIntegration, RawEraseMirrorsToFlatMap) +{ + auto const ledger = makeMutableChildLedger(); + attachFlatStateMapTo(*ledger); + auto const map = ledger->flatStateMap(); + ASSERT_NE(map, nullptr); + + // Insert, then erase. + auto const sle = makeTestSle(0xCAFEu); + ledger->rawInsert(sle); + ASSERT_TRUE(map->exists(sle->key())); + + ledger->rawErase(sle); + + EXPECT_FALSE(map->exists(sle->key())) + << "Erase should clear the flat-map entry"; + EXPECT_TRUE(ledger->validateFlatStateMapMatchesShaMap()); +} + +TEST_F(FlatStateMapIntegration, RawReplaceUpdatesFlatMap) +{ + auto const ledger = makeMutableChildLedger(); + attachFlatStateMapTo(*ledger); + auto const map = ledger->flatStateMap(); + ASSERT_NE(map, nullptr); + + // Insert first version, then replace with a different SLE object + // at the same key. + auto const firstSle = makeTestSle(0xC0DEu); + ledger->rawInsert(firstSle); + auto const fromMapFirst = map->read(firstSle->key()); + ASSERT_NE(fromMapFirst, nullptr); + EXPECT_EQ(fromMapFirst.get(), firstSle.get()); + + auto const secondSle = makeTestSle(0xC0DEu); // same key + ASSERT_NE(firstSle.get(), secondSle.get()) + << "Test setup: replacement SLE must be a distinct object"; + + ledger->rawReplace(secondSle); + + auto const fromMapSecond = map->read(secondSle->key()); + ASSERT_NE(fromMapSecond, nullptr); + EXPECT_EQ(fromMapSecond.get(), secondSle.get()) + << "Replace should swap the flat-map pointer to the new SLE"; + EXPECT_NE(fromMapSecond.get(), firstSle.get()); + + EXPECT_TRUE(ledger->validateFlatStateMapMatchesShaMap()); +} + +TEST_F(FlatStateMapIntegration, ManyWritesPreserveInvariant) +{ + auto const ledger = makeMutableChildLedger(); + attachFlatStateMapTo(*ledger); + auto const map = ledger->flatStateMap(); + ASSERT_NE(map, nullptr); + + std::vector> sles; + for (std::uint64_t i = 0; i < 100; ++i) + { + auto sle = makeTestSle(0x1000u + i); + ledger->rawInsert(sle); + sles.push_back(sle); + } + + // Erase every other one. + for (std::size_t i = 0; i < sles.size(); i += 2) + ledger->rawErase(sles[i]); + + // Replace the rest. + for (std::size_t i = 1; i < sles.size(); i += 2) + { + auto replacement = makeTestSle(0x1000u + i); + ledger->rawReplace(replacement); + } + + // After 100 inserts + 50 erases + 50 replaces, the invariant + // must still hold. + EXPECT_TRUE(ledger->validateFlatStateMapMatchesShaMap()); +} + +TEST_F(FlatStateMapIntegration, WritesBeforeAttachDontGetMirrored) +{ + // Sanity: if writes happen BEFORE the flat map is attached, they + // hit the SHAMap only. Then attach + validate populates from the + // SHAMap and the invariant holds. Catches the bootstrapping case. + auto const ledger = makeMutableChildLedger(); + auto const sle = makeTestSle(0xBEEFu); + ledger->rawInsert(sle); + + EXPECT_EQ(ledger->flatStateMap(), nullptr); + + attachFlatStateMapTo(*ledger); + + // After attach, the flat map sees the previously-inserted SLE + // because populate walked the SHAMap. + auto const map = ledger->flatStateMap(); + ASSERT_NE(map, nullptr); + EXPECT_TRUE(map->exists(sle->key())); + EXPECT_TRUE(ledger->validateFlatStateMapMatchesShaMap()); +} + +// --------------------------------------------------------------------------- +// Lifecycle propagation (P6.3): a child ledger built from a parent that +// carries a flat-state mirror must inherit an INDEPENDENT snapshot — the +// flat-map analogue of the SHAMap COW snapshot. This is what lets the flat +// map survive across ledgers instead of dying after one round. +// --------------------------------------------------------------------------- + +TEST_F(FlatStateMapIntegration, ChildWithoutParentMapInheritsNone) +{ + // Default lifecycle: parent has no flat map → child has none. Zero + // behavior change when the feature is not turned on. + auto const parent = makeGenesisLedger(); + ASSERT_EQ(parent->flatStateMap(), nullptr); + + auto const child = std::make_shared( + *parent, NetClock::time_point{NetClock::duration{0}}); + EXPECT_EQ(child->flatStateMap(), nullptr); + EXPECT_TRUE(child->validateFlatStateMapMatchesShaMap()); +} + +TEST_F(FlatStateMapIntegration, ChildInheritsIndependentSnapshot) +{ + auto const parent = makeGenesisLedger(); + attachFlatStateMapTo(*parent); + auto const parentMap = parent->flatStateMap(); + ASSERT_NE(parentMap, nullptr); + + auto const child = std::make_shared( + *parent, NetClock::time_point{NetClock::duration{0}}); + + auto const childMap = child->flatStateMap(); + ASSERT_NE(childMap, nullptr); + // Distinct object, equal contents, and consistent with the child's own + // (COW-snapshotted) SHAMap. + EXPECT_NE(childMap.get(), parentMap.get()); + EXPECT_EQ(childMap->size(), parentMap->size()); + EXPECT_TRUE(child->validateFlatStateMapMatchesShaMap()); +} + +TEST_F(FlatStateMapIntegration, ChildWritesDoNotCorruptParentSnapshot) +{ + auto const parent = makeGenesisLedger(); + attachFlatStateMapTo(*parent); + auto const parentMap = parent->flatStateMap(); + auto const parentSizeBefore = parentMap->size(); + + auto const child = std::make_shared( + *parent, NetClock::time_point{NetClock::duration{0}}); + + // Mutate the child; the parent's snapshot must be untouched. + auto const sle = makeTestSle(0x5117'5157u); + child->rawInsert(sle); + + EXPECT_TRUE(child->flatStateMap()->exists(sle->key())); + EXPECT_FALSE(parentMap->exists(sle->key())) + << "Child write leaked into the parent's flat map"; + EXPECT_EQ(parentMap->size(), parentSizeBefore); + EXPECT_TRUE(child->validateFlatStateMapMatchesShaMap()); + EXPECT_TRUE(parent->validateFlatStateMapMatchesShaMap()); +} + +TEST_F(FlatStateMapIntegration, PropagationChainsAcrossGenerations) +{ + // parent -> child -> grandchild, each inheriting + mutating. Every + // generation's invariant must hold and each map stays independent. + auto const parent = makeGenesisLedger(); + attachFlatStateMapTo(*parent); + + auto const child = std::make_shared( + *parent, NetClock::time_point{NetClock::duration{0}}); + auto const cSle = makeTestSle(0xC111'D000u); + child->rawInsert(cSle); + ASSERT_TRUE(child->validateFlatStateMapMatchesShaMap()); + + auto const grandchild = std::make_shared( + *child, NetClock::time_point{NetClock::duration{0}}); + // Grandchild inherits the child's mutation... + EXPECT_TRUE(grandchild->flatStateMap()->exists(cSle->key())); + auto const gSle = makeTestSle(0x6111'D000u); + grandchild->rawInsert(gSle); + + EXPECT_TRUE(grandchild->validateFlatStateMapMatchesShaMap()); + // ...but the grandchild's own write doesn't reach back up. + EXPECT_FALSE(child->flatStateMap()->exists(gSle->key())); + EXPECT_TRUE(child->validateFlatStateMapMatchesShaMap()); +} + +// --------------------------------------------------------------------------- +// Read routing (P6.4): with a map attached, Ledger::read(Keylet) must take +// the flat path and return results identical to the SHAMap-descent path. +// --------------------------------------------------------------------------- + +TEST_F(FlatStateMapIntegration, LedgerReadFlatPathMatchesShaMapPath) +{ + auto const ledger = makeMutableChildLedger(); + // Add a few typed SLEs so there's something to read. + std::vector> inserted; + for (std::uint64_t i = 0; i < 25; ++i) + { + auto sle = makeTestSle(0x7000u + i); + ledger->rawInsert(sle); + inserted.push_back(sle); + } + + // Capture SHAMap-path reads BEFORE attaching the flat map. + std::vector shaMapReads; + for (auto const& s : inserted) + { + auto const r = ledger->read(Keylet{s->getType(), s->key()}); + ASSERT_NE(r, nullptr); + shaMapReads.push_back(r->getFullText()); + } + + attachFlatStateMapTo(*ledger); + ASSERT_NE(ledger->flatStateMap(), nullptr); + + // Now reads route through the flat map and must match byte-for-byte. + for (std::size_t i = 0; i < inserted.size(); ++i) + { + auto const r = ledger->read(Keylet{inserted[i]->getType(), inserted[i]->key()}); + ASSERT_NE(r, nullptr) << "flat read miss for key " << inserted[i]->key(); + EXPECT_EQ(r->getFullText(), shaMapReads[i]); + } + + // An absent key returns nullptr on the flat path too. + EXPECT_EQ( + ledger->read(Keylet{ltACCOUNT_ROOT, uint256{0xAB5E'0000ull}}), nullptr); +} + +// --------------------------------------------------------------------------- +// The close-time differential invariant (P6.5) must actually FAIL on drift, +// not just pass on correct state — otherwise it's not a safety gate. +// --------------------------------------------------------------------------- + +TEST_F(FlatStateMapIntegration, InvariantFailsOnPhantomEntry) +{ + auto const ledger = makeMutableChildLedger(); + attachFlatStateMapTo(*ledger); + ASSERT_TRUE(ledger->validateFlatStateMapMatchesShaMap()); + + // Inject a flat-map entry the SHAMap does not have. + ledger->flatStateMap()->insert( + uint256{0xDEAD'0001ull}, makeTestSle(0xDEAD'0001ull)); + + EXPECT_FALSE(ledger->validateFlatStateMapMatchesShaMap()) + << "Invariant must catch an entry present in flat but not SHAMap"; +} + +TEST_F(FlatStateMapIntegration, InvariantFailsOnMissingEntry) +{ + auto const ledger = makeMutableChildLedger(); + auto const sle = makeTestSle(0xFEED'0001u); + ledger->rawInsert(sle); + attachFlatStateMapTo(*ledger); + ASSERT_TRUE(ledger->validateFlatStateMapMatchesShaMap()); + + // Drop an entry the SHAMap still has. + ledger->flatStateMap()->erase(sle->key()); + + EXPECT_FALSE(ledger->validateFlatStateMapMatchesShaMap()) + << "Invariant must catch an entry present in SHAMap but not flat"; +} + +// --------------------------------------------------------------------------- +// Plan 7 against a real Ledger's SHAMap (A-phase milestone 2) +// +// First end-to-end composition test: deferredRebuildRoot driven by a +// callback that reads from an actual Ledger's stateMap. The empty- +// modifications case is the smallest meaningful integration — the +// rebuild trivially returns the current root, which must match the +// SHAMap's root via getHash(). +// +// Future slices extend this with a real after-modifications byte- +// identical assertion (the merge gate the plan-7 doc requires for any +// production ship). +// --------------------------------------------------------------------------- + +TEST_F(FlatStateMapIntegration, Plan7EmptyDeltaProducesShaMapRoot) +{ + auto const ledger = makeMutableChildLedger(); + // Add some state so the SHAMap root is non-trivial. + for (std::uint64_t i = 0; i < 20; ++i) + ledger->rawInsert(makeTestSle(0x2000u + i)); + + auto const shaMapRoot = ledger->stateMap().getHash().asUInt256(); + + // The callback only needs to handle (depth=0, prefix=zero) for + // the empty-delta case — it returns the SHAMap root. + auto const callback = + [&shaMapRoot](int depth, uint256 const& prefix) -> uint256 { + if (depth == 0 && prefix == uint256{}) + return shaMapRoot; + // Any other position is a bug for the empty-delta path. + return uint256{}; + }; + + auto const rebuiltRoot = deferredRebuildRoot({}, callback); + EXPECT_EQ(rebuiltRoot, shaMapRoot); +} + +TEST_F(FlatStateMapIntegration, Plan7EmptyDeltaOnEmptyLedger) +{ + // Same as above but on a freshly-constructed mutable child with + // no extra state. The SHAMap root may already be non-trivial due + // to inherited genesis SLEs. + auto const ledger = makeMutableChildLedger(); + auto const shaMapRoot = ledger->stateMap().getHash().asUInt256(); + + auto const callback = + [&shaMapRoot](int depth, uint256 const& prefix) -> uint256 { + if (depth == 0 && prefix == uint256{}) + return shaMapRoot; + return uint256{}; + }; + + EXPECT_EQ(deferredRebuildRoot({}, callback), shaMapRoot); +} + +// --------------------------------------------------------------------------- +// CRITICAL LIMITATION TEST — documents an architectural gap surfaced +// by A-phase integration. +// +// Plan 7's `executeRebuildPlan` algorithm assumes leaves live at depth +// 64 (a fully-expanded radix tree). REAL SHAMap uses PATH COMPRESSION: +// leaves are stored at the shallowest depth where they're unambiguous +// (see SHAMap.cpp addGiveItem — when the path hits an empty branch or +// a leaf, the new leaf is placed at that depth, not deeper). +// +// Consequence: when actual SHAMap modifications cause new leaves to be +// placed at depths < 64 (the common case), the SHAMap's root hash +// computation differs from Plan 7's. They will NOT agree. +// +// Implications: +// * Plan 7 as implemented is NOT byte-identical to real SHAMap. +// The merge gate plan-7-deferred-shamap.md describes cannot +// close on this implementation. +// * Production deployment of Plan 7 requires either: +// (a) extending the algorithm to handle path compression (know +// where each leaf lives in the parent tree; account for +// new inner-node creation at split points), OR +// (b) replacing SHAMap with a non-path-compressed structure +// (a much bigger amendment-class change). +// * Option (a) is substantially more complex than the current +// callback-based design — the algorithm needs to track tree +// topology, not just position-keyed hashes. +// +// This test asserts the discrepancy explicitly so future readers see +// the issue. The library code (planDeferredRebuild, computeInnerNodeHash, +// executeRebuildPlan, deferredRebuildRoot, parallel variants) remains +// in place as a working kernel for the depth-64-leaves model, which +// is still useful as a reference and starting point for the refactor. +// --------------------------------------------------------------------------- + +TEST_F( + FlatStateMapIntegration, + Plan7DoesNotMatchPathCompressedShaMap_DocumentedLimitation) +{ + // Build a Ledger with a few SLEs whose keys differ in their high + // nibbles (forces SHAMap path compression — they're stored as + // direct children of root or shallow inner nodes). + auto const ledger = makeMutableChildLedger(); + std::vector> sles; + for (std::uint64_t i = 0; i < 5; ++i) + { + // Spread keys across the top of the tree. + uint256 k; + k.data()[0] = static_cast(i << 4); + sles.push_back(std::make_shared(ltACCOUNT_ROOT, k)); + ledger->rawInsert(sles.back()); + } + + auto const realShaMapRoot = ledger->stateMap().getHash().asUInt256(); + + // Construct a Plan-7 rebuild treating these leaves as if they + // lived at depth 64. The callback returns: + // - depth 64: the leaf hash for the modified key (else zero) + // - other depths: zero (assume empty parent) + std::vector modKeys; + for (auto const& s : sles) + modKeys.push_back(s->key()); + + // Pre-compute each leaf's SHAMap hash for the callback. + std::unordered_map> leafHashes; + for (auto const& s : sles) + { + uint256 const leafKey = s->key(); + // We can ask the SHAMap for the leaf's actual hash + SHAMapHash itemHash; + auto const item = ledger->stateMap().peekItem(leafKey, itemHash); + if (item) + leafHashes[leafKey] = itemHash.asUInt256(); + } + + auto const plan7Callback = + [&leafHashes](int depth, uint256 const& prefix) -> uint256 { + if (depth == 64) + { + auto it = leafHashes.find(prefix); + if (it != leafHashes.end()) + return it->second; + } + return uint256{}; // empty parent at non-leaf depths + }; + + auto const plan7Root = deferredRebuildRoot(modKeys, plan7Callback); + + // Document the discrepancy. Plan 7's depth-64 model produces a + // different hash than path-compressed SHAMap — this MUST be the + // case until Plan 7 is refactored to handle path compression. + EXPECT_NE(plan7Root, realShaMapRoot) + << "If this assertion ever starts failing, Plan 7's path-" + << "compression limitation may have been fixed — update this " + << "test to assert equality and remove the documented limitation."; +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/ledger/main.cpp b/src/tests/libxrpl/ledger/main.cpp new file mode 100644 index 0000000000..5142bbe08a --- /dev/null +++ b/src/tests/libxrpl/ledger/main.cpp @@ -0,0 +1,8 @@ +#include + +int +main(int argc, char** argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/src/tests/libxrpl/shamap/ShaMapCostBreakdown.cpp b/src/tests/libxrpl/shamap/ShaMapCostBreakdown.cpp new file mode 100644 index 0000000000..2cf749cce1 --- /dev/null +++ b/src/tests/libxrpl/shamap/ShaMapCostBreakdown.cpp @@ -0,0 +1,417 @@ +// Plan 7 — Phase 0 cost-breakdown benchmark. +// +// Splits per-close SHAMap cost into three buckets so the Phase-1 vs Phase-2 +// build decision rests on measured numbers, not the (corrected) cost model in +// tasks/plan-7-deferred-shamap.md. See tasks/plan-7-quantify.md for the why. +// +// COW = clone allocations on first touch (already deduped today) +// traversal+dirty = descent + setItem/setChild (Phase-1 bulkApply target) +// serial hashing = bottom-up unshare() at close (Phase-2 parallel target) +// +// Gated behind the SHAMAP_BENCH env var so it never runs in normal CI. +// Run with: SHAMAP_BENCH=1 ./xrpl.test.shamap + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +namespace { + +using Clock = std::chrono::steady_clock; +using Ns = std::chrono::nanoseconds; + +[[nodiscard]] bool +benchEnabled() +{ + char const* v = std::getenv("SHAMAP_BENCH"); + return v != nullptr && v[0] != '\0' && v[0] != '0'; +} + +// Fill a uint256 with 32 pseudo-random bytes. State keys on mainnet are +// SHA-512Half digests, i.e. uniformly distributed — random bytes give a +// representative (uniform, depth ~log16 N) tree shape. +[[nodiscard]] uint256 +randomKey(std::mt19937_64& rng) +{ + uint256 k; + auto* p = k.data(); + for (std::size_t i = 0; i < k.size(); i += 8) + { + std::uint64_t const r = rng(); + std::memcpy(p + i, &r, 8); + } + return k; +} + +// A ~128-byte value whose leading bytes encode `salt`, so successive +// replacements of the same key always differ (forcing setItem to re-dirty). +constexpr std::size_t kValueBytes = 128; + +[[nodiscard]] boost::intrusive_ptr +makeItem(uint256 const& key, std::uint64_t salt) +{ + std::array buf{}; + std::memcpy(buf.data(), &salt, sizeof(salt)); + std::memcpy(buf.data() + sizeof(salt), key.data(), 16); + return makeShamapitem(key, Slice(buf.data(), buf.size())); +} + +struct Trial +{ + double tColdNs = 0; // traversal + COW + dirty + double tWarmNs = 0; // traversal + dirty (no COW) + double tHashNs = 0; // serial bottom-up hash recompute (unshare) + double tHashParNs = 0; // updateHashesParallel(kParWorkers) + int dirtyNodes = 0; // nodes processed by the close-time recompute +}; + +constexpr int kParWorkers = 8; + +[[nodiscard]] double +median(std::vector v) +{ + std::sort(v.begin(), v.end()); + return v.empty() ? 0.0 : v[v.size() / 2]; +} + +// One (N, M) measurement: build a base map of N entries, then on fresh +// snapshots time M random replacements (cold), the same again warm, and the +// close-time recompute. +[[nodiscard]] Trial +measure(std::size_t N, std::size_t M, int iters, beast::Journal j) +{ + std::mt19937_64 rng(0xC0FFEEull ^ (N * 1000003ull + M)); + + TestFamily family(j); + auto base = std::make_shared(SHAMapType::STATE, family); + base->setUnbacked(); + + std::vector keys; + keys.reserve(N); + for (std::size_t i = 0; i < N; ++i) + { + uint256 const k = randomKey(rng); + keys.push_back(k); + base->addItem(SHAMapNodeType::TnAccountState, makeItem(k, 0)); + } + base->getHash(); // settle: all base nodes become shared (cowid 0) + + std::uniform_int_distribution pick(0, N - 1); + + std::vector cold, warm, hash, hashPar; + std::vector dirty; + cold.reserve(iters); + warm.reserve(iters); + hash.reserve(iters); + hashPar.reserve(iters); + dirty.reserve(iters); + + for (int it = 0; it < iters; ++it) + { + // Choose M distinct existing keys for this iteration. + std::vector sel; + sel.reserve(M); + { + std::vector seen(N, false); + while (sel.size() < M) + { + std::size_t const idx = pick(rng); + if (!seen[idx]) + { + seen[idx] = true; + sel.push_back(keys[idx]); + } + } + } + + // --- COLD: traversal + COW + dirty, on a fresh snapshot --- + { + auto m = base->snapShot(/*isMutable=*/true); + auto const t0 = Clock::now(); + std::uint64_t salt = 1; + for (auto const& k : sel) + m->updateGiveItem( + SHAMapNodeType::TnAccountState, makeItem(k, salt++)); + auto const t1 = Clock::now(); + cold.push_back( + std::chrono::duration_cast(t1 - t0).count()); + + // --- HASH: the serial recompute getHash() would drive --- + auto const h0 = Clock::now(); + int const flushed = m->unshare(); + auto const h1 = Clock::now(); + hash.push_back( + std::chrono::duration_cast(h1 - h0).count()); + dirty.push_back(flushed); + } + + // --- WARM: traversal + dirty, no COW (nodes already at cowid) --- + { + auto m = base->snapShot(/*isMutable=*/true); + std::uint64_t salt = 1; + for (auto const& k : sel) // warm-up pass clones every path + m->updateGiveItem( + SHAMapNodeType::TnAccountState, makeItem(k, salt++)); + + auto const w0 = Clock::now(); + for (auto const& k : sel) // timed pass: no clones + m->updateGiveItem( + SHAMapNodeType::TnAccountState, makeItem(k, salt++)); + auto const w1 = Clock::now(); + warm.push_back( + std::chrono::duration_cast(w1 - w0).count()); + } + + // --- PARALLEL HASH: the Phase-2 path on a fresh dirty snapshot --- + { + auto m = base->snapShot(/*isMutable=*/true); + std::uint64_t salt = 1; + for (auto const& k : sel) + m->updateGiveItem( + SHAMapNodeType::TnAccountState, makeItem(k, salt++)); + + auto const p0 = Clock::now(); + m->updateHashesParallel(kParWorkers); + auto const p1 = Clock::now(); + hashPar.push_back( + std::chrono::duration_cast(p1 - p0).count()); + } + } + + Trial r; + r.tColdNs = median(cold); + r.tWarmNs = median(warm); + r.tHashNs = median(hash); + r.tHashParNs = median(hashPar); + r.dirtyNodes = dirty.empty() ? 0 : dirty[dirty.size() / 2]; + return r; +} + +void +printRow(std::size_t N, std::size_t M, Trial const& t) +{ + double const cowNs = std::max(0.0, t.tColdNs - t.tWarmNs); + auto us = [](double ns) { return ns / 1000.0; }; + std::cout << std::fixed << std::setprecision(1) << " " << std::setw(9) << N + << std::setw(7) << M << " |" << std::setw(9) << us(t.tWarmNs) + << std::setw(9) << us(cowNs) << std::setw(10) << us(t.tHashNs) + << std::setw(10) << us(t.tHashParNs) << " |" << std::setw(7) + << t.dirtyNodes << std::setw(9) + << (t.tHashParNs > 0 ? t.tHashNs / t.tHashParNs : 0.0) << "x\n"; +} + +// --- Backed-map flush split (Phase-3 sizing) --------------------------------- +// +// The real close path computes the state root via flushDirty() == hash + write +// to the nodestore, in one serial walk — NOT via getHash(). updateHashesParallel +// only parallelizes the hash half; the write half (writeNode → canonicalize is +// serialized by the TreeNodeCache's single mutex) stays serial. So the +// realizable close win is bounded by the hash fraction of flush. +// +// We measure on a BACKED map (memory nodestore, like production) per (N,M): +// flush = flushDirty() — today's serial close cost (hash + write) +// s.hash = unshare() — serial hash only +// p.hash = updateHashesParallel +// Projected Phase-3 close = flush - (s.hash - p.hash) [replace serial hash +// with parallel hash; write half unchanged]. + +struct BackedTrial +{ + double flushNs = 0; + double sHashNs = 0; + double pHashNs = 0; + int dirtyNodes = 0; +}; + +[[nodiscard]] BackedTrial +measureBacked(std::size_t N, std::size_t M, int iters, beast::Journal j) +{ + std::mt19937_64 rng(0xF1A7ull ^ (N * 1000003ull + M)); + + TestFamily family(j); // backed: do NOT call setUnbacked() + auto base = std::make_shared(SHAMapType::STATE, family); + + std::vector keys; + keys.reserve(N); + for (std::size_t i = 0; i < N; ++i) + { + uint256 const k = randomKey(rng); + keys.push_back(k); + base->addItem(SHAMapNodeType::TnAccountState, makeItem(k, 0)); + } + base->flushDirty(NodeObjectType::AccountNode); // settle + persist base + + std::uniform_int_distribution pick(0, N - 1); + std::vector flush, sHash, pHash; + std::vector dirty; + + auto dirtySnapshot = [&](std::vector const& sel) { + auto m = base->snapShot(/*isMutable=*/true); + std::uint64_t salt = 1; + for (auto const& k : sel) + m->updateGiveItem(SHAMapNodeType::TnAccountState, makeItem(k, salt++)); + return m; + }; + + for (int it = 0; it < iters; ++it) + { + std::vector sel; + sel.reserve(M); + std::vector seen(N, false); + while (sel.size() < M) + { + std::size_t const idx = pick(rng); + if (!seen[idx]) + { + seen[idx] = true; + sel.push_back(keys[idx]); + } + } + + { + auto m = dirtySnapshot(sel); + auto const t0 = Clock::now(); + int const f = m->flushDirty(NodeObjectType::AccountNode); + flush.push_back( + std::chrono::duration_cast(Clock::now() - t0).count()); + dirty.push_back(f); + } + { + auto m = dirtySnapshot(sel); + auto const t0 = Clock::now(); + m->unshare(); + sHash.push_back( + std::chrono::duration_cast(Clock::now() - t0).count()); + } + { + auto m = dirtySnapshot(sel); + auto const t0 = Clock::now(); + m->updateHashesParallel(kParWorkers); + pHash.push_back( + std::chrono::duration_cast(Clock::now() - t0).count()); + } + } + + BackedTrial r; + r.flushNs = median(flush); + r.sHashNs = median(sHash); + r.pHashNs = median(pHash); + r.dirtyNodes = dirty.empty() ? 0 : dirty[dirty.size() / 2]; + return r; +} + +void +printBackedRow(std::size_t N, std::size_t M, BackedTrial const& t) +{ + auto us = [](double ns) { return ns / 1000.0; }; + double const projected = std::max(0.0, t.flushNs - (t.sHashNs - t.pHashNs)); + double const hashFrac = t.flushNs > 0 ? t.sHashNs / t.flushNs : 0.0; + std::cout << std::fixed << std::setprecision(1) << " " << std::setw(9) << N + << std::setw(7) << M << " |" << std::setw(10) << us(t.flushNs) + << std::setw(10) << us(t.sHashNs) << std::setw(10) << us(t.pHashNs) + << std::setw(11) << us(projected) << " |" << std::setw(7) + << std::setprecision(0) << (hashFrac * 100) << "%" + << std::setw(8) << std::setprecision(2) + << (projected > 0 ? t.flushNs / projected : 0.0) << "x\n"; +} + +} // namespace + +TEST(ShaMapCostBreakdown, Report) +{ + if (!benchEnabled()) + GTEST_SKIP() << "set SHAMAP_BENCH=1 to run the cost-breakdown benchmark"; + + beast::Journal const j{beast::Journal::getNullSink()}; + + std::cout << "\nPlan 7 Phase-0 — SHAMap per-close cost breakdown\n" + << "(median over iterations; times in microseconds for the whole " + "batch of M replaces)\n\n" + << " N M | travrsl COW s.hash p.hash |" + " dirty speedup\n" + << " -----------------------------------------------------------" + "---------------\n"; + + struct Case + { + std::size_t N; + std::size_t M; + int iters; + }; + std::array const cases{ + {{50'000, 1'000, 7}, + {50'000, 3'000, 7}, + {200'000, 1'000, 5}, + {200'000, 3'000, 5}}}; + + for (auto const& c : cases) + printRow(c.N, c.M, measure(c.N, c.M, c.iters, j)); + + std::cout << "\n travrsl = traversal+dirty (Phase-1 bulkApply ceiling)\n" + " COW = clone allocs (already deduped by cowid today)\n" + " s.hash = serial bottom-up recompute (status quo at close)\n" + " p.hash = updateHashesParallel(" << kParWorkers + << ") (Phase-2)\n" + " speedup = s.hash / p.hash\n\n"; +} + +TEST(ShaMapCostBreakdown, BackedFlush) +{ + if (!benchEnabled()) + GTEST_SKIP() << "set SHAMAP_BENCH=1 to run the cost-breakdown benchmark"; + + beast::Journal const j{beast::Journal::getNullSink()}; + + std::cout << "\nPlan 7 Phase-3 sizing — backed-map flush split\n" + "(real close computes the root via flushDirty = hash + write; " + "memory nodestore)\n\n" + " N M | flush s.hash p.hash projected |" + " hashfr speedup\n" + " --------------------------------------------------------" + "------------------\n"; + + struct Case + { + std::size_t N; + std::size_t M; + int iters; + }; + std::array const cases{ + {{50'000, 1'000, 5}, + {50'000, 3'000, 5}, + {200'000, 1'000, 4}, + {200'000, 3'000, 4}}}; + + for (auto const& c : cases) + printBackedRow(c.N, c.M, measureBacked(c.N, c.M, c.iters, j)); + + std::cout << "\n flush = flushDirty() — today's serial close (hash+write)\n" + " projected = flush - (s.hash - p.hash) [parallel hash, " + "write half unchanged]\n" + " hashfr = s.hash / flush (hash share of close)\n" + " speedup = flush / projected (realizable Phase-3 close lift)\n\n"; +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/shamap/UpdateHashesParallel.cpp b/src/tests/libxrpl/shamap/UpdateHashesParallel.cpp new file mode 100644 index 0000000000..7bf9d01d98 --- /dev/null +++ b/src/tests/libxrpl/shamap/UpdateHashesParallel.cpp @@ -0,0 +1,222 @@ +// Plan 7 Phase-2 — differential test for SHAMap::updateHashesParallel. +// +// The contract: updateHashesParallel(W) must return the *byte-identical* root +// hash that the serial getHash() produces, for any workload and any worker +// count. We assert this against the production serial path on independently +// mutated twin maps, over replace / insert / erase / mixed workloads, many +// randomized seeds, and W in {1,2,4,8,16}. + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +namespace { + +constexpr std::array kWorkerCounts{1, 2, 4, 8, 16}; + +[[nodiscard]] uint256 +randomKey(std::mt19937_64& rng) +{ + uint256 k; + auto* p = k.data(); + for (std::size_t i = 0; i < k.size(); i += 8) + { + std::uint64_t const r = rng(); + std::memcpy(p + i, &r, 8); + } + return k; +} + +[[nodiscard]] boost::intrusive_ptr +makeItem(uint256 const& key, std::uint64_t salt) +{ + std::array buf{}; + std::memcpy(buf.data(), &salt, sizeof(salt)); + std::memcpy(buf.data() + sizeof(salt), key.data(), 16); + return makeShamapitem(key, Slice(buf.data(), buf.size())); +} + +} // namespace + +class UpdateHashesParallel : public ::testing::Test +{ +protected: + TestFamily family_{beast::Journal{beast::Journal::getNullSink()}}; + + // A settled (hashes computed, nodes shared) base map of N random entries. + // Snapshots of it are the mutation targets — each snapshot clones on first + // touch, exactly like a live ledger inheriting its parent's state. + std::shared_ptr + makeBase(std::size_t N, std::uint64_t seed, std::vector& keysOut) + { + std::mt19937_64 rng(seed); + auto base = std::make_shared(SHAMapType::STATE, family_); + base->setUnbacked(); + keysOut.clear(); + keysOut.reserve(N); + for (std::size_t i = 0; i < N; ++i) + { + uint256 const k = randomKey(rng); + keysOut.push_back(k); + base->addItem(SHAMapNodeType::TnAccountState, makeItem(k, 0)); + } + base->getHash(); // settle + return base; + } + + // Assert: for every worker count, a freshly mutated snapshot hashed in + // parallel equals an identically mutated snapshot hashed serially. + template + void + expectParallelMatchesSerial( + std::shared_ptr const& base, + Mutate&& mutate, + char const* label) + { + auto serialMap = base->snapShot(/*isMutable=*/true); + mutate(*serialMap); + SHAMapHash const serial = serialMap->getHash(); + + for (int w : kWorkerCounts) + { + auto parMap = base->snapShot(/*isMutable=*/true); + mutate(*parMap); + SHAMapHash const par = parMap->updateHashesParallel(w); + EXPECT_EQ(serial, par) + << label << " mismatch at workers=" << w; + // A second hash must agree with the cached result it left behind. + EXPECT_EQ(serial, parMap->getHash()) + << label << " post-parallel getHash mismatch at workers=" << w; + } + } +}; + +TEST_F(UpdateHashesParallel, ReplaceWorkload) +{ + std::vector keys; + auto base = makeBase(/*N=*/5000, /*seed=*/0x11, keys); + std::mt19937_64 rng(0xA1); + std::uniform_int_distribution pick(0, keys.size() - 1); + + for (std::size_t M : {1u, 50u, 500u, 2000u}) + { + std::vector sel; + for (std::size_t i = 0; i < M; ++i) + sel.push_back(keys[pick(rng)]); + + expectParallelMatchesSerial( + base, + [&](SHAMap& m) { + std::uint64_t salt = 1; + for (auto const& k : sel) + m.updateGiveItem( + SHAMapNodeType::TnAccountState, makeItem(k, salt++)); + }, + "replace"); + } +} + +TEST_F(UpdateHashesParallel, InsertWorkload) +{ + std::vector keys; + auto base = makeBase(/*N=*/3000, /*seed=*/0x22, keys); + + for (std::size_t M : {1u, 100u, 1500u}) + { + // Distinct fresh keys generated from a fixed seed so both twin + // snapshots receive the identical insert set. + std::mt19937_64 keyRng(0xBEEF + M); + std::vector fresh; + for (std::size_t i = 0; i < M; ++i) + fresh.push_back(randomKey(keyRng)); + + expectParallelMatchesSerial( + base, + [&](SHAMap& m) { + std::uint64_t salt = 1; + for (auto const& k : fresh) + m.addItem( + SHAMapNodeType::TnAccountState, makeItem(k, salt++)); + }, + "insert"); + } +} + +TEST_F(UpdateHashesParallel, EraseWorkload) +{ + std::vector keys; + auto base = makeBase(/*N=*/4000, /*seed=*/0x33, keys); + + for (std::size_t M : {1u, 100u, 1000u}) + { + // Erase the first M keys (a deterministic, distinct subset). + std::vector sel(keys.begin(), keys.begin() + M); + expectParallelMatchesSerial( + base, + [&](SHAMap& m) { + for (auto const& k : sel) + m.delItem(k); + }, + "erase"); + } +} + +TEST_F(UpdateHashesParallel, MixedWorkload) +{ + std::vector keys; + auto base = makeBase(/*N=*/6000, /*seed=*/0x44, keys); + std::mt19937_64 keyRng(0xC0DE); + std::vector fresh; + for (int i = 0; i < 800; ++i) + fresh.push_back(randomKey(keyRng)); + + expectParallelMatchesSerial( + base, + [&](SHAMap& m) { + std::uint64_t salt = 1; + for (std::size_t i = 0; i < 800; ++i) + { + m.updateGiveItem( + SHAMapNodeType::TnAccountState, makeItem(keys[i], salt++)); + m.delItem(keys[3000 + i]); + m.addItem( + SHAMapNodeType::TnAccountState, makeItem(fresh[i], salt++)); + } + }, + "mixed"); +} + +TEST_F(UpdateHashesParallel, NoMutationsAndEmpty) +{ + // Clean snapshot: no dirty nodes, must return the inherited root hash. + std::vector keys; + auto base = makeBase(/*N=*/2000, /*seed=*/0x55, keys); + auto clean = base->snapShot(/*isMutable=*/true); + for (int w : kWorkerCounts) + EXPECT_EQ(base->getHash(), clean->updateHashesParallel(w)); + + // Empty map. + auto empty = std::make_shared(SHAMapType::STATE, family_); + empty->setUnbacked(); + for (int w : kWorkerCounts) + EXPECT_EQ(empty->getHash(), empty->updateHashesParallel(w)); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/shamap/main.cpp b/src/tests/libxrpl/shamap/main.cpp new file mode 100644 index 0000000000..5142bbe08a --- /dev/null +++ b/src/tests/libxrpl/shamap/main.cpp @@ -0,0 +1,8 @@ +#include + +int +main(int argc, char** argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}