mirror of
https://github.com/XRPLF/rippled.git
synced 2026-06-02 16:26:48 +00:00
feat: FlatMap ShaMap
This commit is contained in:
229
include/xrpl/ledger/DeferredRebuild.h
Normal file
229
include/xrpl/ledger/DeferredRebuild.h
Normal 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
|
||||
347
include/xrpl/ledger/FlatStateMap.h
Normal file
347
include/xrpl/ledger/FlatStateMap.h
Normal 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: ~6–10 spinlocked inner-node
|
||||
fetches per `read(Keylet)`.
|
||||
|
||||
`FlatStateMap` materializes the same keylet → SLE mapping into a
|
||||
flat hash table. Once populated, lookups are a single
|
||||
`unordered_map::find()` — nanoseconds, not microseconds.
|
||||
|
||||
This is the 2-writes-for-1-read pattern (Plan 6):
|
||||
* On apply, the apply path dual-writes to SHAMap and FlatStateMap.
|
||||
* On read, only FlatStateMap is consulted.
|
||||
|
||||
The flat map is purely auxiliary. The SHAMap remains authoritative,
|
||||
can always be rebuilt from the underlying NodeStore, and is what the
|
||||
network's state-root commitment is computed from. If FlatStateMap is
|
||||
wrong, the differential invariant check at ledger close (Plan 6 P6.5)
|
||||
catches it; there is **no runtime fallback to SHAMap descent on
|
||||
miss** — a miss is a bug.
|
||||
|
||||
Thread-safe via a shared_mutex. Concurrent reads do not block each
|
||||
other; writes are exclusive. Future phases may replace this with
|
||||
a lock-free atomic-pointer-swap design.
|
||||
*/
|
||||
class FlatStateMap
|
||||
{
|
||||
public:
|
||||
using key_type = uint256;
|
||||
using value_type = std::shared_ptr<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 ~100–200 ms; expensive enough that snapshots should be
|
||||
per-ledger-close, not per-transaction.
|
||||
|
||||
A future phase will replace this with a persistent / HAMT
|
||||
structure that gives O(log N) snapshot and structural sharing
|
||||
across versions.
|
||||
*/
|
||||
[[nodiscard]] std::unique_ptr<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
|
||||
@@ -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_;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
329
src/libxrpl/ledger/DeferredRebuild.cpp
Normal file
329
src/libxrpl/ledger/DeferredRebuild.cpp
Normal 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
|
||||
130
src/libxrpl/ledger/FlatStateMap.cpp
Normal file
130
src/libxrpl/ledger/FlatStateMap.cpp
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
927
src/tests/libxrpl/ledger/DeferredRebuild.cpp
Normal file
927
src/tests/libxrpl/ledger/DeferredRebuild.cpp
Normal 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";
|
||||
}
|
||||
995
src/tests/libxrpl/ledger/FlatStateMap.cpp
Normal file
995
src/tests/libxrpl/ledger/FlatStateMap.cpp
Normal 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
|
||||
}
|
||||
596
src/tests/libxrpl/ledger/FlatStateMapIntegration.cpp
Normal file
596
src/tests/libxrpl/ledger/FlatStateMapIntegration.cpp
Normal 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
|
||||
8
src/tests/libxrpl/ledger/main.cpp
Normal file
8
src/tests/libxrpl/ledger/main.cpp
Normal file
@@ -0,0 +1,8 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
int
|
||||
main(int argc, char** argv)
|
||||
{
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
417
src/tests/libxrpl/shamap/ShaMapCostBreakdown.cpp
Normal file
417
src/tests/libxrpl/shamap/ShaMapCostBreakdown.cpp
Normal 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
|
||||
222
src/tests/libxrpl/shamap/UpdateHashesParallel.cpp
Normal file
222
src/tests/libxrpl/shamap/UpdateHashesParallel.cpp
Normal 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
|
||||
8
src/tests/libxrpl/shamap/main.cpp
Normal file
8
src/tests/libxrpl/shamap/main.cpp
Normal file
@@ -0,0 +1,8 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
int
|
||||
main(int argc, char** argv)
|
||||
{
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
Reference in New Issue
Block a user