feat: FlatMap ShaMap

This commit is contained in:
Denis Angell
2026-05-30 05:33:46 +02:00
parent 2f3558c610
commit b48db9471c
16 changed files with 4425 additions and 0 deletions

View File

@@ -0,0 +1,229 @@
#pragma once
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/hardened_hash.h>
#include <array>
#include <cstdint>
#include <functional>
#include <unordered_map>
#include <vector>
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<AffectedNode>
planDeferredRebuild(std::vector<uint256> 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<uint256, 16> 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<std::size_t>(n.depth) << 56);
}
};
/** Map of recomputed inner-node hashes keyed by (depth, prefix). */
using RebuildResult =
std::unordered_map<AffectedNode, uint256, AffectedNodeHash>;
/** 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 <typename GetChildHashFn>
[[nodiscard]] RebuildResult
executeRebuildPlan(
std::vector<AffectedNode> const& plan,
GetChildHashFn getOriginalChildHash);
namespace detail {
// Forwarder for template instantiation; declared here, defined in .cpp.
[[nodiscard]] RebuildResult
executeRebuildPlanImpl(
std::vector<AffectedNode> const& plan,
std::function<uint256(int, uint256 const&)> getOriginalChildHash);
} // namespace detail
template <typename GetChildHashFn>
[[nodiscard]] RebuildResult
executeRebuildPlan(
std::vector<AffectedNode> const& plan,
GetChildHashFn getOriginalChildHash)
{
return detail::executeRebuildPlanImpl(
plan,
std::function<uint256(int, uint256 const&)>(
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 <typename GetChildHashFn>
[[nodiscard]] uint256
deferredRebuildRoot(
std::vector<uint256> 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<std::vector<uint256>, 16>
partitionByFirstNibble(std::vector<uint256> const& modifiedKeys);
namespace detail {
[[nodiscard]] uint256
deferredRebuildRootParallelImpl(
std::vector<uint256> const& modifiedKeys,
std::function<uint256(int, uint256 const&)> 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 <typename GetChildHashFn>
[[nodiscard]] uint256
deferredRebuildRootParallel(
std::vector<uint256> const& modifiedKeys,
GetChildHashFn getOriginalChildHash)
{
return detail::deferredRebuildRootParallelImpl(
modifiedKeys,
std::function<uint256(int, uint256 const&)>(
std::move(getOriginalChildHash)));
}
} // namespace xrpl

View File

@@ -0,0 +1,347 @@
#pragma once
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/hardened_hash.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <cstddef>
#include <iterator>
#include <memory>
#include <shared_mutex>
#include <unordered_map>
#include <unordered_set>
#include <vector>
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: ~610 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<STLedgerEntry const>;
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(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 ~100200 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<FlatStateMap>
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 <typename F>
void
forEach(F&& visitor) const
{
std::shared_lock<std::shared_mutex> lock(mutex_);
for (auto const& [key, sle] : map_)
visitor(key, sle);
}
private:
using HashFn = HardenedHash<>;
using MapType = std::unordered_map<key_type, value_type, HashFn>;
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<SLE const>`.
Lower-level building block underlying `populateFromReadView`. Useful
in tests (the range can be a `std::vector<shared_ptr<SLE const>>`)
and in non-ReadView contexts (e.g., reloading a persisted flat-map
sidecar at startup).
The range element type must be `shared_ptr<SLE const>` (or
implicitly convertible). Each element's `.key()` becomes the
FlatStateMap key.
*/
template <typename Range>
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<STLedgerEntry const> sle);
void
mirrorRawReplace(FlatStateMap& map, std::shared_ptr<STLedgerEntry const> sle);
void
mirrorRawErase(FlatStateMap& map, std::shared_ptr<STLedgerEntry const> 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<STLedgerEntry const>
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<uint256> missingFromFlat;
std::vector<uint256> extraInFlat;
};
template <typename SourceRange>
[[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<uint256, HardenedHash<>> seen;
seen.reserve(static_cast<std::size_t>(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 <typename SourceRange>
[[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 <typename ShaMapLike>
[[nodiscard]] bool
flatStateMapMatchesShaMap(FlatStateMap const& flat, ShaMapLike const& shaMap)
{
std::vector<uint256> 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

View File

@@ -14,6 +14,7 @@
namespace xrpl {
class FlatStateMap;
class ServiceRegistry;
class Job;
class TransactionMaster;
@@ -364,6 +365,38 @@ public:
std::shared_ptr<SLE>
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<FlatStateMap> map);
[[nodiscard]] std::shared_ptr<FlatStateMap>
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<FlatStateMap> mutable flatStateMap_;
// Protects fee variables
std::mutex mutable mutex_;

View File

@@ -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<SHAMapItem const> item);

View File

@@ -0,0 +1,329 @@
#include <xrpl/ledger/DeferredRebuild.h>
#include <xrpl/basics/Slice.h>
#include <xrpl/basics/hardened_hash.h>
#include <xrpl/protocol/HashPrefix.h>
#include <xrpl/protocol/digest.h>
#include <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <future>
#include <mutex>
#include <unordered_set>
#include <utility>
#include <vector>
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<AffectedNode>
planDeferredRebuild(std::vector<uint256> 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<uint256> sorted = modifiedKeys;
std::sort(sorted.begin(), sorted.end());
std::vector<AffectedNode> 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<std::uint8_t>((branch & 0x0F) << 4);
else
result.data()[byteIdx] =
(result.data()[byteIdx] & 0xF0) |
static_cast<std::uint8_t>(branch & 0x0F);
return result;
}
} // namespace
namespace detail {
RebuildResult
executeRebuildPlanImpl(
std::vector<AffectedNode> const& plan,
std::function<uint256(int, uint256 const&)> 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<uint256, 16> 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<std::vector<uint256>, 16>
partitionByFirstNibble(std::vector<uint256> const& modifiedKeys)
{
std::array<std::vector<uint256>, 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<uint256> const& modifiedKeys,
std::function<uint256(int, uint256 const&)> 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<std::future<uint256>, 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<std::uint8_t>(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<uint256, 16> 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<std::uint8_t>(b << 4);
rootChildren[b] = getOriginalChildHash(1, prefix);
}
else
{
rootChildren[b] = futures[b].get();
}
}
return computeInnerNodeHash(rootChildren);
}
} // namespace detail
uint256
computeInnerNodeHash(std::array<uint256, 16> 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<std::uint8_t, kBufSize> buf{};
auto const prefix = static_cast<std::uint32_t>(HashPrefix::InnerNode);
buf[0] = static_cast<std::uint8_t>(prefix >> 24);
buf[1] = static_cast<std::uint8_t>(prefix >> 16);
buf[2] = static_cast<std::uint8_t>(prefix >> 8);
buf[3] = static_cast<std::uint8_t>(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

View File

@@ -0,0 +1,130 @@
#include <xrpl/ledger/FlatStateMap.h>
#include <xrpl/ledger/Ledger.h>
#include <xrpl/ledger/ReadView.h>
#include <memory>
#include <mutex>
#include <shared_mutex>
#include <utility>
namespace xrpl {
FlatStateMap::value_type
FlatStateMap::read(key_type const& key) const
{
std::shared_lock<std::shared_mutex> 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<std::shared_mutex> lock(mutex_);
return map_.find(key) != map_.end();
}
void
FlatStateMap::insert(key_type const& key, value_type sle)
{
std::unique_lock<std::shared_mutex> lock(mutex_);
map_.insert_or_assign(key, std::move(sle));
}
void
FlatStateMap::erase(key_type const& key)
{
std::unique_lock<std::shared_mutex> lock(mutex_);
map_.erase(key);
}
std::size_t
FlatStateMap::size() const
{
std::shared_lock<std::shared_mutex> lock(mutex_);
return map_.size();
}
bool
FlatStateMap::empty() const
{
std::shared_lock<std::shared_mutex> lock(mutex_);
return map_.empty();
}
void
FlatStateMap::clear()
{
std::unique_lock<std::shared_mutex> lock(mutex_);
map_.clear();
}
std::unique_ptr<FlatStateMap>
FlatStateMap::snapshot() const
{
auto out = std::make_unique<FlatStateMap>();
std::shared_lock<std::shared_mutex> 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<FlatStateMap>();
populateFromReadView(*map, ledger);
ledger.setFlatStateMap(std::move(map));
}
void
mirrorRawInsert(FlatStateMap& map, std::shared_ptr<STLedgerEntry const> sle)
{
auto const key = sle->key();
map.insert(key, std::move(sle));
}
void
mirrorRawReplace(FlatStateMap& map, std::shared_ptr<STLedgerEntry const> sle)
{
auto const key = sle->key();
map.insert(key, std::move(sle)); // insert == insert_or_assign here
}
void
mirrorRawErase(
FlatStateMap& map,
std::shared_ptr<STLedgerEntry const> const& sle)
{
map.erase(sle->key());
}
void
mirrorRawErase(FlatStateMap& map, uint256 const& key)
{
map.erase(key);
}
std::shared_ptr<STLedgerEntry const>
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

View File

@@ -9,6 +9,7 @@
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/FlatStateMap.h>
#include <xrpl/ledger/LedgerTiming.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/nodestore/NodeObject.h>
@@ -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<SLE> 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<SLE> const& sle)
{
logicError("Ledger::rawInsert: key already exists");
}
if (flatStateMap_)
mirrorRawInsert(*flatStateMap_, sle);
}
void
@@ -521,6 +549,28 @@ Ledger::rawReplace(std::shared_ptr<SLE> const& sle)
{
logicError("Ledger::rawReplace: key not found");
}
if (flatStateMap_)
mirrorRawReplace(*flatStateMap_, sle);
}
void
Ledger::setFlatStateMap(std::shared_ptr<FlatStateMap> map)
{
flatStateMap_ = std::move(map);
}
std::shared_ptr<FlatStateMap>
Ledger::flatStateMap() const
{
return flatStateMap_;
}
bool
Ledger::validateFlatStateMapMatchesShaMap() const
{
if (!flatStateMap_)
return true; // nothing to validate against
return flatStateMapMatchesShaMap(*flatStateMap_, stateMap_);
}
void

View File

@@ -26,13 +26,17 @@
#include <boost/smart_ptr/intrusive_ptr.hpp>
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <exception>
#include <functional>
#include <future>
#include <memory>
#include <stack>
#include <stdexcept>
#include <string>
#include <thread>
#include <tuple>
#include <type_traits>
#include <utility>
@@ -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<SHAMapInnerNode*>(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<SHAMapInnerNode*>(root_.get());
// Gather the root's dirty, resident top-level subtrees. These are
// independent and can be recomputed concurrently.
std::vector<SHAMapTreeNode*> 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<int>(std::thread::hardware_concurrency());
if (workers <= 1 || subtrees.size() <= 1)
{
for (auto* s : subtrees)
recomputeSubtreeHashes(s);
}
else
{
int const nthreads = std::min<int>(workers, static_cast<int>(subtrees.size()));
std::atomic<std::size_t> next{0};
std::vector<std::future<void>> 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<SHAMapItem const> item)
{

View File

@@ -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)

View File

@@ -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 <xrpl/basics/base_uint.h>
#include <xrpl/basics/SHAMapHash.h>
#include <xrpl/basics/Slice.h>
#include <xrpl/ledger/DeferredRebuild.h>
#include <xrpl/shamap/SHAMapInnerNode.h>
#include <gtest/gtest.h>
#include <algorithm>
#include <array>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <vector>
using namespace xrpl;
namespace {
// Inhibit dead-code elimination in benchmark loops.
template <typename T>
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<std::uint8_t> 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<std::uint8_t>(
(prefixNibbles[i] & 0x0F) << shift);
}
return k;
}
} // namespace
// ---------------------------------------------------------------------------
// Empty + single-key shape
// ---------------------------------------------------------------------------
TEST(DeferredRebuild_Plan, EmptyKeySetYieldsEmptyPlan)
{
std::vector<uint256> 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<uint256> 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<uint256> 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<uint256> 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<uint256> 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<std::chrono::microseconds>(elapsed).count();
std::printf(
" planDeferredRebuild N=%zu modified keys : %lld µs (plan size %zu)\n",
N,
static_cast<long long>(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<uint256> 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<std::chrono::microseconds>(elapsed).count();
std::printf(
" planDeferredRebuild N=%zu modified keys : %lld µs (plan size %zu)\n",
N,
static_cast<long long>(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<uint256> 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<std::uint8_t>
packChildHashes(std::array<uint256, 16> const& children)
{
std::vector<std::uint8_t> 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<uint256, 16> 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<uint256, 16> children{}; // all zero
auto const oracle = oracleHash(children);
auto const ours = computeInnerNodeHash(children);
EXPECT_EQ(ours, oracle);
}
TEST(DeferredRebuild_InnerHash, SingleChildMatchesOracle)
{
std::array<uint256, 16> 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<uint256, 16> 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<uint256, 16> a;
for (std::size_t i = 0; i < 16; ++i)
a[i] = uint256{0x200ULL + i};
std::array<uint256, 16> 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<uint256, 16> 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<uint256, 16> 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<std::uint8_t>((branch & 0x0F) << 4);
else
result.data()[byteIdx] =
(result.data()[byteIdx] & 0xF0) |
static_cast<std::uint8_t>(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<AffectedNode> 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<AffectedNode> 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<uint256, 16> 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<AffectedNode> 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<uint256, 16> 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<uint256, 16> 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<AffectedNode> 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<std::uint64_t>(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<uint256> keys{keyOf(42)};
auto const parentLookup = [](int depth, uint256 const& prefix) {
std::uint64_t v = static_cast<std::uint64_t>(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<uint256> 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<std::uint64_t>(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<uint256> keys{keyOf(1), keyOf(2), keyOf(3)};
auto const parentLookup = [](int depth, uint256 const& prefix) {
return uint256{
static_cast<std::uint64_t>(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<uint256> keys{keyOf(42)};
auto const parentLookup = [](int depth, uint256 const& prefix) {
std::uint64_t v = static_cast<std::uint64_t>(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<uint256> 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<std::uint8_t>(n)});
// Mix in some entropy for nibbles 1+
for (int i = 1; i < 8; ++i)
k.data()[i / 2] ^= static_cast<std::uint8_t>(j * 0x37 + i);
keys.push_back(k);
}
}
auto const parentLookup = [](int depth, uint256 const& prefix) {
std::uint64_t v = static_cast<std::uint64_t>(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<uint256> 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<std::uint8_t>(j * 0x11 + i);
keys.push_back(k);
}
auto const parentLookup = [](int depth, uint256 const& prefix) {
std::uint64_t v = static_cast<std::uint64_t>(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<uint256> 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<std::uint64_t>(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<std::chrono::microseconds>(elapsed).count();
std::printf(
" deferredRebuildRoot N=%zu keys : %lld µs total\n",
N,
static_cast<long long>(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<uint256> 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<std::uint8_t>((i % 16) << 4);
keys.push_back(k);
}
auto const parentLookup = [](int depth, uint256 const& prefix) {
return uint256{
static_cast<std::uint64_t>(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<std::chrono::microseconds>(t1 - t0).count();
auto const parallelUs =
std::chrono::duration_cast<std::chrono::microseconds>(t2 - t1).count();
std::printf(
" serial : %lld µs\n"
" parallel : %lld µs (speedup %.2fx)\n",
static_cast<long long>(serialUs),
static_cast<long long>(parallelUs),
static_cast<double>(serialUs) / std::max<long long>(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";
}

View File

@@ -0,0 +1,995 @@
#include <xrpl/basics/base_uint.h>
#include <xrpl/ledger/FlatStateMap.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <gtest/gtest.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <memory>
#include <random>
#include <thread>
#include <vector>
using namespace xrpl;
namespace {
// Construct a synthetic SLE for testing. The contents are not meaningful —
// we only need a distinct shared_ptr<STLedgerEntry const> per key to exercise
// the map's storage and retrieval semantics.
[[nodiscard]] std::shared_ptr<STLedgerEntry const>
makeSle(std::uint64_t keyValue)
{
uint256 key{keyValue};
return std::make_shared<STLedgerEntry const>(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<std::shared_ptr<STLedgerEntry const>> 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<FlatStateMap>.
// ---------------------------------------------------------------------------
TEST(FlatStateMap, UniquePtrOwnershipTransfer)
{
auto a = std::make_unique<FlatStateMap>();
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<std::shared_ptr<STLedgerEntry const>> 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<std::shared_ptr<STLedgerEntry const>> 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<std::shared_ptr<STLedgerEntry const>> 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<uint256> 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<uint256> 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<uint256> 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<uint256> 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<uint256> 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<uint256> 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<MockShaMapItem> shaMap;
EXPECT_TRUE(flatStateMapMatchesShaMap(flat, shaMap));
}
TEST(FlatStateMap_ShaMapAdapter, IdenticalContentsMatch)
{
FlatStateMap flat;
std::vector<MockShaMapItem> 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<MockShaMapItem> 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<MockShaMapItem> 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 <typename T>
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 <typename Fn>
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<std::chrono::nanoseconds>(t1 - t0).count();
return {static_cast<double>(elapsedNs) / static_cast<double>(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<std::size_t> sizes{1'000, 10'000, 100'000};
std::vector<double> 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<uint256> 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<Keylet> 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<std::shared_ptr<STLedgerEntry const>> 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<std::chrono::milliseconds>(elapsed).count();
auto const nsPerEntry =
std::chrono::duration_cast<std::chrono::nanoseconds>(elapsed).count() /
static_cast<double>(N);
std::printf(
" FlatStateMap::snapshot N=%zu : %lld ms total (%.1f ns/entry)\n",
N,
static_cast<long long>(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<uint256> 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<std::chrono::milliseconds>(elapsed).count();
auto const nsPerKey =
std::chrono::duration_cast<std::chrono::nanoseconds>(elapsed).count() /
static_cast<double>(N);
std::printf(
" flatStateMapMatches N=%zu : %lld ms total (%.1f ns/key)\n",
N,
static_cast<long long>(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<std::shared_ptr<STLedgerEntry const>> 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<bool> stop{false};
std::atomic<std::uint64_t> 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<std::thread> 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
}

View File

@@ -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 <helpers/TestFamily.h>
#include <xrpl/basics/UnorderedContainers.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/DeferredRebuild.h>
#include <xrpl/ledger/FlatStateMap.h>
#include <xrpl/ledger/Ledger.h>
#include <xrpl/protocol/Fees.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/Rules.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/shamap/SHAMap.h>
#include <gtest/gtest.h>
#include <memory>
#include <unordered_set>
#include <vector>
namespace xrpl::test {
class FlatStateMapIntegration : public ::testing::Test
{
protected:
TestFamily family_{beast::Journal{beast::Journal::getNullSink()}};
[[nodiscard]] std::shared_ptr<Ledger>
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<uint256, beast::Uhash<>>{}};
Fees const fees{XRPAmount{10}, XRPAmount{10'000'000}, XRPAmount{2'000'000}};
std::vector<uint256> const amendments;
return std::make_shared<Ledger>(
kCreateGenesis, rules, fees, amendments, family_);
}
[[nodiscard]] std::shared_ptr<Ledger>
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<Ledger>(
*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<std::shared_ptr<SLE const>> 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<SLE>
makeTestSle(std::uint64_t keyValue)
{
uint256 const key{keyValue};
return std::make_shared<SLE>(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<std::shared_ptr<SLE>> 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<Ledger>(
*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<Ledger>(
*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<Ledger>(
*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<Ledger>(
*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<Ledger>(
*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<std::shared_ptr<SLE>> 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<std::string> 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<std::shared_ptr<SLE>> 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<std::uint8_t>(i << 4);
sles.push_back(std::make_shared<SLE>(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<uint256> modKeys;
for (auto const& s : sles)
modKeys.push_back(s->key());
// Pre-compute each leaf's SHAMap hash for the callback.
std::unordered_map<uint256, uint256, beast::Uhash<>> 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

View File

@@ -0,0 +1,8 @@
#include <gtest/gtest.h>
int
main(int argc, char** argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View File

@@ -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 <helpers/TestFamily.h>
#include <xrpl/basics/Slice.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/nodestore/NodeObject.h>
#include <xrpl/shamap/SHAMap.h>
#include <xrpl/shamap/SHAMapItem.h>
#include <xrpl/shamap/SHAMapMissingNode.h>
#include <xrpl/shamap/SHAMapTreeNode.h>
#include <gtest/gtest.h>
#include <algorithm>
#include <array>
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <iomanip>
#include <iostream>
#include <random>
#include <vector>
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<SHAMapItem const>
makeItem(uint256 const& key, std::uint64_t salt)
{
std::array<std::uint8_t, kValueBytes> 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<double> 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<SHAMap>(SHAMapType::STATE, family);
base->setUnbacked();
std::vector<uint256> 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<std::size_t> pick(0, N - 1);
std::vector<double> cold, warm, hash, hashPar;
std::vector<int> 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<uint256> sel;
sel.reserve(M);
{
std::vector<bool> 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<Ns>(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<Ns>(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<Ns>(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<Ns>(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<SHAMap>(SHAMapType::STATE, family);
std::vector<uint256> 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<std::size_t> pick(0, N - 1);
std::vector<double> flush, sHash, pHash;
std::vector<int> dirty;
auto dirtySnapshot = [&](std::vector<uint256> 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<uint256> sel;
sel.reserve(M);
std::vector<bool> 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<Ns>(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<Ns>(Clock::now() - t0).count());
}
{
auto m = dirtySnapshot(sel);
auto const t0 = Clock::now();
m->updateHashesParallel(kParWorkers);
pHash.push_back(
std::chrono::duration_cast<Ns>(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<Case, 4> 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<Case, 4> 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

View File

@@ -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 <helpers/TestFamily.h>
#include <xrpl/basics/Slice.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/shamap/SHAMap.h>
#include <xrpl/shamap/SHAMapItem.h>
#include <xrpl/shamap/SHAMapMissingNode.h>
#include <xrpl/shamap/SHAMapTreeNode.h>
#include <gtest/gtest.h>
#include <array>
#include <cstdint>
#include <cstring>
#include <memory>
#include <random>
#include <vector>
namespace xrpl::test {
namespace {
constexpr std::array<int, 5> 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<SHAMapItem const>
makeItem(uint256 const& key, std::uint64_t salt)
{
std::array<std::uint8_t, 96> 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<SHAMap>
makeBase(std::size_t N, std::uint64_t seed, std::vector<uint256>& keysOut)
{
std::mt19937_64 rng(seed);
auto base = std::make_shared<SHAMap>(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 <class Mutate>
void
expectParallelMatchesSerial(
std::shared_ptr<SHAMap> 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<uint256> keys;
auto base = makeBase(/*N=*/5000, /*seed=*/0x11, keys);
std::mt19937_64 rng(0xA1);
std::uniform_int_distribution<std::size_t> pick(0, keys.size() - 1);
for (std::size_t M : {1u, 50u, 500u, 2000u})
{
std::vector<uint256> 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<uint256> 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<uint256> 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<uint256> 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<uint256> 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<uint256> keys;
auto base = makeBase(/*N=*/6000, /*seed=*/0x44, keys);
std::mt19937_64 keyRng(0xC0DE);
std::vector<uint256> 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<uint256> 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<SHAMap>(SHAMapType::STATE, family_);
empty->setUnbacked();
for (int w : kWorkerCounts)
EXPECT_EQ(empty->getHash(), empty->updateHashesParallel(w));
}
} // namespace xrpl::test

View File

@@ -0,0 +1,8 @@
#include <gtest/gtest.h>
int
main(int argc, char** argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}