mirror of
https://github.com/XRPLF/rippled.git
synced 2026-07-22 22:50:21 +00:00
Compare commits
1 Commits
develop
...
dangell7/f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b48db9471c |
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 {
|
namespace xrpl {
|
||||||
|
|
||||||
|
class FlatStateMap;
|
||||||
class ServiceRegistry;
|
class ServiceRegistry;
|
||||||
class Job;
|
class Job;
|
||||||
class TransactionMaster;
|
class TransactionMaster;
|
||||||
@@ -364,6 +365,38 @@ public:
|
|||||||
std::shared_ptr<SLE>
|
std::shared_ptr<SLE>
|
||||||
peek(Keylet const& k) const;
|
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:
|
private:
|
||||||
class SlesIterImpl;
|
class SlesIterImpl;
|
||||||
class TxsIterImpl;
|
class TxsIterImpl;
|
||||||
@@ -400,6 +433,11 @@ private:
|
|||||||
// A SHAMap containing the state objects for this ledger.
|
// A SHAMap containing the state objects for this ledger.
|
||||||
SHAMap mutable stateMap_;
|
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
|
// Protects fee variables
|
||||||
std::mutex mutable mutex_;
|
std::mutex mutable mutex_;
|
||||||
|
|
||||||
|
|||||||
@@ -178,6 +178,31 @@ public:
|
|||||||
SHAMapHash
|
SHAMapHash
|
||||||
getHash() const;
|
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
|
// save a copy if you have a temporary anyway
|
||||||
bool
|
bool
|
||||||
updateGiveItem(SHAMapNodeType type, boost::intrusive_ptr<SHAMapItem const> item);
|
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/Journal.h>
|
||||||
#include <xrpl/beast/utility/Zero.h>
|
#include <xrpl/beast/utility/Zero.h>
|
||||||
#include <xrpl/beast/utility/instrumentation.h>
|
#include <xrpl/beast/utility/instrumentation.h>
|
||||||
|
#include <xrpl/ledger/FlatStateMap.h>
|
||||||
#include <xrpl/ledger/LedgerTiming.h>
|
#include <xrpl/ledger/LedgerTiming.h>
|
||||||
#include <xrpl/ledger/ReadView.h>
|
#include <xrpl/ledger/ReadView.h>
|
||||||
#include <xrpl/nodestore/NodeObject.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;
|
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)
|
Ledger::Ledger(LedgerHeader const& info, Rules rules, Family& family)
|
||||||
@@ -411,6 +425,14 @@ Ledger::read(Keylet const& k) const
|
|||||||
return nullptr;
|
return nullptr;
|
||||||
// LCOV_EXCL_STOP
|
// 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);
|
auto const& item = stateMap_.peekItem(k.key);
|
||||||
if (!item)
|
if (!item)
|
||||||
return nullptr;
|
return nullptr;
|
||||||
@@ -490,6 +512,8 @@ Ledger::rawErase(std::shared_ptr<SLE> const& sle)
|
|||||||
{
|
{
|
||||||
if (!stateMap_.delItem(sle->key()))
|
if (!stateMap_.delItem(sle->key()))
|
||||||
logicError("Ledger::rawErase: key not found");
|
logicError("Ledger::rawErase: key not found");
|
||||||
|
if (flatStateMap_)
|
||||||
|
mirrorRawErase(*flatStateMap_, sle);
|
||||||
}
|
}
|
||||||
|
|
||||||
void
|
void
|
||||||
@@ -497,6 +521,8 @@ Ledger::rawErase(uint256 const& key)
|
|||||||
{
|
{
|
||||||
if (!stateMap_.delItem(key))
|
if (!stateMap_.delItem(key))
|
||||||
logicError("Ledger::rawErase: key not found");
|
logicError("Ledger::rawErase: key not found");
|
||||||
|
if (flatStateMap_)
|
||||||
|
mirrorRawErase(*flatStateMap_, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
void
|
void
|
||||||
@@ -509,6 +535,8 @@ Ledger::rawInsert(std::shared_ptr<SLE> const& sle)
|
|||||||
{
|
{
|
||||||
logicError("Ledger::rawInsert: key already exists");
|
logicError("Ledger::rawInsert: key already exists");
|
||||||
}
|
}
|
||||||
|
if (flatStateMap_)
|
||||||
|
mirrorRawInsert(*flatStateMap_, sle);
|
||||||
}
|
}
|
||||||
|
|
||||||
void
|
void
|
||||||
@@ -521,6 +549,28 @@ Ledger::rawReplace(std::shared_ptr<SLE> const& sle)
|
|||||||
{
|
{
|
||||||
logicError("Ledger::rawReplace: key not found");
|
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
|
void
|
||||||
|
|||||||
@@ -26,13 +26,17 @@
|
|||||||
|
|
||||||
#include <boost/smart_ptr/intrusive_ptr.hpp>
|
#include <boost/smart_ptr/intrusive_ptr.hpp>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <exception>
|
#include <exception>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
#include <future>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <stack>
|
#include <stack>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
#include <tuple>
|
#include <tuple>
|
||||||
#include <type_traits>
|
#include <type_traits>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
@@ -851,6 +855,98 @@ SHAMap::getHash() const
|
|||||||
return hash;
|
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
|
bool
|
||||||
SHAMap::updateGiveItem(SHAMapNodeType type, boost::intrusive_ptr<SHAMapItem const> item)
|
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)
|
target_link_libraries(xrpl.test.json PRIVATE xrpl.imports.test)
|
||||||
add_dependencies(xrpl.tests xrpl.test.json)
|
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)
|
xrpl_add_test(tx)
|
||||||
target_link_libraries(xrpl.test.tx PRIVATE xrpl.imports.test)
|
target_link_libraries(xrpl.test.tx PRIVATE xrpl.imports.test)
|
||||||
add_dependencies(xrpl.tests xrpl.test.tx)
|
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