mirror of
https://github.com/XRPLF/rippled.git
synced 2026-10-16 00:28:22 +00:00
Compare commits
1 Commits
mvadari/re
...
dangell7/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1daf950ea |
@@ -1,7 +1,9 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <xrpl/ledger/OrderBookIndex.h>
|
||||||
#include <xrpl/ledger/RawView.h>
|
#include <xrpl/ledger/RawView.h>
|
||||||
#include <xrpl/ledger/ReadView.h>
|
#include <xrpl/ledger/ReadView.h>
|
||||||
|
#include <xrpl/ledger/TopOfBookCache.h>
|
||||||
#include <xrpl/ledger/detail/RawStateTable.h>
|
#include <xrpl/ledger/detail/RawStateTable.h>
|
||||||
#include <xrpl/protocol/STArray.h>
|
#include <xrpl/protocol/STArray.h>
|
||||||
#include <xrpl/protocol/XRPAmount.h>
|
#include <xrpl/protocol/XRPAmount.h>
|
||||||
@@ -89,6 +91,17 @@ private:
|
|||||||
|
|
||||||
bool open_ = true;
|
bool open_ = true;
|
||||||
|
|
||||||
|
// Per-view top-of-book cache. Lifetime is the view's lifetime; on
|
||||||
|
// OpenView copy (used to snapshot for parallel apply / batch views),
|
||||||
|
// the underlying data is copied but counters reset.
|
||||||
|
mutable TopOfBookCache topOfBookCache_;
|
||||||
|
|
||||||
|
// Per-view ordered order-book index (Plan 9). Generalizes the cache from
|
||||||
|
// "best page" to the full quality-ordered offer sequence, letting the
|
||||||
|
// crossing path iterate via an in-memory cursor instead of re-walking the
|
||||||
|
// SHAMap with succ() per offer. Maintained off the same notifications.
|
||||||
|
mutable OrderBookIndex orderBookIndex_;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
OpenView() = delete;
|
OpenView() = delete;
|
||||||
OpenView&
|
OpenView&
|
||||||
@@ -200,6 +213,46 @@ public:
|
|||||||
std::shared_ptr<SLE const>
|
std::shared_ptr<SLE const>
|
||||||
read(Keylet const& k) const override;
|
read(Keylet const& k) const override;
|
||||||
|
|
||||||
|
// Top-of-book cache hooks
|
||||||
|
|
||||||
|
[[nodiscard]] std::optional<uint256>
|
||||||
|
topOfBookFirstPage(Book const& book) const override;
|
||||||
|
|
||||||
|
void
|
||||||
|
recordTopOfBook(Book const& book, uint256 const& firstPageKey) const override;
|
||||||
|
|
||||||
|
void
|
||||||
|
notifyOfferInserted(Book const& book, uint256 const& dirKey, uint256 const& offerKey)
|
||||||
|
const override;
|
||||||
|
|
||||||
|
void
|
||||||
|
notifyOfferDeleted(Book const& book, uint256 const& dirKey, uint256 const& offerKey)
|
||||||
|
const override;
|
||||||
|
|
||||||
|
[[nodiscard]] std::optional<std::vector<uint256>>
|
||||||
|
orderedBook(Book const& book) const override;
|
||||||
|
|
||||||
|
[[nodiscard]] TopOfBookCache const&
|
||||||
|
topOfBookCache() const noexcept
|
||||||
|
{
|
||||||
|
return topOfBookCache_;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] OrderBookIndex const&
|
||||||
|
orderBookIndex() const noexcept
|
||||||
|
{
|
||||||
|
return orderBookIndex_;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-const access for seeding (rebuild-from-state at attach time) and for
|
||||||
|
// the cursor's lazy populate. The index is auxiliary, so this never affects
|
||||||
|
// the authoritative state.
|
||||||
|
[[nodiscard]] OrderBookIndex&
|
||||||
|
orderBookIndex() noexcept
|
||||||
|
{
|
||||||
|
return orderBookIndex_;
|
||||||
|
}
|
||||||
|
|
||||||
std::unique_ptr<SlesType::iter_base>
|
std::unique_ptr<SlesType::iter_base>
|
||||||
slesBegin() const override;
|
slesBegin() const override;
|
||||||
|
|
||||||
|
|||||||
181
include/xrpl/ledger/OrderBookIndex.h
Normal file
181
include/xrpl/ledger/OrderBookIndex.h
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <xrpl/basics/base_uint.h>
|
||||||
|
#include <xrpl/ledger/detail/PersistentOrderTree.h>
|
||||||
|
#include <xrpl/protocol/Book.h>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <optional>
|
||||||
|
#include <shared_mutex>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace xrpl {
|
||||||
|
|
||||||
|
class ReadView;
|
||||||
|
|
||||||
|
/** Deterministic, ordered, **persistent** in-memory index of every active order
|
||||||
|
book.
|
||||||
|
|
||||||
|
`BookTip::step()` finds the next offer to cross by calling `ReadView::succ()`
|
||||||
|
— an O(log N) SHAMap successor walk from the book root, re-done once per
|
||||||
|
consumed offer. Profiling shows that walk is ~32% of crossing-apply cost.
|
||||||
|
This index materializes the same quality-ordered offer sequence so iteration
|
||||||
|
becomes an in-memory cursor advance instead of a trie re-walk.
|
||||||
|
|
||||||
|
It generalizes `TopOfBookCache` from "the best directory page" to "the full
|
||||||
|
ordered book". Like `FlatStateMap`, it is **auxiliary**: the SHAMap remains
|
||||||
|
the authoritative state and the source of the consensus root. The index is
|
||||||
|
rebuildable from the SHAMap at any time (`rebuildBook`) and differentially
|
||||||
|
validated against it (`validateMatchesShaMap`); a divergence is a bug in the
|
||||||
|
maintenance hooks, never a fallback.
|
||||||
|
|
||||||
|
**Persistence.** Each book's offers live in an immutable, structurally-shared
|
||||||
|
weight-balanced tree ([[detail/PersistentOrderTree.h]]). `clone()` copies only
|
||||||
|
the per-book `shared_ptr` roots (O(#books)), not the offers — so the
|
||||||
|
open-ledger copy-on-write (`OpenView` copy per `modify()`) preserves the index
|
||||||
|
cheaply and it stays warm across transactions, instead of cold-starting and
|
||||||
|
rebuilding per tx. Immutable nodes also make the COW rollback of a discarded
|
||||||
|
sandbox free: it simply drops its own root pointers.
|
||||||
|
|
||||||
|
Ordering invariant (the load-bearing property for bit-exact crossing):
|
||||||
|
|
||||||
|
- Books are keyed by `Book` (which already carries the permissioned-DEX
|
||||||
|
`domain`), so each book — open or domain — is indexed independently.
|
||||||
|
- Within a book, the tree is keyed by `(dirRoot, insertSeq)`. `dirRoot` is
|
||||||
|
the quality-directory root key; ascending == best-quality-first ==
|
||||||
|
`succ()` order. `insertSeq` is a per-book monotonic counter capturing
|
||||||
|
directory append order; since `dirRemove` preserves relative order and
|
||||||
|
offer keys are never reused, in-order traversal reproduces the SHAMap
|
||||||
|
directory walk byte-for-byte.
|
||||||
|
|
||||||
|
Maintenance drives `insertOffer`/`deleteOffer` from the offer-mutation
|
||||||
|
notifications (`notifyOfferInserted`/`notifyOfferDeleted`), which fire with
|
||||||
|
the quality-directory root key and the offer key.
|
||||||
|
*/
|
||||||
|
class OrderBookIndex
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
OrderBookIndex() = default;
|
||||||
|
|
||||||
|
/** Move-construct by locking the source and stealing its book map.
|
||||||
|
Counters are not transferred (a fresh view starts its own accounting). */
|
||||||
|
OrderBookIndex(OrderBookIndex&& other);
|
||||||
|
|
||||||
|
OrderBookIndex(OrderBookIndex const&) = delete;
|
||||||
|
OrderBookIndex&
|
||||||
|
operator=(OrderBookIndex const&) = delete;
|
||||||
|
OrderBookIndex&
|
||||||
|
operator=(OrderBookIndex&&) = delete;
|
||||||
|
|
||||||
|
/** Cheap structural copy: clones the per-book tree roots (O(#books)
|
||||||
|
shared_ptr copies), sharing all offer nodes. Used by the `OpenView` copy
|
||||||
|
ctor so the index stays warm across the open-ledger COW. Counters reset. */
|
||||||
|
[[nodiscard]] OrderBookIndex
|
||||||
|
clone() const;
|
||||||
|
|
||||||
|
// --- maintenance (apply-path hooks) ---
|
||||||
|
|
||||||
|
/** Record that `offerKey` was inserted into `book` at quality-directory root
|
||||||
|
`dirRoot`. Appended (next insertSeq) so it sorts after same-level offers,
|
||||||
|
preserving directory order. */
|
||||||
|
void
|
||||||
|
insertOffer(Book const& book, uint256 const& dirRoot, uint256 const& offerKey);
|
||||||
|
|
||||||
|
/** Record that `offerKey` was removed from `book` at quality-directory root
|
||||||
|
`dirRoot`. The book is dropped when it empties. Removing an absent key is
|
||||||
|
a no-op. */
|
||||||
|
void
|
||||||
|
deleteOffer(Book const& book, uint256 const& dirRoot, uint256 const& offerKey);
|
||||||
|
|
||||||
|
// --- ordered read access (BookTip seam) ---
|
||||||
|
|
||||||
|
/** All offer keys of `book`, best-quality-first, directory order within a
|
||||||
|
level. Empty if the book is absent. */
|
||||||
|
[[nodiscard]] std::vector<uint256>
|
||||||
|
flatten(Book const& book) const;
|
||||||
|
|
||||||
|
/** The best (first) offer key of `book`, or nullopt if absent. */
|
||||||
|
[[nodiscard]] std::optional<uint256>
|
||||||
|
firstOffer(Book const& book) const;
|
||||||
|
|
||||||
|
// --- rebuild / validation (composition with the authoritative SHAMap) ---
|
||||||
|
|
||||||
|
/** Repopulate `book` from `view` by the canonical quality-ordered walk
|
||||||
|
(`succ()` over directory roots + directory iteration within each). */
|
||||||
|
void
|
||||||
|
rebuildBook(ReadView const& view, Book const& book);
|
||||||
|
|
||||||
|
/** True iff the maintained sequence for `book` equals a fresh walk of
|
||||||
|
`view`. The differential invariant. */
|
||||||
|
[[nodiscard]] bool
|
||||||
|
validateMatchesShaMap(ReadView const& view, Book const& book) const;
|
||||||
|
|
||||||
|
// --- bookkeeping ---
|
||||||
|
|
||||||
|
/** True if `book` has an entry (at least one offer). O(1). Present implies
|
||||||
|
non-empty (empty books are dropped). */
|
||||||
|
[[nodiscard]] bool
|
||||||
|
contains(Book const& book) const;
|
||||||
|
|
||||||
|
void
|
||||||
|
eraseBook(Book const& book);
|
||||||
|
|
||||||
|
void
|
||||||
|
clear();
|
||||||
|
|
||||||
|
[[nodiscard]] std::size_t
|
||||||
|
bookCount() const;
|
||||||
|
|
||||||
|
[[nodiscard]] std::size_t
|
||||||
|
offerCount(Book const& book) const;
|
||||||
|
|
||||||
|
[[nodiscard]] std::uint64_t
|
||||||
|
inserts() const noexcept
|
||||||
|
{
|
||||||
|
return inserts_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::uint64_t
|
||||||
|
deletes() const noexcept
|
||||||
|
{
|
||||||
|
return deletes_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::uint64_t
|
||||||
|
rebuilds() const noexcept
|
||||||
|
{
|
||||||
|
return rebuilds_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- operator-facing kill switch (mirrors TopOfBookCache) ---
|
||||||
|
|
||||||
|
[[nodiscard]] static bool
|
||||||
|
enabled() noexcept;
|
||||||
|
|
||||||
|
static void
|
||||||
|
setEnabled(bool on) noexcept;
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct BookState
|
||||||
|
{
|
||||||
|
detail::OrderTreePtr root; // persistent (dirRoot, insertSeq) -> offerKey
|
||||||
|
std::uint64_t nextSeq{0}; // per-book monotonic append counter
|
||||||
|
};
|
||||||
|
|
||||||
|
// Canonical quality-ordered walk of `book` in `view`: (dirRoot, offerKey)
|
||||||
|
// for each offer, best-quality-first, directory order within a level.
|
||||||
|
[[nodiscard]] static std::vector<std::pair<uint256, uint256>>
|
||||||
|
walkBook(ReadView const& view, Book const& book);
|
||||||
|
|
||||||
|
mutable std::shared_mutex mutex_;
|
||||||
|
std::unordered_map<Book, BookState> books_;
|
||||||
|
std::atomic<std::uint64_t> inserts_{0};
|
||||||
|
std::atomic<std::uint64_t> deletes_{0};
|
||||||
|
std::atomic<std::uint64_t> rebuilds_{0};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace xrpl
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
#include <xrpl/basics/chrono.h>
|
#include <xrpl/basics/chrono.h>
|
||||||
#include <xrpl/beast/hash/uhash.h>
|
#include <xrpl/beast/hash/uhash.h>
|
||||||
#include <xrpl/ledger/detail/ReadViewFwdRange.h>
|
#include <xrpl/ledger/detail/ReadViewFwdRange.h>
|
||||||
|
#include <xrpl/protocol/Book.h>
|
||||||
#include <xrpl/protocol/Fees.h>
|
#include <xrpl/protocol/Fees.h>
|
||||||
#include <xrpl/protocol/IOUAmount.h>
|
#include <xrpl/protocol/IOUAmount.h>
|
||||||
#include <xrpl/protocol/Indexes.h>
|
#include <xrpl/protocol/Indexes.h>
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace xrpl {
|
namespace xrpl {
|
||||||
|
|
||||||
@@ -188,6 +190,68 @@ public:
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Top-of-book cache hooks
|
||||||
|
//
|
||||||
|
// The default implementations make every non-overriding view a no-op
|
||||||
|
// pass-through, so non-orderbook code is unaffected. OpenView overrides
|
||||||
|
// these to maintain a real `TopOfBookCache`; views that wrap a base
|
||||||
|
// (ApplyViewBase, PaymentSandbox, ...) delegate to that base.
|
||||||
|
|
||||||
|
/** Return the cached keylet of the best (lowest-keyed) directory page
|
||||||
|
for `book`, if known. std::nullopt forces a `succ()` fallback.
|
||||||
|
*/
|
||||||
|
[[nodiscard]] virtual std::optional<uint256>
|
||||||
|
topOfBookFirstPage(Book const& book) const
|
||||||
|
{
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Populate the cache after a `succ()`-driven discovery. Called from
|
||||||
|
the cold path of `BookTip::step()`.
|
||||||
|
*/
|
||||||
|
virtual void
|
||||||
|
recordTopOfBook(Book const& book, uint256 const& firstPageKey) const
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply-path notification: an offer was inserted into `book` at
|
||||||
|
directory keylet `dirKey`. The cache may use this to update or
|
||||||
|
invalidate its entry; the call must be safe under any base view.
|
||||||
|
*/
|
||||||
|
virtual void
|
||||||
|
notifyOfferInserted(Book const& book, uint256 const& dirKey, uint256 const& offerKey) const
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply-path notification: an offer was deleted from `book` at
|
||||||
|
directory keylet `dirKey`. If the deleted offer was on the
|
||||||
|
cached top page, the cache invalidates that entry.
|
||||||
|
|
||||||
|
`offerKey` is the deleted offer's ledger key — unused by the cache,
|
||||||
|
consumed by the order-book index.
|
||||||
|
*/
|
||||||
|
virtual void
|
||||||
|
notifyOfferDeleted(Book const& book, uint256 const& dirKey, uint256 const& offerKey) const
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return `book`'s offer keys best-quality-first (the order the crossing
|
||||||
|
path consumes them), or std::nullopt to force the `succ()`-based walk.
|
||||||
|
|
||||||
|
Lets `BookTip` iterate the book from an in-memory cursor instead of
|
||||||
|
re-walking the SHAMap with `succ()` per offer. A returned vector is
|
||||||
|
guaranteed complete for `book` — implementations rebuild from the
|
||||||
|
authoritative state on a miss, so the cursor can never under-include.
|
||||||
|
Empty/absent books return nullopt (the cheap `succ()` path finds
|
||||||
|
nothing). Default: no index, always nullopt.
|
||||||
|
*/
|
||||||
|
[[nodiscard]] virtual std::optional<std::vector<uint256>>
|
||||||
|
orderedBook(Book const& book) const
|
||||||
|
{
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
// used by the implementation
|
// used by the implementation
|
||||||
[[nodiscard]] virtual std::unique_ptr<SlesType::iter_base>
|
[[nodiscard]] virtual std::unique_ptr<SlesType::iter_base>
|
||||||
slesBegin() const = 0;
|
slesBegin() const = 0;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ public:
|
|||||||
apply(RawView& to)
|
apply(RawView& to)
|
||||||
{
|
{
|
||||||
items_.apply(to);
|
items_.apply(to);
|
||||||
|
flushTopOfBookNotifications();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
163
include/xrpl/ledger/TopOfBookCache.h
Normal file
163
include/xrpl/ledger/TopOfBookCache.h
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <xrpl/basics/base_uint.h>
|
||||||
|
#include <xrpl/protocol/Book.h>
|
||||||
|
#include <xrpl/protocol/Protocol.h>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
namespace xrpl {
|
||||||
|
|
||||||
|
/** One entry in the top-of-book cache.
|
||||||
|
|
||||||
|
Records the keylet of the best-quality (lowest-keyed) directory page
|
||||||
|
for a single order book at the time the entry was recorded.
|
||||||
|
*/
|
||||||
|
struct TopOfBookEntry
|
||||||
|
{
|
||||||
|
/// Keylet of the best directory page for the book.
|
||||||
|
uint256 firstPageKey;
|
||||||
|
/// Quality bits encoded in firstPageKey (decoded for fast comparison).
|
||||||
|
std::uint64_t bestQuality{0};
|
||||||
|
/// Ledger sequence at which this entry was populated.
|
||||||
|
LedgerIndex asOfLedger{0};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Cache of "best directory page" keylet per active order book.
|
||||||
|
|
||||||
|
Reads of the top of an order book usually return the same directory page
|
||||||
|
over and over, but `BookTip::step()` re-walks the SHAMap on every call.
|
||||||
|
This cache memoizes that result. Lookups become a single hash-map probe;
|
||||||
|
the SHAMap successor walk happens only on cold or invalidated entries.
|
||||||
|
|
||||||
|
The cache is auxiliary — invalidating an entry is always safe, since the
|
||||||
|
next read repopulates lazily via `ReadView::succ()`. That property is what
|
||||||
|
lets the cache ship without an amendment.
|
||||||
|
|
||||||
|
Maintenance rules, applied at the apply path:
|
||||||
|
|
||||||
|
- **Offer inserted**: if the new offer's directory keylet is at-or-better
|
||||||
|
than the cached top, update the entry. Otherwise no-op.
|
||||||
|
- **Offer deleted**: if the deleted offer was on the cached top page,
|
||||||
|
invalidate. Otherwise no-op.
|
||||||
|
|
||||||
|
A best-page key is `keylet::quality(keylet::kBook(book), rate).key`. All
|
||||||
|
pages of a single book share the same prefix, so lower uint256 key =
|
||||||
|
better quality. Comparisons in this file rely on that ordering.
|
||||||
|
*/
|
||||||
|
class TopOfBookCache
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
TopOfBookCache() = default;
|
||||||
|
|
||||||
|
/** Copy-construct (used when snapshotting open->closed ledger).
|
||||||
|
|
||||||
|
Hit/miss/invalidation counters are not copied; only the data is.
|
||||||
|
*/
|
||||||
|
TopOfBookCache(TopOfBookCache const& other);
|
||||||
|
|
||||||
|
/** Move-construct by locking the source and stealing its map.
|
||||||
|
|
||||||
|
Needed because views that own a cache (OpenView) are moveable;
|
||||||
|
std::mutex is not, so the move is implemented via lock-and-move.
|
||||||
|
Counters are not transferred.
|
||||||
|
*/
|
||||||
|
TopOfBookCache(TopOfBookCache&& other);
|
||||||
|
|
||||||
|
TopOfBookCache&
|
||||||
|
operator=(TopOfBookCache const&) = delete;
|
||||||
|
TopOfBookCache&
|
||||||
|
operator=(TopOfBookCache&&) = delete;
|
||||||
|
|
||||||
|
/** Look up the cached top of `book`.
|
||||||
|
|
||||||
|
Returns std::nullopt on miss. Hit/miss counters are updated.
|
||||||
|
*/
|
||||||
|
[[nodiscard]] std::optional<TopOfBookEntry>
|
||||||
|
get(Book const& book) const;
|
||||||
|
|
||||||
|
/** Record (or overwrite) a top-of-book entry for `book`.
|
||||||
|
|
||||||
|
Called from the cold path after `succ()` discovers the first page.
|
||||||
|
*/
|
||||||
|
void
|
||||||
|
record(Book const& book, uint256 const& firstPageKey, LedgerIndex seq);
|
||||||
|
|
||||||
|
/** Notify the cache that an offer was inserted into `book` at directory
|
||||||
|
keylet `dirKey`.
|
||||||
|
|
||||||
|
If the new keylet is better than (less than) the cached top, the entry
|
||||||
|
is updated. If it is equal, no change. If worse, no change.
|
||||||
|
|
||||||
|
If no entry exists for `book`, this is a no-op: the next read will
|
||||||
|
populate from `succ()`.
|
||||||
|
*/
|
||||||
|
void
|
||||||
|
onOfferInsert(Book const& book, uint256 const& dirKey, LedgerIndex seq);
|
||||||
|
|
||||||
|
/** Notify the cache that an offer was deleted from `book` at directory
|
||||||
|
keylet `dirKey`.
|
||||||
|
|
||||||
|
If the delete was on the cached top page, invalidate (the page may
|
||||||
|
now be empty, or the offer count is irrelevant — next read repopulates).
|
||||||
|
Otherwise no-op.
|
||||||
|
*/
|
||||||
|
void
|
||||||
|
onOfferDelete(Book const& book, uint256 const& dirKey);
|
||||||
|
|
||||||
|
/** Drop the entry for `book` unconditionally.
|
||||||
|
|
||||||
|
Used as a safety hatch and by tests.
|
||||||
|
*/
|
||||||
|
void
|
||||||
|
invalidate(Book const& book);
|
||||||
|
|
||||||
|
/** Drop every entry. */
|
||||||
|
void
|
||||||
|
clear();
|
||||||
|
|
||||||
|
[[nodiscard]] std::size_t
|
||||||
|
size() const;
|
||||||
|
|
||||||
|
[[nodiscard]] std::uint64_t
|
||||||
|
hits() const noexcept
|
||||||
|
{
|
||||||
|
return hits_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::uint64_t
|
||||||
|
misses() const noexcept
|
||||||
|
{
|
||||||
|
return misses_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] std::uint64_t
|
||||||
|
invalidations() const noexcept
|
||||||
|
{
|
||||||
|
return invalidations_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Operator-facing kill switch.
|
||||||
|
|
||||||
|
When false, `BookTip` skips cache consults and writes entirely,
|
||||||
|
falling back to plain `succ()`. Default is true.
|
||||||
|
*/
|
||||||
|
[[nodiscard]] static bool
|
||||||
|
enabled() noexcept;
|
||||||
|
|
||||||
|
static void
|
||||||
|
setEnabled(bool on) noexcept;
|
||||||
|
|
||||||
|
private:
|
||||||
|
mutable std::mutex mutex_;
|
||||||
|
std::unordered_map<Book, TopOfBookEntry> map_;
|
||||||
|
mutable std::atomic<std::uint64_t> hits_{0};
|
||||||
|
mutable std::atomic<std::uint64_t> misses_{0};
|
||||||
|
std::atomic<std::uint64_t> invalidations_{0};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace xrpl
|
||||||
@@ -3,8 +3,13 @@
|
|||||||
#include <xrpl/ledger/ApplyView.h>
|
#include <xrpl/ledger/ApplyView.h>
|
||||||
#include <xrpl/ledger/ReadView.h>
|
#include <xrpl/ledger/ReadView.h>
|
||||||
#include <xrpl/ledger/detail/ApplyStateTable.h>
|
#include <xrpl/ledger/detail/ApplyStateTable.h>
|
||||||
|
#include <xrpl/protocol/Book.h>
|
||||||
#include <xrpl/protocol/XRPAmount.h>
|
#include <xrpl/protocol/XRPAmount.h>
|
||||||
|
|
||||||
|
#include <unordered_set>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace xrpl::detail {
|
namespace xrpl::detail {
|
||||||
|
|
||||||
class ApplyViewBase : public ApplyView, public RawView
|
class ApplyViewBase : public ApplyView, public RawView
|
||||||
@@ -43,6 +48,26 @@ public:
|
|||||||
[[nodiscard]] std::shared_ptr<SLE const>
|
[[nodiscard]] std::shared_ptr<SLE const>
|
||||||
read(Keylet const& k) const override;
|
read(Keylet const& k) const override;
|
||||||
|
|
||||||
|
// Top-of-book cache hooks — delegated to the wrapped base view so
|
||||||
|
// sandboxed views share the underlying open-ledger cache.
|
||||||
|
|
||||||
|
[[nodiscard]] std::optional<uint256>
|
||||||
|
topOfBookFirstPage(Book const& book) const override;
|
||||||
|
|
||||||
|
void
|
||||||
|
recordTopOfBook(Book const& book, uint256 const& firstPageKey) const override;
|
||||||
|
|
||||||
|
void
|
||||||
|
notifyOfferInserted(Book const& book, uint256 const& dirKey, uint256 const& offerKey)
|
||||||
|
const override;
|
||||||
|
|
||||||
|
void
|
||||||
|
notifyOfferDeleted(Book const& book, uint256 const& dirKey, uint256 const& offerKey)
|
||||||
|
const override;
|
||||||
|
|
||||||
|
[[nodiscard]] std::optional<std::vector<uint256>>
|
||||||
|
orderedBook(Book const& book) const override;
|
||||||
|
|
||||||
[[nodiscard]] std::unique_ptr<SlesType::iter_base>
|
[[nodiscard]] std::unique_ptr<SlesType::iter_base>
|
||||||
slesBegin() const override;
|
slesBegin() const override;
|
||||||
|
|
||||||
@@ -95,10 +120,45 @@ public:
|
|||||||
void
|
void
|
||||||
rawDestroyXRP(XRPAmount const& feeDrops) override;
|
rawDestroyXRP(XRPAmount const& feeDrops) override;
|
||||||
|
|
||||||
|
/** Flush buffered top-of-book notifications to the wrapped base view.
|
||||||
|
|
||||||
|
Called by `Sandbox::apply` (and similar commit points) after the
|
||||||
|
state table itself has been applied. Notifications buffered during
|
||||||
|
the sandbox's lifetime are replayed against `base_` in insertion
|
||||||
|
order so the parent cache only sees changes that actually commit.
|
||||||
|
*/
|
||||||
|
void
|
||||||
|
flushTopOfBookNotifications() const;
|
||||||
|
|
||||||
|
/** Discard buffered notifications (e.g. when a sandbox is dropped
|
||||||
|
without applying). Safe to call multiple times.
|
||||||
|
*/
|
||||||
|
void
|
||||||
|
discardTopOfBookNotifications() const noexcept;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
ApplyFlags flags_;
|
ApplyFlags flags_;
|
||||||
ReadView const* base_;
|
ReadView const* base_;
|
||||||
detail::ApplyStateTable items_;
|
detail::ApplyStateTable items_;
|
||||||
|
|
||||||
|
// Top-of-book cache notifications are buffered here for the lifetime
|
||||||
|
// of the sandbox and only flushed to `base_` on `apply()`. This keeps
|
||||||
|
// rolled-back transactions (e.g. FillOrKill via the sbCancel branch
|
||||||
|
// of OfferCreate) from polluting the parent's cache.
|
||||||
|
//
|
||||||
|
// `dirtyBooks_` records every book mutated by buffered notifications;
|
||||||
|
// reads against `topOfBookFirstPage` skip the cache for these books so
|
||||||
|
// we never observe our own un-committed state. Outside of the dirty
|
||||||
|
// set, the parent's cache is trusted as usual.
|
||||||
|
struct OfferNote
|
||||||
|
{
|
||||||
|
Book book;
|
||||||
|
uint256 dirKey;
|
||||||
|
uint256 offerKey;
|
||||||
|
bool isDelete;
|
||||||
|
};
|
||||||
|
mutable std::vector<OfferNote> pendingTopOfBookNotifications_;
|
||||||
|
mutable std::unordered_set<Book> dirtyBooks_;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace xrpl::detail
|
} // namespace xrpl::detail
|
||||||
|
|||||||
257
include/xrpl/ledger/detail/PersistentOrderTree.h
Normal file
257
include/xrpl/ledger/detail/PersistentOrderTree.h
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <xrpl/basics/base_uint.h>
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <optional>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace xrpl::detail {
|
||||||
|
|
||||||
|
/** Persistent (immutable, structurally-shared) ordered tree for the order-book
|
||||||
|
index.
|
||||||
|
|
||||||
|
A weight-balanced BST (Adams BB[α], the family used by Haskell `Data.Map`
|
||||||
|
and std::map-replacement libraries) of immutable `shared_ptr<const Node>`.
|
||||||
|
Keyed by `(dirRoot, insertSeq)`:
|
||||||
|
|
||||||
|
- `dirRoot` ascending == best-quality-first (book directory pages share a
|
||||||
|
prefix, quality is in the low bytes — lower key = better quality).
|
||||||
|
- `insertSeq` ascending within a `dirRoot` == directory append order
|
||||||
|
(the per-book monotonic counter mirrors `dirAppend`; `dirRemove`
|
||||||
|
preserves relative order, so this reproduces the directory walk
|
||||||
|
byte-for-byte).
|
||||||
|
|
||||||
|
Operations are persistent via path-copying: insert/delete reallocate only
|
||||||
|
the O(log n) nodes on the root→leaf path and SHARE every untouched subtree.
|
||||||
|
A "copy" of a tree is just copying the root `shared_ptr` — O(1) — which is
|
||||||
|
what lets the order-book index survive the open-ledger copy-on-write cheaply
|
||||||
|
and stay warm across transactions.
|
||||||
|
|
||||||
|
Immutable nodes are safe to share across threads/snapshots without locking.
|
||||||
|
*/
|
||||||
|
struct OrderTreeNode
|
||||||
|
{
|
||||||
|
uint256 dirRoot;
|
||||||
|
std::uint64_t insertSeq;
|
||||||
|
uint256 offerKey;
|
||||||
|
std::uint32_t size; // subtree node count (balance + rank)
|
||||||
|
std::shared_ptr<OrderTreeNode const> left;
|
||||||
|
std::shared_ptr<OrderTreeNode const> right;
|
||||||
|
};
|
||||||
|
|
||||||
|
using OrderTreePtr = std::shared_ptr<OrderTreeNode const>;
|
||||||
|
|
||||||
|
// Weight-balance parameters (Adams). delta bounds the size ratio between
|
||||||
|
// siblings; gamma chooses single vs double rotation.
|
||||||
|
inline constexpr std::uint32_t kOtDelta = 3;
|
||||||
|
inline constexpr std::uint32_t kOtGamma = 2;
|
||||||
|
|
||||||
|
[[nodiscard]] inline std::uint32_t
|
||||||
|
otSize(OrderTreePtr const& t) noexcept
|
||||||
|
{
|
||||||
|
return t ? t->size : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -1 / 0 / +1 ordering on (dirRoot, insertSeq).
|
||||||
|
[[nodiscard]] inline int
|
||||||
|
otCmp(
|
||||||
|
uint256 const& aDir,
|
||||||
|
std::uint64_t aSeq,
|
||||||
|
uint256 const& bDir,
|
||||||
|
std::uint64_t bSeq) noexcept
|
||||||
|
{
|
||||||
|
if (aDir < bDir)
|
||||||
|
return -1;
|
||||||
|
if (bDir < aDir)
|
||||||
|
return 1;
|
||||||
|
if (aSeq < bSeq)
|
||||||
|
return -1;
|
||||||
|
if (bSeq < aSeq)
|
||||||
|
return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] inline OrderTreePtr
|
||||||
|
otNode(
|
||||||
|
uint256 const& dir,
|
||||||
|
std::uint64_t seq,
|
||||||
|
uint256 const& off,
|
||||||
|
OrderTreePtr l,
|
||||||
|
OrderTreePtr r)
|
||||||
|
{
|
||||||
|
auto n = std::make_shared<OrderTreeNode>();
|
||||||
|
n->dirRoot = dir;
|
||||||
|
n->insertSeq = seq;
|
||||||
|
n->offerKey = off;
|
||||||
|
n->left = std::move(l);
|
||||||
|
n->right = std::move(r);
|
||||||
|
n->size = otSize(n->left) + otSize(n->right) + 1;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebalance a node whose subtrees may violate the weight balance by one step.
|
||||||
|
[[nodiscard]] inline OrderTreePtr
|
||||||
|
otBalance(
|
||||||
|
uint256 const& dir,
|
||||||
|
std::uint64_t seq,
|
||||||
|
uint256 const& off,
|
||||||
|
OrderTreePtr const& l,
|
||||||
|
OrderTreePtr const& r)
|
||||||
|
{
|
||||||
|
auto const ln = otSize(l);
|
||||||
|
auto const rn = otSize(r);
|
||||||
|
|
||||||
|
if (ln + rn <= 1)
|
||||||
|
return otNode(dir, seq, off, l, r);
|
||||||
|
|
||||||
|
if (rn > kOtDelta * ln)
|
||||||
|
{
|
||||||
|
// Right-heavy.
|
||||||
|
auto const& rl = r->left;
|
||||||
|
auto const& rr = r->right;
|
||||||
|
if (otSize(rl) < kOtGamma * otSize(rr))
|
||||||
|
// single left rotation
|
||||||
|
return otNode(
|
||||||
|
r->dirRoot,
|
||||||
|
r->insertSeq,
|
||||||
|
r->offerKey,
|
||||||
|
otNode(dir, seq, off, l, rl),
|
||||||
|
rr);
|
||||||
|
// double left rotation
|
||||||
|
return otNode(
|
||||||
|
rl->dirRoot,
|
||||||
|
rl->insertSeq,
|
||||||
|
rl->offerKey,
|
||||||
|
otNode(dir, seq, off, l, rl->left),
|
||||||
|
otNode(r->dirRoot, r->insertSeq, r->offerKey, rl->right, rr));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ln > kOtDelta * rn)
|
||||||
|
{
|
||||||
|
// Left-heavy.
|
||||||
|
auto const& ll = l->left;
|
||||||
|
auto const& lr = l->right;
|
||||||
|
if (otSize(lr) < kOtGamma * otSize(ll))
|
||||||
|
// single right rotation
|
||||||
|
return otNode(
|
||||||
|
l->dirRoot,
|
||||||
|
l->insertSeq,
|
||||||
|
l->offerKey,
|
||||||
|
ll,
|
||||||
|
otNode(dir, seq, off, lr, r));
|
||||||
|
// double right rotation
|
||||||
|
return otNode(
|
||||||
|
lr->dirRoot,
|
||||||
|
lr->insertSeq,
|
||||||
|
lr->offerKey,
|
||||||
|
otNode(l->dirRoot, l->insertSeq, l->offerKey, ll, lr->left),
|
||||||
|
otNode(dir, seq, off, lr->right, r));
|
||||||
|
}
|
||||||
|
|
||||||
|
return otNode(dir, seq, off, l, r);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] inline OrderTreePtr
|
||||||
|
otInsert(OrderTreePtr const& t, uint256 const& dir, std::uint64_t seq, uint256 const& off)
|
||||||
|
{
|
||||||
|
if (!t)
|
||||||
|
return otNode(dir, seq, off, nullptr, nullptr);
|
||||||
|
int const c = otCmp(dir, seq, t->dirRoot, t->insertSeq);
|
||||||
|
if (c < 0)
|
||||||
|
return otBalance(
|
||||||
|
t->dirRoot, t->insertSeq, t->offerKey, otInsert(t->left, dir, seq, off), t->right);
|
||||||
|
if (c > 0)
|
||||||
|
return otBalance(
|
||||||
|
t->dirRoot, t->insertSeq, t->offerKey, t->left, otInsert(t->right, dir, seq, off));
|
||||||
|
// Equal key: replace payload (keys are unique in practice; never hit).
|
||||||
|
return otNode(t->dirRoot, t->insertSeq, off, t->left, t->right);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the minimum node of a non-null tree; write its fields into `outMin`.
|
||||||
|
[[nodiscard]] inline OrderTreePtr
|
||||||
|
otDeleteMin(OrderTreePtr const& t, OrderTreeNode& outMin)
|
||||||
|
{
|
||||||
|
if (!t->left)
|
||||||
|
{
|
||||||
|
outMin = *t;
|
||||||
|
return t->right;
|
||||||
|
}
|
||||||
|
return otBalance(
|
||||||
|
t->dirRoot, t->insertSeq, t->offerKey, otDeleteMin(t->left, outMin), t->right);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join two subtrees (all keys in l < all keys in r) by promoting r's minimum.
|
||||||
|
[[nodiscard]] inline OrderTreePtr
|
||||||
|
otGlue(OrderTreePtr const& l, OrderTreePtr const& r)
|
||||||
|
{
|
||||||
|
if (!l)
|
||||||
|
return r;
|
||||||
|
if (!r)
|
||||||
|
return l;
|
||||||
|
OrderTreeNode minN;
|
||||||
|
auto const r2 = otDeleteMin(r, minN);
|
||||||
|
return otBalance(minN.dirRoot, minN.insertSeq, minN.offerKey, l, r2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] inline OrderTreePtr
|
||||||
|
otDelete(OrderTreePtr const& t, uint256 const& dir, std::uint64_t seq)
|
||||||
|
{
|
||||||
|
if (!t)
|
||||||
|
return nullptr;
|
||||||
|
int const c = otCmp(dir, seq, t->dirRoot, t->insertSeq);
|
||||||
|
if (c < 0)
|
||||||
|
return otBalance(
|
||||||
|
t->dirRoot, t->insertSeq, t->offerKey, otDelete(t->left, dir, seq), t->right);
|
||||||
|
if (c > 0)
|
||||||
|
return otBalance(
|
||||||
|
t->dirRoot, t->insertSeq, t->offerKey, t->left, otDelete(t->right, dir, seq));
|
||||||
|
return otGlue(t->left, t->right);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-order traversal: appends offer keys best-quality-first, append order
|
||||||
|
// within a level.
|
||||||
|
inline void
|
||||||
|
otInorder(OrderTreePtr const& t, std::vector<uint256>& out)
|
||||||
|
{
|
||||||
|
if (!t)
|
||||||
|
return;
|
||||||
|
otInorder(t->left, out);
|
||||||
|
out.push_back(t->offerKey);
|
||||||
|
otInorder(t->right, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leftmost (best) offer key.
|
||||||
|
[[nodiscard]] inline std::optional<uint256>
|
||||||
|
otFirst(OrderTreePtr t)
|
||||||
|
{
|
||||||
|
if (!t)
|
||||||
|
return std::nullopt;
|
||||||
|
while (t->left)
|
||||||
|
t = t->left;
|
||||||
|
return t->offerKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the insertSeq for (dirRoot, offerKey). All nodes sharing a dirRoot form
|
||||||
|
// a contiguous in-order range that may straddle a node's two children, so when
|
||||||
|
// dirRoot matches we must check the node and both subtrees. O(level-size) worst
|
||||||
|
// case; effectively O(log n) for front deletions (crossing consumes front-first
|
||||||
|
// and the target is then the level's leftmost remaining node).
|
||||||
|
[[nodiscard]] inline std::optional<std::uint64_t>
|
||||||
|
otFindSeq(OrderTreePtr const& t, uint256 const& dir, uint256 const& off)
|
||||||
|
{
|
||||||
|
if (!t)
|
||||||
|
return std::nullopt;
|
||||||
|
if (dir < t->dirRoot)
|
||||||
|
return otFindSeq(t->left, dir, off);
|
||||||
|
if (t->dirRoot < dir)
|
||||||
|
return otFindSeq(t->right, dir, off);
|
||||||
|
if (t->offerKey == off)
|
||||||
|
return t->insertSeq;
|
||||||
|
if (auto const l = otFindSeq(t->left, dir, off))
|
||||||
|
return l;
|
||||||
|
return otFindSeq(t->right, dir, off);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace xrpl::detail
|
||||||
@@ -4,6 +4,10 @@
|
|||||||
#include <xrpl/protocol/Indexes.h>
|
#include <xrpl/protocol/Indexes.h>
|
||||||
#include <xrpl/protocol/Quality.h>
|
#include <xrpl/protocol/Quality.h>
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace xrpl {
|
namespace xrpl {
|
||||||
|
|
||||||
class Logs;
|
class Logs;
|
||||||
@@ -17,6 +21,7 @@ class BookTip
|
|||||||
private:
|
private:
|
||||||
ApplyView& view_;
|
ApplyView& view_;
|
||||||
bool valid_{false};
|
bool valid_{false};
|
||||||
|
Book originalBook_;
|
||||||
uint256 book_;
|
uint256 book_;
|
||||||
uint256 end_;
|
uint256 end_;
|
||||||
uint256 dir_;
|
uint256 dir_;
|
||||||
@@ -24,6 +29,15 @@ private:
|
|||||||
std::shared_ptr<SLE> entry_;
|
std::shared_ptr<SLE> entry_;
|
||||||
Quality quality_{};
|
Quality quality_{};
|
||||||
|
|
||||||
|
// Plan 9: when the order-book index supplies an ordered offer-key snapshot
|
||||||
|
// for this book, iterate it instead of re-walking the SHAMap with succ()
|
||||||
|
// per offer. `useCursor_` is decided on the first step; thereafter the two
|
||||||
|
// paths are mutually exclusive for the iterator's lifetime.
|
||||||
|
std::vector<uint256> cursor_;
|
||||||
|
std::size_t cursorPos_{0};
|
||||||
|
bool useCursor_{false};
|
||||||
|
std::uint64_t lastCursorQuality_{0};
|
||||||
|
|
||||||
public:
|
public:
|
||||||
/** Create the iterator. */
|
/** Create the iterator. */
|
||||||
BookTip(ApplyView& view, Book const& book);
|
BookTip(ApplyView& view, Book const& book);
|
||||||
|
|||||||
@@ -64,6 +64,75 @@ ApplyViewBase::read(Keylet const& k) const
|
|||||||
return items_.read(*base_, k);
|
return items_.read(*base_, k);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::optional<uint256>
|
||||||
|
ApplyViewBase::topOfBookFirstPage(Book const& book) const
|
||||||
|
{
|
||||||
|
// Reads inside a sandbox that has already mutated `book` cannot use
|
||||||
|
// the parent's cache: the parent's view of the top doesn't reflect
|
||||||
|
// our buffered changes yet. Fall back to succ() in that case.
|
||||||
|
if (dirtyBooks_.find(book) != dirtyBooks_.end())
|
||||||
|
return std::nullopt;
|
||||||
|
return base_->topOfBookFirstPage(book);
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
ApplyViewBase::recordTopOfBook(Book const& book, uint256 const& firstPageKey) const
|
||||||
|
{
|
||||||
|
// Don't populate the parent cache from inside a dirty sandbox view —
|
||||||
|
// our succ() result may reflect uncommitted mutations from the parent's
|
||||||
|
// perspective.
|
||||||
|
if (dirtyBooks_.find(book) != dirtyBooks_.end())
|
||||||
|
return;
|
||||||
|
base_->recordTopOfBook(book, firstPageKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
ApplyViewBase::notifyOfferInserted(Book const& book, uint256 const& dirKey, uint256 const& offerKey)
|
||||||
|
const
|
||||||
|
{
|
||||||
|
dirtyBooks_.insert(book);
|
||||||
|
pendingTopOfBookNotifications_.emplace_back(book, dirKey, offerKey, /*isDelete=*/false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
ApplyViewBase::notifyOfferDeleted(Book const& book, uint256 const& dirKey, uint256 const& offerKey)
|
||||||
|
const
|
||||||
|
{
|
||||||
|
dirtyBooks_.insert(book);
|
||||||
|
pendingTopOfBookNotifications_.emplace_back(book, dirKey, offerKey, /*isDelete=*/true);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::vector<uint256>>
|
||||||
|
ApplyViewBase::orderedBook(Book const& book) const
|
||||||
|
{
|
||||||
|
// Unlike topOfBookFirstPage, do NOT skip dirty books: the cursor is taken
|
||||||
|
// once and iterated locally, and it self-heals any offer this sandbox has
|
||||||
|
// buffered-deleted via peek()-null-skip in BookTip. So always delegate to
|
||||||
|
// the (immutable-for-this-crossing) base index.
|
||||||
|
return base_->orderedBook(book);
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
ApplyViewBase::flushTopOfBookNotifications() const
|
||||||
|
{
|
||||||
|
for (auto const& note : pendingTopOfBookNotifications_)
|
||||||
|
{
|
||||||
|
if (note.isDelete)
|
||||||
|
base_->notifyOfferDeleted(note.book, note.dirKey, note.offerKey);
|
||||||
|
else
|
||||||
|
base_->notifyOfferInserted(note.book, note.dirKey, note.offerKey);
|
||||||
|
}
|
||||||
|
pendingTopOfBookNotifications_.clear();
|
||||||
|
dirtyBooks_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
ApplyViewBase::discardTopOfBookNotifications() const noexcept
|
||||||
|
{
|
||||||
|
pendingTopOfBookNotifications_.clear();
|
||||||
|
dirtyBooks_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
auto
|
auto
|
||||||
ApplyViewBase::slesBegin() const -> std::unique_ptr<SlesType::iter_base>
|
ApplyViewBase::slesBegin() const -> std::unique_ptr<SlesType::iter_base>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ ApplyViewImpl::apply(
|
|||||||
bool isDryRun,
|
bool isDryRun,
|
||||||
beast::Journal j)
|
beast::Journal j)
|
||||||
{
|
{
|
||||||
return items_.apply(to, tx, ter, deliver_, parentBatchId, isDryRun, j);
|
auto meta = items_.apply(to, tx, ter, deliver_, parentBatchId, isDryRun, j);
|
||||||
|
flushTopOfBookNotifications();
|
||||||
|
return meta;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::size_t
|
std::size_t
|
||||||
|
|||||||
@@ -86,7 +86,12 @@ OpenView::OpenView(OpenView const& rhs)
|
|||||||
, base_{rhs.base_}
|
, base_{rhs.base_}
|
||||||
, items_{rhs.items_}
|
, items_{rhs.items_}
|
||||||
, hold_{rhs.hold_}
|
, hold_{rhs.hold_}
|
||||||
, open_{rhs.open_} {};
|
, open_{rhs.open_}
|
||||||
|
// Plan 9 P9.6: carry the persistent order-book index forward on the
|
||||||
|
// open-ledger COW (modify() copies the OpenView per tx). clone() is O(#books)
|
||||||
|
// shared_ptr copies sharing all offer nodes, so the index stays warm across
|
||||||
|
// transactions instead of cold-starting and rebuilding per tx.
|
||||||
|
, orderBookIndex_{rhs.orderBookIndex_.clone()} {};
|
||||||
|
|
||||||
OpenView::OpenView(OpenLedgerT, ReadView const* base, Rules rules, std::shared_ptr<void const> hold)
|
OpenView::OpenView(OpenLedgerT, ReadView const* base, Rules rules, std::shared_ptr<void const> hold)
|
||||||
: monotonicResource_{
|
: monotonicResource_{
|
||||||
@@ -169,6 +174,71 @@ OpenView::read(Keylet const& k) const
|
|||||||
return items_.read(*base_, k);
|
return items_.read(*base_, k);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::optional<uint256>
|
||||||
|
OpenView::topOfBookFirstPage(Book const& book) const
|
||||||
|
{
|
||||||
|
if (!TopOfBookCache::enabled())
|
||||||
|
return std::nullopt;
|
||||||
|
if (auto const entry = topOfBookCache_.get(book))
|
||||||
|
return entry->firstPageKey;
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
OpenView::recordTopOfBook(Book const& book, uint256 const& firstPageKey) const
|
||||||
|
{
|
||||||
|
if (!TopOfBookCache::enabled())
|
||||||
|
return;
|
||||||
|
topOfBookCache_.record(book, firstPageKey, header_.seq);
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
OpenView::notifyOfferInserted(Book const& book, uint256 const& dirKey, uint256 const& offerKey)
|
||||||
|
const
|
||||||
|
{
|
||||||
|
// Maintain only books already in the index: a book enters the index only
|
||||||
|
// via rebuildBook (which captures the full authoritative state), so it is
|
||||||
|
// always complete. Inserting into an absent book would create a PARTIAL
|
||||||
|
// entry (missing pre-existing offers) that a later crossing would trust —
|
||||||
|
// wrong. Absent books are populated completely on first read (orderedBook's
|
||||||
|
// rebuild-on-absent). This mirrors TopOfBookCache::onOfferInsert's no-op.
|
||||||
|
if (OrderBookIndex::enabled() && orderBookIndex_.contains(book))
|
||||||
|
orderBookIndex_.insertOffer(book, dirKey, offerKey);
|
||||||
|
if (!TopOfBookCache::enabled())
|
||||||
|
return;
|
||||||
|
topOfBookCache_.onOfferInsert(book, dirKey, header_.seq);
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
OpenView::notifyOfferDeleted(Book const& book, uint256 const& dirKey, uint256 const& offerKey)
|
||||||
|
const
|
||||||
|
{
|
||||||
|
if (OrderBookIndex::enabled())
|
||||||
|
orderBookIndex_.deleteOffer(book, dirKey, offerKey);
|
||||||
|
if (!TopOfBookCache::enabled())
|
||||||
|
return;
|
||||||
|
topOfBookCache_.onOfferDelete(book, dirKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::vector<uint256>>
|
||||||
|
OpenView::orderedBook(Book const& book) const
|
||||||
|
{
|
||||||
|
if (!OrderBookIndex::enabled())
|
||||||
|
return std::nullopt;
|
||||||
|
|
||||||
|
// Guarantee completeness: if the index has no entry for `book`, populate it
|
||||||
|
// from the authoritative state before serving the cursor. A maintained,
|
||||||
|
// already-present book skips this (the steady-state fast path). The index
|
||||||
|
// never holds a partial book, so the cursor can't under-include.
|
||||||
|
if (!orderBookIndex_.contains(book))
|
||||||
|
orderBookIndex_.rebuildBook(*this, book);
|
||||||
|
|
||||||
|
auto offers = orderBookIndex_.flatten(book);
|
||||||
|
if (offers.empty())
|
||||||
|
return std::nullopt; // genuinely empty book — let succ() find nothing
|
||||||
|
return offers;
|
||||||
|
}
|
||||||
|
|
||||||
auto
|
auto
|
||||||
OpenView::slesBegin() const -> std::unique_ptr<SlesType::iter_base>
|
OpenView::slesBegin() const -> std::unique_ptr<SlesType::iter_base>
|
||||||
{
|
{
|
||||||
|
|||||||
199
src/libxrpl/ledger/OrderBookIndex.cpp
Normal file
199
src/libxrpl/ledger/OrderBookIndex.cpp
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
#include <xrpl/ledger/OrderBookIndex.h>
|
||||||
|
|
||||||
|
#include <xrpl/ledger/ReadView.h>
|
||||||
|
#include <xrpl/ledger/helpers/DirectoryHelpers.h>
|
||||||
|
#include <xrpl/protocol/Indexes.h>
|
||||||
|
#include <xrpl/protocol/STLedgerEntry.h>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
|
||||||
|
namespace xrpl {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Operator-facing kill switch. Defaults to true; set false via setEnabled()
|
||||||
|
// to bypass the index entirely and fall back to baseline succ() iteration
|
||||||
|
// without a restart (mirrors TopOfBookCache).
|
||||||
|
std::atomic<bool> gEnabled{true};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool
|
||||||
|
OrderBookIndex::enabled() noexcept
|
||||||
|
{
|
||||||
|
return gEnabled.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
OrderBookIndex::setEnabled(bool on) noexcept
|
||||||
|
{
|
||||||
|
gEnabled.store(on, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
OrderBookIndex::OrderBookIndex(OrderBookIndex&& other)
|
||||||
|
{
|
||||||
|
std::unique_lock lock(other.mutex_);
|
||||||
|
books_ = std::move(other.books_);
|
||||||
|
}
|
||||||
|
|
||||||
|
OrderBookIndex
|
||||||
|
OrderBookIndex::clone() const
|
||||||
|
{
|
||||||
|
OrderBookIndex out;
|
||||||
|
std::shared_lock lock(mutex_);
|
||||||
|
// Copying the map copies each BookState — a shared_ptr root (O(1), shares
|
||||||
|
// all offer nodes) + the counter. Total O(#books).
|
||||||
|
out.books_ = books_;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
OrderBookIndex::insertOffer(Book const& book, uint256 const& dirRoot, uint256 const& offerKey)
|
||||||
|
{
|
||||||
|
std::unique_lock lock(mutex_);
|
||||||
|
auto& st = books_[book];
|
||||||
|
st.root = detail::otInsert(st.root, dirRoot, st.nextSeq++, offerKey);
|
||||||
|
inserts_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
OrderBookIndex::deleteOffer(Book const& book, uint256 const& dirRoot, uint256 const& offerKey)
|
||||||
|
{
|
||||||
|
std::unique_lock lock(mutex_);
|
||||||
|
auto const it = books_.find(book);
|
||||||
|
if (it == books_.end())
|
||||||
|
return;
|
||||||
|
auto const seq = detail::otFindSeq(it->second.root, dirRoot, offerKey);
|
||||||
|
if (!seq)
|
||||||
|
return;
|
||||||
|
it->second.root = detail::otDelete(it->second.root, dirRoot, *seq);
|
||||||
|
deletes_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
if (!it->second.root)
|
||||||
|
books_.erase(it);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<uint256>
|
||||||
|
OrderBookIndex::flatten(Book const& book) const
|
||||||
|
{
|
||||||
|
std::vector<uint256> out;
|
||||||
|
std::shared_lock lock(mutex_);
|
||||||
|
auto const it = books_.find(book);
|
||||||
|
if (it != books_.end())
|
||||||
|
detail::otInorder(it->second.root, out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<uint256>
|
||||||
|
OrderBookIndex::firstOffer(Book const& book) const
|
||||||
|
{
|
||||||
|
std::shared_lock lock(mutex_);
|
||||||
|
auto const it = books_.find(book);
|
||||||
|
if (it == books_.end())
|
||||||
|
return std::nullopt;
|
||||||
|
return detail::otFirst(it->second.root);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::pair<uint256, uint256>>
|
||||||
|
OrderBookIndex::walkBook(ReadView const& view, Book const& book)
|
||||||
|
{
|
||||||
|
// Canonical quality-ordered enumeration, mirroring NetworkOPs::getBookPage
|
||||||
|
// and BookTip: succ() over directory roots in [bookBase, bookEnd), then
|
||||||
|
// cdirFirst/cdirNext across each root's pages. uTip advances to the found
|
||||||
|
// root, so the next succ() yields the next-worse quality; a root's overflow
|
||||||
|
// pages live outside [bookBase, bookEnd) and are reached only via sfIndexNext
|
||||||
|
// inside cdirNext, never by succ().
|
||||||
|
std::vector<std::pair<uint256, uint256>> out;
|
||||||
|
uint256 const bookBase = getBookBase(book);
|
||||||
|
uint256 const bookEnd = getQualityNext(bookBase);
|
||||||
|
uint256 uTip = bookBase;
|
||||||
|
|
||||||
|
for (;;)
|
||||||
|
{
|
||||||
|
auto const next = view.succ(uTip, bookEnd);
|
||||||
|
if (!next)
|
||||||
|
break;
|
||||||
|
uint256 const dirRoot = *next;
|
||||||
|
|
||||||
|
std::shared_ptr<SLE const> page;
|
||||||
|
unsigned int index = 0;
|
||||||
|
uint256 offerKey;
|
||||||
|
if (cdirFirst(view, dirRoot, page, index, offerKey))
|
||||||
|
{
|
||||||
|
do
|
||||||
|
{
|
||||||
|
out.emplace_back(dirRoot, offerKey);
|
||||||
|
} while (cdirNext(view, dirRoot, page, index, offerKey));
|
||||||
|
}
|
||||||
|
uTip = dirRoot;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
OrderBookIndex::rebuildBook(ReadView const& view, Book const& book)
|
||||||
|
{
|
||||||
|
auto const walked = walkBook(view, book);
|
||||||
|
|
||||||
|
BookState st;
|
||||||
|
// Inserting in walk order assigns ascending insertSeq, so in-order traversal
|
||||||
|
// reproduces the walk exactly.
|
||||||
|
for (auto const& [dirRoot, offerKey] : walked)
|
||||||
|
st.root = detail::otInsert(st.root, dirRoot, st.nextSeq++, offerKey);
|
||||||
|
|
||||||
|
std::unique_lock lock(mutex_);
|
||||||
|
if (!st.root)
|
||||||
|
books_.erase(book);
|
||||||
|
else
|
||||||
|
books_[book] = std::move(st);
|
||||||
|
rebuilds_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool
|
||||||
|
OrderBookIndex::validateMatchesShaMap(ReadView const& view, Book const& book) const
|
||||||
|
{
|
||||||
|
std::vector<uint256> fresh;
|
||||||
|
for (auto const& [dirRoot, offerKey] : walkBook(view, book))
|
||||||
|
fresh.push_back(offerKey);
|
||||||
|
|
||||||
|
return fresh == flatten(book);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool
|
||||||
|
OrderBookIndex::contains(Book const& book) const
|
||||||
|
{
|
||||||
|
std::shared_lock lock(mutex_);
|
||||||
|
return books_.find(book) != books_.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
OrderBookIndex::eraseBook(Book const& book)
|
||||||
|
{
|
||||||
|
std::unique_lock lock(mutex_);
|
||||||
|
books_.erase(book);
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
OrderBookIndex::clear()
|
||||||
|
{
|
||||||
|
std::unique_lock lock(mutex_);
|
||||||
|
books_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t
|
||||||
|
OrderBookIndex::bookCount() const
|
||||||
|
{
|
||||||
|
std::shared_lock lock(mutex_);
|
||||||
|
return books_.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t
|
||||||
|
OrderBookIndex::offerCount(Book const& book) const
|
||||||
|
{
|
||||||
|
std::shared_lock lock(mutex_);
|
||||||
|
auto const it = books_.find(book);
|
||||||
|
if (it == books_.end())
|
||||||
|
return 0;
|
||||||
|
return detail::otSize(it->second.root);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace xrpl
|
||||||
@@ -453,6 +453,7 @@ PaymentSandbox::apply(RawView& to)
|
|||||||
{
|
{
|
||||||
XRPL_ASSERT(!ps_, "xrpl::PaymentSandbox::apply : non-null sandbox");
|
XRPL_ASSERT(!ps_, "xrpl::PaymentSandbox::apply : non-null sandbox");
|
||||||
items_.apply(to);
|
items_.apply(to);
|
||||||
|
flushTopOfBookNotifications();
|
||||||
}
|
}
|
||||||
|
|
||||||
void
|
void
|
||||||
@@ -461,6 +462,7 @@ PaymentSandbox::apply(PaymentSandbox& to)
|
|||||||
XRPL_ASSERT(ps_ == &to, "xrpl::PaymentSandbox::apply : matching sandbox");
|
XRPL_ASSERT(ps_ == &to, "xrpl::PaymentSandbox::apply : matching sandbox");
|
||||||
items_.apply(to);
|
items_.apply(to);
|
||||||
tab_.apply(to.tab_);
|
tab_.apply(to.tab_);
|
||||||
|
flushTopOfBookNotifications();
|
||||||
}
|
}
|
||||||
|
|
||||||
XRPAmount
|
XRPAmount
|
||||||
|
|||||||
123
src/libxrpl/ledger/TopOfBookCache.cpp
Normal file
123
src/libxrpl/ledger/TopOfBookCache.cpp
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
#include <xrpl/ledger/TopOfBookCache.h>
|
||||||
|
|
||||||
|
#include <xrpl/protocol/Indexes.h>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
|
namespace xrpl {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Operator-facing kill switch. Defaults to true; set false via setEnabled()
|
||||||
|
// to bypass the cache entirely (e.g. in case a workload exposes a bug, the
|
||||||
|
// node can be brought back to baseline succ() behavior without restart).
|
||||||
|
std::atomic<bool> gEnabled{true};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool
|
||||||
|
TopOfBookCache::enabled() noexcept
|
||||||
|
{
|
||||||
|
return gEnabled.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
TopOfBookCache::setEnabled(bool on) noexcept
|
||||||
|
{
|
||||||
|
gEnabled.store(on, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
TopOfBookCache::TopOfBookCache(TopOfBookCache const& other)
|
||||||
|
{
|
||||||
|
std::lock_guard lock(other.mutex_);
|
||||||
|
map_ = other.map_;
|
||||||
|
}
|
||||||
|
|
||||||
|
TopOfBookCache::TopOfBookCache(TopOfBookCache&& other)
|
||||||
|
{
|
||||||
|
std::lock_guard lock(other.mutex_);
|
||||||
|
map_ = std::move(other.map_);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<TopOfBookEntry>
|
||||||
|
TopOfBookCache::get(Book const& book) const
|
||||||
|
{
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
auto it = map_.find(book);
|
||||||
|
if (it == map_.end())
|
||||||
|
{
|
||||||
|
misses_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
hits_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
return it->second;
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
TopOfBookCache::record(Book const& book, uint256 const& firstPageKey, LedgerIndex seq)
|
||||||
|
{
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
auto& entry = map_[book];
|
||||||
|
entry.firstPageKey = firstPageKey;
|
||||||
|
entry.bestQuality = getQuality(firstPageKey);
|
||||||
|
entry.asOfLedger = seq;
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
TopOfBookCache::onOfferInsert(Book const& book, uint256 const& dirKey, LedgerIndex seq)
|
||||||
|
{
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
auto it = map_.find(book);
|
||||||
|
if (it == map_.end())
|
||||||
|
{
|
||||||
|
// No cached top — defer to the next reader, which populates lazily.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Lower keylet == better quality (pages share the book prefix, quality
|
||||||
|
// bits are encoded in the low bytes).
|
||||||
|
if (dirKey < it->second.firstPageKey)
|
||||||
|
{
|
||||||
|
it->second.firstPageKey = dirKey;
|
||||||
|
it->second.bestQuality = getQuality(dirKey);
|
||||||
|
it->second.asOfLedger = seq;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
TopOfBookCache::onOfferDelete(Book const& book, uint256 const& dirKey)
|
||||||
|
{
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
auto it = map_.find(book);
|
||||||
|
if (it == map_.end())
|
||||||
|
return;
|
||||||
|
if (it->second.firstPageKey == dirKey)
|
||||||
|
{
|
||||||
|
map_.erase(it);
|
||||||
|
invalidations_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
TopOfBookCache::invalidate(Book const& book)
|
||||||
|
{
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
if (map_.erase(book) != 0)
|
||||||
|
invalidations_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
TopOfBookCache::clear()
|
||||||
|
{
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
map_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t
|
||||||
|
TopOfBookCache::size() const
|
||||||
|
{
|
||||||
|
std::lock_guard lock(mutex_);
|
||||||
|
return map_.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace xrpl
|
||||||
@@ -5,17 +5,46 @@
|
|||||||
#include <xrpl/beast/utility/instrumentation.h>
|
#include <xrpl/beast/utility/instrumentation.h>
|
||||||
#include <xrpl/ledger/ApplyView.h>
|
#include <xrpl/ledger/ApplyView.h>
|
||||||
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
|
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
|
||||||
|
#include <xrpl/protocol/Book.h>
|
||||||
#include <xrpl/protocol/Indexes.h>
|
#include <xrpl/protocol/Indexes.h>
|
||||||
#include <xrpl/protocol/LedgerFormats.h>
|
#include <xrpl/protocol/LedgerFormats.h>
|
||||||
#include <xrpl/protocol/SField.h>
|
#include <xrpl/protocol/SField.h>
|
||||||
|
#include <xrpl/protocol/STAmount.h>
|
||||||
#include <xrpl/protocol/STArray.h> // IWYU pragma: keep
|
#include <xrpl/protocol/STArray.h> // IWYU pragma: keep
|
||||||
#include <xrpl/protocol/STLedgerEntry.h>
|
#include <xrpl/protocol/STLedgerEntry.h>
|
||||||
#include <xrpl/protocol/TER.h>
|
#include <xrpl/protocol/TER.h>
|
||||||
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
namespace xrpl {
|
namespace xrpl {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Reconstruct the Book this offer was placed on. The primary directory uses
|
||||||
|
// the offer's sfDomainID (if any); the open-book directory of a hybrid offer
|
||||||
|
// is the same in/out assets with no domain.
|
||||||
|
Book
|
||||||
|
primaryBookFromOffer(SLE const& sle)
|
||||||
|
{
|
||||||
|
auto const takerPays = sle.getFieldAmount(sfTakerPays);
|
||||||
|
auto const takerGets = sle.getFieldAmount(sfTakerGets);
|
||||||
|
std::optional<uint256> domain;
|
||||||
|
if (sle.isFieldPresent(sfDomainID))
|
||||||
|
domain = sle.getFieldH256(sfDomainID);
|
||||||
|
return Book{takerPays.asset(), takerGets.asset(), domain};
|
||||||
|
}
|
||||||
|
|
||||||
|
Book
|
||||||
|
openBookFromOffer(SLE const& sle)
|
||||||
|
{
|
||||||
|
auto const takerPays = sle.getFieldAmount(sfTakerPays);
|
||||||
|
auto const takerGets = sle.getFieldAmount(sfTakerGets);
|
||||||
|
return Book{takerPays.asset(), takerGets.asset(), std::nullopt};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
TER
|
TER
|
||||||
offerDelete(ApplyView& view, std::shared_ptr<SLE> const& sle, beast::Journal j)
|
offerDelete(ApplyView& view, std::shared_ptr<SLE> const& sle, beast::Journal j)
|
||||||
{
|
{
|
||||||
@@ -37,6 +66,12 @@ offerDelete(ApplyView& view, std::shared_ptr<SLE> const& sle, beast::Journal j)
|
|||||||
return tefBAD_LEDGER; // LCOV_EXCL_LINE
|
return tefBAD_LEDGER; // LCOV_EXCL_LINE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plan 8: notify the top-of-book cache that the primary book lost an
|
||||||
|
// offer at `uDirectory`. If this was the cached top page the cache
|
||||||
|
// invalidates; otherwise no-op. uDirectory is the first-page keylet of
|
||||||
|
// the offer's quality bucket — i.e. exactly what the cache stores.
|
||||||
|
view.notifyOfferDeleted(primaryBookFromOffer(*sle), uDirectory, offerIndex);
|
||||||
|
|
||||||
if (sle->isFieldPresent(sfAdditionalBooks))
|
if (sle->isFieldPresent(sfAdditionalBooks))
|
||||||
{
|
{
|
||||||
XRPL_ASSERT(
|
XRPL_ASSERT(
|
||||||
@@ -54,6 +89,10 @@ offerDelete(ApplyView& view, std::shared_ptr<SLE> const& sle, beast::Journal j)
|
|||||||
{
|
{
|
||||||
return tefBAD_LEDGER; // LCOV_EXCL_LINE
|
return tefBAD_LEDGER; // LCOV_EXCL_LINE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hybrid offers also live on the open (no-domain) book — notify
|
||||||
|
// that cache too.
|
||||||
|
view.notifyOfferDeleted(openBookFromOffer(*sle), dirIndex, offerIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,32 @@
|
|||||||
#include <xrpl/tx/paths/BookTip.h>
|
#include <xrpl/tx/paths/BookTip.h>
|
||||||
|
|
||||||
#include <xrpl/beast/utility/Journal.h>
|
#include <xrpl/beast/utility/Journal.h>
|
||||||
|
#include <xrpl/beast/utility/instrumentation.h>
|
||||||
#include <xrpl/ledger/ApplyView.h>
|
#include <xrpl/ledger/ApplyView.h>
|
||||||
|
#include <xrpl/ledger/OrderBookIndex.h>
|
||||||
|
#include <xrpl/ledger/TopOfBookCache.h>
|
||||||
#include <xrpl/ledger/helpers/DirectoryHelpers.h>
|
#include <xrpl/ledger/helpers/DirectoryHelpers.h>
|
||||||
#include <xrpl/ledger/helpers/OfferHelpers.h>
|
#include <xrpl/ledger/helpers/OfferHelpers.h>
|
||||||
#include <xrpl/protocol/Book.h>
|
#include <xrpl/protocol/Book.h>
|
||||||
#include <xrpl/protocol/Indexes.h>
|
#include <xrpl/protocol/Indexes.h>
|
||||||
|
#include <xrpl/protocol/SField.h>
|
||||||
#include <xrpl/protocol/STLedgerEntry.h>
|
#include <xrpl/protocol/STLedgerEntry.h>
|
||||||
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
namespace xrpl {
|
namespace xrpl {
|
||||||
|
|
||||||
BookTip::BookTip(ApplyView& view, Book const& book)
|
BookTip::BookTip(ApplyView& view, Book const& book)
|
||||||
: view_(view), book_(getBookBase(book)), end_(getQualityNext(book_))
|
: view_(view), originalBook_(book), book_(getBookBase(book)), end_(getQualityNext(book_))
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
bool
|
bool
|
||||||
BookTip::step(beast::Journal j)
|
BookTip::step(beast::Journal j)
|
||||||
{
|
{
|
||||||
|
bool const firstStep = !valid_;
|
||||||
|
|
||||||
if (valid_)
|
if (valid_)
|
||||||
{
|
{
|
||||||
if (entry_)
|
if (entry_)
|
||||||
@@ -29,12 +36,91 @@ BookTip::step(beast::Journal j)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plan 9: on the first step, ask the order-book index for an ordered
|
||||||
|
// snapshot of this book's offers. A returned vector is guaranteed complete
|
||||||
|
// (the index rebuilds from authoritative state on a miss), so iterating it
|
||||||
|
// is equivalent to the succ() walk — but O(1) per offer instead of an
|
||||||
|
// O(log N) trie re-walk. nullopt ⇒ no index ⇒ the succ() path below.
|
||||||
|
if (firstStep && OrderBookIndex::enabled())
|
||||||
|
{
|
||||||
|
if (auto snap = view_.orderedBook(originalBook_))
|
||||||
|
{
|
||||||
|
cursor_ = std::move(*snap);
|
||||||
|
cursorPos_ = 0;
|
||||||
|
useCursor_ = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useCursor_)
|
||||||
|
{
|
||||||
|
for (;;)
|
||||||
|
{
|
||||||
|
if (cursorPos_ >= cursor_.size())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
uint256 const offerKey = cursor_[cursorPos_++];
|
||||||
|
auto sle = view_.peek(keylet::offer(offerKey));
|
||||||
|
if (!sle)
|
||||||
|
// The snapshot is from the (immutable) base index; this offer
|
||||||
|
// was buffered-deleted by the sandbox (e.g. a pre-crossing
|
||||||
|
// cancel). Skip it — the succ() walk wouldn't see it either.
|
||||||
|
continue;
|
||||||
|
|
||||||
|
index_ = offerKey;
|
||||||
|
dir_ = sle->getFieldH256(sfBookDirectory);
|
||||||
|
quality_ = Quality(getQuality(dir_));
|
||||||
|
entry_ = std::move(sle);
|
||||||
|
valid_ = true;
|
||||||
|
|
||||||
|
// Cursor order must be best-quality-first, exactly like succ().
|
||||||
|
XRPL_ASSERT(
|
||||||
|
getQuality(dir_) >= lastCursorQuality_,
|
||||||
|
"xrpl::BookTip::step : order-book cursor yields non-decreasing quality");
|
||||||
|
lastCursorQuality_ = getQuality(dir_);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool firstIter = firstStep;
|
||||||
for (;;)
|
for (;;)
|
||||||
{
|
{
|
||||||
// See if there's an entry at or worse than current quality. Notice
|
// See if there's an entry at or worse than current quality. Notice
|
||||||
// that the quality is encoded only in the index of the first page
|
// that the quality is encoded only in the index of the first page
|
||||||
// of a directory.
|
// of a directory.
|
||||||
auto const firstPage = view_.succ(book_, end_);
|
std::optional<uint256> firstPage;
|
||||||
|
bool fromCache = false;
|
||||||
|
|
||||||
|
if (firstIter && TopOfBookCache::enabled())
|
||||||
|
{
|
||||||
|
if (auto const cached = view_.topOfBookFirstPage(originalBook_))
|
||||||
|
{
|
||||||
|
firstPage = *cached;
|
||||||
|
fromCache = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!firstPage)
|
||||||
|
{
|
||||||
|
firstPage = view_.succ(book_, end_);
|
||||||
|
if (firstIter && firstPage && TopOfBookCache::enabled())
|
||||||
|
view_.recordTopOfBook(originalBook_, *firstPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifndef NDEBUG
|
||||||
|
// Differential gate (Plan 8 P8.7): in debug builds every cache hit
|
||||||
|
// is shadow-verified against a fresh successor walk. Divergence
|
||||||
|
// here is a bug in the invalidation logic, not a fallback.
|
||||||
|
if (fromCache && firstPage)
|
||||||
|
{
|
||||||
|
auto const verified = view_.succ(book_, end_);
|
||||||
|
XRPL_ASSERT(
|
||||||
|
verified == firstPage,
|
||||||
|
"BookTip::step : top-of-book cache hit diverges from succ()");
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
firstIter = false;
|
||||||
|
|
||||||
if (!firstPage)
|
if (!firstPage)
|
||||||
return false;
|
return false;
|
||||||
@@ -60,6 +146,8 @@ BookTip::step(beast::Journal j)
|
|||||||
|
|
||||||
// There should never be an empty directory but just in case,
|
// There should never be an empty directory but just in case,
|
||||||
// we handle that case by advancing to the next directory.
|
// we handle that case by advancing to the next directory.
|
||||||
|
// Also covers the case where a stale cache hit returned a
|
||||||
|
// page that no longer has any indexes.
|
||||||
book_ = *firstPage;
|
book_ = *firstPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,12 @@ TOfferStreamBase<TIn, TOut>::erase(ApplyView& view)
|
|||||||
p->setFieldV256(sfIndexes, v);
|
p->setFieldV256(sfIndexes, v);
|
||||||
view.update(p);
|
view.update(p);
|
||||||
|
|
||||||
|
// Plan 9: this stale-entry cleanup strips sfIndexes directly (not via
|
||||||
|
// dirRemove, which would be a protocol-breaking change), so it bypasses
|
||||||
|
// the usual offerDelete notification. Notify the order-book index here so
|
||||||
|
// it doesn't retain a phantom key. Auxiliary only — no ledger-state change.
|
||||||
|
view.notifyOfferDeleted(book_, tip_.dir(), tip_.index());
|
||||||
|
|
||||||
JLOG(j_.trace()) << "Missing offer " << tip_.index() << " removed from directory "
|
JLOG(j_.trace()) << "Missing offer " << tip_.index() << " removed from directory "
|
||||||
<< tip_.dir();
|
<< tip_.dir();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -593,6 +593,11 @@ OfferCreate::applyHybrid(
|
|||||||
if (!bookExists)
|
if (!bookExists)
|
||||||
ctx_.registry.get().getOrderBookDB().addOrderBook(book);
|
ctx_.registry.get().getOrderBookDB().addOrderBook(book);
|
||||||
|
|
||||||
|
// Plan 8: notify the top-of-book cache that the open book just got a
|
||||||
|
// new offer at `dir.key`. The cache updates its top only if this is
|
||||||
|
// at-or-better than the current cached top; otherwise no-op.
|
||||||
|
sb.notifyOfferInserted(book, dir.key, offerKey.key);
|
||||||
|
|
||||||
sleOffer->setFieldArray(sfAdditionalBooks, bookArr);
|
sleOffer->setFieldArray(sfAdditionalBooks, bookArr);
|
||||||
return tesSUCCESS;
|
return tesSUCCESS;
|
||||||
}
|
}
|
||||||
@@ -915,6 +920,11 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel)
|
|||||||
// LCOV_EXCL_STOP
|
// LCOV_EXCL_STOP
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plan 8: notify the top-of-book cache that `book` got a new offer at
|
||||||
|
// `dir.key`. The cache updates its top only if this is at-or-better
|
||||||
|
// than the current cached top; otherwise no-op.
|
||||||
|
sb.notifyOfferInserted(book, dir.key, offerIndex.key);
|
||||||
|
|
||||||
auto sleOffer = std::make_shared<SLE>(offerIndex);
|
auto sleOffer = std::make_shared<SLE>(offerIndex);
|
||||||
sleOffer->setAccountID(sfAccount, accountID_);
|
sleOffer->setAccountID(sfAccount, accountID_);
|
||||||
sleOffer->setFieldU32(sfSequence, offerSequence);
|
sleOffer->setFieldU32(sfSequence, offerSequence);
|
||||||
|
|||||||
135
src/test/app/OrderBookCrossing_test.cpp
Normal file
135
src/test/app/OrderBookCrossing_test.cpp
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
#include <test/jtx/Account.h>
|
||||||
|
#include <test/jtx/Env.h>
|
||||||
|
#include <test/jtx/amount.h>
|
||||||
|
#include <test/jtx/offer.h>
|
||||||
|
#include <test/jtx/pay.h>
|
||||||
|
#include <test/jtx/trust.h>
|
||||||
|
|
||||||
|
#include <xrpl/beast/unit_test/suite.h>
|
||||||
|
#include <xrpl/json/json_value.h>
|
||||||
|
#include <xrpl/ledger/OrderBookIndex.h>
|
||||||
|
#include <xrpl/protocol/UintTypes.h>
|
||||||
|
#include <xrpl/protocol/jss.h>
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace xrpl::test {
|
||||||
|
|
||||||
|
/** Bit-exactness gate for the Plan 9 order-book index seam: a scripted
|
||||||
|
crossing scenario must produce an identical sequence of ledger hashes with
|
||||||
|
the index enabled (BookTip iterates the in-memory cursor) and disabled
|
||||||
|
(BookTip walks the SHAMap with succ()). Any divergence in the cursor's order
|
||||||
|
or contents changes consumed offers/amounts and therefore the ledger hash. */
|
||||||
|
class OrderBookCrossing_test : public beast::unit_test::Suite
|
||||||
|
{
|
||||||
|
// Run a deterministic crossing scenario and return the ledger hash after
|
||||||
|
// every close. The scenario exercises the cursor-specific paths:
|
||||||
|
// multi-quality books, a multi-offer (shared-quality) level, an unfunded
|
||||||
|
// offer, partial fills, and a pre-crossing cancel (peek-null-skip).
|
||||||
|
std::vector<uint256>
|
||||||
|
runScenario()
|
||||||
|
{
|
||||||
|
using namespace jtx;
|
||||||
|
Env env{*this};
|
||||||
|
std::vector<uint256> hashes;
|
||||||
|
// accountHash is the consensus state root — it reflects every crossing
|
||||||
|
// effect (consumed offers, balances, directories). If the cursor and
|
||||||
|
// succ() paths diverge at all, this differs.
|
||||||
|
auto snap = [&] { hashes.push_back(env.closed()->header().accountHash); };
|
||||||
|
|
||||||
|
auto const gw = Account{"gw"};
|
||||||
|
auto const USD = gw["USD"];
|
||||||
|
Account const alice{"alice"}; // maker, spread of qualities
|
||||||
|
Account const bob{"bob"}; // maker, shared-quality level
|
||||||
|
Account const carol{"carol"}; // maker, becomes unfunded
|
||||||
|
Account const dave{"dave"}; // taker
|
||||||
|
|
||||||
|
env.fund(XRP(10'000'000), gw, alice, bob, carol, dave);
|
||||||
|
env.close();
|
||||||
|
snap();
|
||||||
|
env.trust(USD(100'000'000), alice, bob, carol, dave);
|
||||||
|
env.close();
|
||||||
|
env(pay(gw, alice, USD(1'000'000)));
|
||||||
|
env(pay(gw, bob, USD(1'000'000)));
|
||||||
|
env(pay(gw, carol, USD(1'000'000)));
|
||||||
|
env.close();
|
||||||
|
snap();
|
||||||
|
|
||||||
|
// alice: 8 distinct qualities. bob: 4 offers at one shared quality
|
||||||
|
// (a multi-entry level). carol: one offer she will defund.
|
||||||
|
for (int i = 0; i < 8; ++i)
|
||||||
|
env(offer(alice, XRP(500 + i), USD(100)));
|
||||||
|
for (int i = 0; i < 4; ++i)
|
||||||
|
env(offer(bob, XRP(503), USD(100)));
|
||||||
|
env(offer(carol, XRP(501), USD(100)));
|
||||||
|
env.close();
|
||||||
|
snap();
|
||||||
|
|
||||||
|
// Defund carol: move her USD away so her resting offer is unfunded at
|
||||||
|
// cross time (exercises the unfunded-skip path through the cursor).
|
||||||
|
env(pay(carol, gw, USD(1'000'000)));
|
||||||
|
env.close();
|
||||||
|
snap();
|
||||||
|
|
||||||
|
// dave places an offer, then cancels it via an OfferCreate carrying
|
||||||
|
// OfferSequence (pre-crossing delete → cursor peek-null-skip path).
|
||||||
|
auto const daveOfferSeq = env.seq(dave);
|
||||||
|
env(offer(dave, USD(100), XRP(2'000))); // far from market: rests
|
||||||
|
env.close();
|
||||||
|
snap();
|
||||||
|
|
||||||
|
// dave crosses: partial and full fills across alice/bob/carol levels.
|
||||||
|
env(offer(dave, USD(250), XRP(1'255)));
|
||||||
|
env.close();
|
||||||
|
snap();
|
||||||
|
env(offer(dave, USD(500), XRP(2'520)));
|
||||||
|
env.close();
|
||||||
|
snap();
|
||||||
|
|
||||||
|
// A crossing OfferCreate that also cancels dave's resting offer.
|
||||||
|
auto cross = offer(dave, USD(100), XRP(505));
|
||||||
|
cross[jss::OfferSequence] = daveOfferSeq;
|
||||||
|
env(cross);
|
||||||
|
env.close();
|
||||||
|
snap();
|
||||||
|
|
||||||
|
return hashes;
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
testIndexMatchesBaseline()
|
||||||
|
{
|
||||||
|
testcase("ledger hashes identical with order-book index on vs off");
|
||||||
|
|
||||||
|
OrderBookIndex::setEnabled(false);
|
||||||
|
auto const baseline = runScenario();
|
||||||
|
|
||||||
|
OrderBookIndex::setEnabled(true);
|
||||||
|
auto const withIndex = runScenario();
|
||||||
|
|
||||||
|
OrderBookIndex::setEnabled(true); // restore default
|
||||||
|
|
||||||
|
BEAST_EXPECT(baseline.size() == withIndex.size());
|
||||||
|
bool identical = baseline.size() == withIndex.size();
|
||||||
|
for (std::size_t i = 0; i < baseline.size() && i < withIndex.size(); ++i)
|
||||||
|
{
|
||||||
|
if (baseline[i] != withIndex[i])
|
||||||
|
{
|
||||||
|
identical = false;
|
||||||
|
log << " ledger-hash divergence at close " << i << "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BEAST_EXPECT(identical);
|
||||||
|
}
|
||||||
|
|
||||||
|
public:
|
||||||
|
void
|
||||||
|
run() override
|
||||||
|
{
|
||||||
|
testIndexMatchesBaseline();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
BEAST_DEFINE_TESTSUITE(OrderBookCrossing, app, xrpl);
|
||||||
|
|
||||||
|
} // namespace xrpl::test
|
||||||
512
src/test/app/TopOfBookCache_bench.cpp
Normal file
512
src/test/app/TopOfBookCache_bench.cpp
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
#include <test/jtx/Account.h>
|
||||||
|
#include <test/jtx/Env.h>
|
||||||
|
#include <test/jtx/amount.h>
|
||||||
|
#include <test/jtx/fee.h>
|
||||||
|
#include <test/jtx/offer.h>
|
||||||
|
#include <test/jtx/pay.h>
|
||||||
|
#include <test/jtx/seq.h>
|
||||||
|
#include <test/jtx/trust.h>
|
||||||
|
|
||||||
|
#include <xrpl/beast/unit_test/suite.h>
|
||||||
|
#include <xrpl/ledger/ApplyView.h>
|
||||||
|
#include <xrpl/ledger/ApplyViewImpl.h>
|
||||||
|
#include <xrpl/ledger/OpenView.h>
|
||||||
|
#include <xrpl/ledger/TopOfBookCache.h>
|
||||||
|
#include <xrpl/protocol/Book.h>
|
||||||
|
#include <xrpl/protocol/Issue.h>
|
||||||
|
#include <xrpl/tx/apply.h>
|
||||||
|
#include <xrpl/tx/paths/BookTip.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace xrpl::test {
|
||||||
|
|
||||||
|
/** Level 1 micro-benchmark for the top-of-book cache.
|
||||||
|
|
||||||
|
A/Bs the cache via its runtime kill switch (TopOfBookCache::setEnabled) over
|
||||||
|
a deep order book, entirely in-process (no network, no synced ledger).
|
||||||
|
|
||||||
|
Two arms:
|
||||||
|
|
||||||
|
- readArm (isolated): drives BookTip's first-step top-of-book read against
|
||||||
|
an OpenView this benchmark constructs and OWNS, so cache counters are
|
||||||
|
reliable (the open-ledger apply path copies the OpenView per tx and the
|
||||||
|
copy ctor resets counters, so counters read off env.current() are not).
|
||||||
|
This isolates the optimized primitive (succ() walk -> hash probe). It is a
|
||||||
|
BEST-CASE, hot-entry read number — not an end-to-end throughput figure.
|
||||||
|
|
||||||
|
- e2eArm (end-to-end): times a batch of real crossing OfferCreates through
|
||||||
|
the full Env apply path (real offer consumption => invalidation/repopulate
|
||||||
|
churn). Timing only; also captures the cache's own overhead (the OpenView
|
||||||
|
copy on every modify()). Answers "does the isolated saving show up at all,
|
||||||
|
net of overhead". Realistic hit-rate under MainNet-like mixed load is a
|
||||||
|
later, heavier exercise (Level 1.5 / Level 3), not measured here.
|
||||||
|
|
||||||
|
Registered MANUAL — never runs in normal CI. Invoke explicitly:
|
||||||
|
rippled --unittest=TopOfBookCacheBench
|
||||||
|
*/
|
||||||
|
class TopOfBookCacheBench_test : public beast::unit_test::Suite
|
||||||
|
{
|
||||||
|
using clock = std::chrono::steady_clock;
|
||||||
|
|
||||||
|
// Median of repeated samples — robust to scheduler noise.
|
||||||
|
static double
|
||||||
|
median(std::vector<double> v)
|
||||||
|
{
|
||||||
|
std::sort(v.begin(), v.end());
|
||||||
|
return v.empty() ? 0.0 : v[v.size() / 2];
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ReadArm
|
||||||
|
{
|
||||||
|
double nsPerRead{0};
|
||||||
|
std::uint64_t hits{0};
|
||||||
|
std::uint64_t misses{0};
|
||||||
|
std::uint64_t invalidations{0};
|
||||||
|
bool foundTop{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build a deep order book (XRP <-> USD), close it into the LCL, and return
|
||||||
|
// the Book those offers populate. `pages` distinct qualities => `pages`
|
||||||
|
// directory pages.
|
||||||
|
static Book
|
||||||
|
buildDeepBook(jtx::Env& env, jtx::Account const& gw, int pages)
|
||||||
|
{
|
||||||
|
using namespace jtx;
|
||||||
|
auto const USD = gw["USD"];
|
||||||
|
Account const maker{"maker"};
|
||||||
|
|
||||||
|
env.fund(XRP(1'000'000), gw, maker);
|
||||||
|
env.close();
|
||||||
|
env.trust(USD(10'000'000), maker);
|
||||||
|
env.close();
|
||||||
|
env(pay(gw, maker, USD(1'000'000)));
|
||||||
|
env.close();
|
||||||
|
|
||||||
|
// Each offer: maker receives takerPays (XRP), gives takerGets (USD).
|
||||||
|
// Distinct takerPays => distinct quality => distinct directory page.
|
||||||
|
for (int i = 0; i < pages; ++i)
|
||||||
|
env(offer(maker, XRP(500 + i), USD(100)));
|
||||||
|
env.close();
|
||||||
|
|
||||||
|
// Book{in = takerPays.asset(), out = takerGets.asset()} — see
|
||||||
|
// OfferCreate.cpp:570.
|
||||||
|
return Book{xrpIssue(), USD.issue(), std::nullopt};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Isolated read-path arm. Owns the OpenView so counters are trustworthy.
|
||||||
|
ReadArm
|
||||||
|
runReadArm(jtx::Env& env, Book const& book, bool cacheEnabled, std::size_t reads)
|
||||||
|
{
|
||||||
|
TopOfBookCache::setEnabled(cacheEnabled);
|
||||||
|
|
||||||
|
// Fresh owned view per arm => clean counters (no reset API otherwise).
|
||||||
|
OpenView ov(kOpenLedger, env.closed()->rules(), env.closed());
|
||||||
|
|
||||||
|
// One read = fresh BookTip + a single step() = one top-of-book probe.
|
||||||
|
// BookTip's first step is read-only (it deletes only from the 2nd step
|
||||||
|
// on), so a single ApplyView can be reused across reads.
|
||||||
|
ApplyViewImpl av(&ov, TapNone);
|
||||||
|
|
||||||
|
ReadArm r;
|
||||||
|
{
|
||||||
|
BookTip bt(av, book);
|
||||||
|
r.foundTop = bt.step(env.journal) && bt.entry() != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto const once = [&] {
|
||||||
|
for (std::size_t i = 0; i < reads; ++i)
|
||||||
|
{
|
||||||
|
BookTip bt(av, book);
|
||||||
|
bt.step(env.journal);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
once(); // warmup (also populates the cache in the enabled arm)
|
||||||
|
|
||||||
|
std::vector<double> samples;
|
||||||
|
for (int rep = 0; rep < 5; ++rep)
|
||||||
|
{
|
||||||
|
auto const t0 = clock::now();
|
||||||
|
once();
|
||||||
|
auto const t1 = clock::now();
|
||||||
|
samples.push_back(
|
||||||
|
static_cast<double>(
|
||||||
|
std::chrono::duration_cast<std::chrono::nanoseconds>(t1 - t0).count()) /
|
||||||
|
static_cast<double>(reads));
|
||||||
|
}
|
||||||
|
|
||||||
|
r.nsPerRead = median(std::move(samples));
|
||||||
|
r.hits = ov.topOfBookCache().hits();
|
||||||
|
r.misses = ov.topOfBookCache().misses();
|
||||||
|
r.invalidations = ov.topOfBookCache().invalidations();
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
testReadPath()
|
||||||
|
{
|
||||||
|
testcase("Arm 1: isolated top-of-book read (owned OpenView)");
|
||||||
|
using namespace jtx;
|
||||||
|
|
||||||
|
// Isolate the TopOfBookCache read path: the order-book index, when on,
|
||||||
|
// supersedes the cache in BookTip (cursor instead of cache+succ), so it
|
||||||
|
// must be off for this arm to measure the cache.
|
||||||
|
OrderBookIndex::setEnabled(false);
|
||||||
|
|
||||||
|
int const pages = 64;
|
||||||
|
std::size_t const reads = 200'000;
|
||||||
|
|
||||||
|
Env env{*this};
|
||||||
|
auto const book = buildDeepBook(env, Account{"gw"}, pages);
|
||||||
|
|
||||||
|
auto const off = runReadArm(env, book, /*cacheEnabled=*/false, reads);
|
||||||
|
auto const on = runReadArm(env, book, /*cacheEnabled=*/true, reads);
|
||||||
|
TopOfBookCache::setEnabled(true); // restore default
|
||||||
|
|
||||||
|
BEAST_EXPECT(off.foundTop);
|
||||||
|
BEAST_EXPECT(on.foundTop);
|
||||||
|
// Disabled arm never consults the cache.
|
||||||
|
BEAST_EXPECT(off.hits == 0 && off.misses == 0);
|
||||||
|
// Enabled arm: 1 cold miss, the rest hits.
|
||||||
|
BEAST_EXPECT(on.hits > 0);
|
||||||
|
BEAST_EXPECT(on.misses >= 1);
|
||||||
|
BEAST_EXPECT(on.invalidations == 0);
|
||||||
|
|
||||||
|
double const speedup = on.nsPerRead > 0 ? off.nsPerRead / on.nsPerRead : 0.0;
|
||||||
|
|
||||||
|
#ifndef NDEBUG
|
||||||
|
log << "\n*** DEBUG build: BookTip's differential gate shadow-verifies "
|
||||||
|
"every cache hit with an extra succ() walk, so the cache-ON path "
|
||||||
|
"does MORE work here. Arm 1 timing is only meaningful in a "
|
||||||
|
"Release (NDEBUG) build; counters below are valid regardless. ***\n";
|
||||||
|
#endif
|
||||||
|
|
||||||
|
log << "\n=== Arm 1: isolated read (best-case, hot entry) ===\n"
|
||||||
|
<< " book pages : " << pages << "\n"
|
||||||
|
<< " reads / sample : " << reads << "\n"
|
||||||
|
<< " cache OFF ns/read : " << off.nsPerRead << "\n"
|
||||||
|
<< " cache ON ns/read : " << on.nsPerRead << "\n"
|
||||||
|
<< " speedup : " << speedup << "x\n"
|
||||||
|
<< " cache ON hits/miss : " << on.hits << " / " << on.misses << "\n"
|
||||||
|
<< std::endl;
|
||||||
|
|
||||||
|
OrderBookIndex::setEnabled(true); // restore default
|
||||||
|
}
|
||||||
|
|
||||||
|
// End-to-end arm: time real crossing offers through the full apply path.
|
||||||
|
double
|
||||||
|
runE2EArm(bool cacheEnabled, int pages, int crossings)
|
||||||
|
{
|
||||||
|
using namespace jtx;
|
||||||
|
TopOfBookCache::setEnabled(cacheEnabled);
|
||||||
|
|
||||||
|
Env env{*this};
|
||||||
|
auto const gw = Account{"gw"};
|
||||||
|
auto const USD = gw["USD"];
|
||||||
|
buildDeepBook(env, gw, pages);
|
||||||
|
|
||||||
|
// Taker buys USD with XRP, crossing the maker's resting offers.
|
||||||
|
Account const taker{"taker"};
|
||||||
|
env.fund(XRP(1'000'000), taker);
|
||||||
|
env.close();
|
||||||
|
env.trust(USD(10'000'000), taker);
|
||||||
|
env.close();
|
||||||
|
|
||||||
|
auto const t0 = clock::now();
|
||||||
|
for (int i = 0; i < crossings; ++i)
|
||||||
|
{
|
||||||
|
env(offer(taker, USD(100), XRP(500 + (i % pages))));
|
||||||
|
if ((i % 10) == 9)
|
||||||
|
env.close();
|
||||||
|
}
|
||||||
|
env.close();
|
||||||
|
auto const t1 = clock::now();
|
||||||
|
|
||||||
|
return static_cast<double>(
|
||||||
|
std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count()) /
|
||||||
|
crossings;
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
testEndToEnd()
|
||||||
|
{
|
||||||
|
testcase("Arm 2: end-to-end crossing throughput (timing only)");
|
||||||
|
|
||||||
|
int const pages = 64;
|
||||||
|
int const crossings = 300;
|
||||||
|
|
||||||
|
double const off = runE2EArm(/*cacheEnabled=*/false, pages, crossings);
|
||||||
|
double const on = runE2EArm(/*cacheEnabled=*/true, pages, crossings);
|
||||||
|
TopOfBookCache::setEnabled(true); // restore default
|
||||||
|
|
||||||
|
BEAST_EXPECT(off > 0 && on > 0);
|
||||||
|
|
||||||
|
log << "\n=== Arm 2: end-to-end crossing (full apply path, real churn) ===\n"
|
||||||
|
<< " book pages : " << pages << "\n"
|
||||||
|
<< " crossing offers : " << crossings << "\n"
|
||||||
|
<< " cache OFF us/cross : " << off << "\n"
|
||||||
|
<< " cache ON us/cross : " << on << "\n"
|
||||||
|
<< " delta : " << (off - on) << " us/cross"
|
||||||
|
<< " (note: dominated by tx machinery + cache copy overhead)\n"
|
||||||
|
<< std::endl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profiling arm: measure PURE crossing-apply cost with NO ledger close.
|
||||||
|
// env.close() runs full consensus close (flushDirty hashing + SQLite ledger
|
||||||
|
// writes); Arm 2 closed every 10 offers, contaminating its per-crossing
|
||||||
|
// number. Here we pre-sign crossing OfferCreates and replay them against a
|
||||||
|
// fresh owned OpenView per rep (each rep starts with the full book), timing
|
||||||
|
// only xrpl::apply (preflight + preclaim + doApply). Long enough total work
|
||||||
|
// to attach `sample`/Instruments to the running process.
|
||||||
|
void
|
||||||
|
testCrossingApplyProfile()
|
||||||
|
{
|
||||||
|
testcase("Arm 3: pure crossing-apply cost (no ledger close)");
|
||||||
|
using namespace jtx;
|
||||||
|
|
||||||
|
int const pages = 64;
|
||||||
|
int const crossPerRep = 50; // crossings applied per fresh book
|
||||||
|
// BENCH_PROFILE=1 cranks reps so the apply loop runs ~30s for `sample`.
|
||||||
|
bool const profiling = std::getenv("BENCH_PROFILE") != nullptr;
|
||||||
|
int const reps = profiling ? 5000 : 400;
|
||||||
|
|
||||||
|
Env env{*this};
|
||||||
|
auto const gw = Account{"gw"};
|
||||||
|
auto const USD = gw["USD"];
|
||||||
|
buildDeepBook(env, gw, pages);
|
||||||
|
|
||||||
|
Account const taker{"taker"};
|
||||||
|
env.fund(XRP(10'000'000), taker);
|
||||||
|
env.close();
|
||||||
|
env.trust(USD(100'000'000), taker);
|
||||||
|
env.close();
|
||||||
|
|
||||||
|
// Pre-sign the crossing OfferCreates once, with explicit increasing
|
||||||
|
// sequences starting at taker's current seq. Each fresh accum resets
|
||||||
|
// taker to that same seq, so the identical signed set replays cleanly.
|
||||||
|
std::uint32_t const startSeq = env.seq(taker);
|
||||||
|
std::vector<std::shared_ptr<STTx const>> txns;
|
||||||
|
txns.reserve(crossPerRep);
|
||||||
|
for (int i = 0; i < crossPerRep; ++i)
|
||||||
|
{
|
||||||
|
auto jtx = env.jt(
|
||||||
|
offer(taker, USD(100), XRP(500 + (i % pages))),
|
||||||
|
Seq(startSeq + i),
|
||||||
|
Fee(100));
|
||||||
|
txns.push_back(jtx.stx);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto const base = env.current(); // open view over the closed book
|
||||||
|
|
||||||
|
std::size_t applied = 0, crossed = 0;
|
||||||
|
std::vector<double> samples;
|
||||||
|
for (int rep = 0; rep < reps; ++rep)
|
||||||
|
{
|
||||||
|
OpenView accum(kOpenLedger, base->rules(), base);
|
||||||
|
auto const t0 = clock::now();
|
||||||
|
for (auto const& tx : txns)
|
||||||
|
{
|
||||||
|
auto const r = apply(env.app(), accum, *tx, TapNone, env.journal);
|
||||||
|
if (rep == 0)
|
||||||
|
{
|
||||||
|
++applied;
|
||||||
|
if (r.applied && isTesSuccess(r.ter))
|
||||||
|
++crossed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auto const t1 = clock::now();
|
||||||
|
samples.push_back(
|
||||||
|
static_cast<double>(
|
||||||
|
std::chrono::duration_cast<std::chrono::nanoseconds>(t1 - t0).count()) /
|
||||||
|
crossPerRep / 1000.0); // us/crossing
|
||||||
|
}
|
||||||
|
|
||||||
|
BEAST_EXPECT(applied == static_cast<std::size_t>(crossPerRep));
|
||||||
|
BEAST_EXPECT(crossed > 0);
|
||||||
|
|
||||||
|
log << "\n=== Arm 3: pure crossing-apply (no ledger close) ===\n"
|
||||||
|
<< " book pages : " << pages << "\n"
|
||||||
|
<< " crossings / rep : " << crossPerRep << "\n"
|
||||||
|
<< " reps : " << reps << "\n"
|
||||||
|
<< " tesSUCCESS (rep 0) : " << crossed << " / " << applied << "\n"
|
||||||
|
<< " median us / crossing : " << median(samples) << "\n"
|
||||||
|
<< " (compare to Arm 2's ~780us which INCLUDED ledger close)\n"
|
||||||
|
<< std::endl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arm 4 (Plan 9 headline): pure crossing-apply with the order-book index
|
||||||
|
// ON vs OFF. OFF = baseline succ()-per-offer walk; ON = BookTip iterates the
|
||||||
|
// in-memory cursor (index pre-seeded per rep, untimed, modelling the
|
||||||
|
// maintained steady state). Same owned-OpenView / no-ledger-close method as
|
||||||
|
// Arm 3, so the delta isolates the succ() cost the cursor removes.
|
||||||
|
void
|
||||||
|
testCrossingIndexArm()
|
||||||
|
{
|
||||||
|
testcase("Arm 4: crossing-apply, order-book index ON vs OFF");
|
||||||
|
using namespace jtx;
|
||||||
|
|
||||||
|
int const pages = 64;
|
||||||
|
int const crossPerRep = 50;
|
||||||
|
int const reps = 400;
|
||||||
|
|
||||||
|
Env env{*this};
|
||||||
|
auto const gw = Account{"gw"};
|
||||||
|
auto const USD = gw["USD"];
|
||||||
|
auto const book = buildDeepBook(env, gw, pages);
|
||||||
|
|
||||||
|
Account const taker{"taker"};
|
||||||
|
env.fund(XRP(10'000'000), taker);
|
||||||
|
env.close();
|
||||||
|
env.trust(USD(100'000'000), taker);
|
||||||
|
env.close();
|
||||||
|
|
||||||
|
std::uint32_t const startSeq = env.seq(taker);
|
||||||
|
std::vector<std::shared_ptr<STTx const>> txns;
|
||||||
|
txns.reserve(crossPerRep);
|
||||||
|
for (int i = 0; i < crossPerRep; ++i)
|
||||||
|
txns.push_back(
|
||||||
|
env.jt(offer(taker, USD(100), XRP(500 + (i % pages))), Seq(startSeq + i), Fee(100))
|
||||||
|
.stx);
|
||||||
|
|
||||||
|
auto const base = env.current();
|
||||||
|
|
||||||
|
auto runArm = [&](bool indexEnabled) {
|
||||||
|
OrderBookIndex::setEnabled(indexEnabled);
|
||||||
|
std::vector<double> samples;
|
||||||
|
for (int rep = 0; rep < reps; ++rep)
|
||||||
|
{
|
||||||
|
OpenView accum(kOpenLedger, base->rules(), base);
|
||||||
|
// Warm the maintained index outside the timed region (models the
|
||||||
|
// steady state where it is kept in sync, not rebuilt per cross).
|
||||||
|
if (indexEnabled)
|
||||||
|
accum.orderBookIndex().rebuildBook(accum, book);
|
||||||
|
auto const t0 = clock::now();
|
||||||
|
for (auto const& tx : txns)
|
||||||
|
apply(env.app(), accum, *tx, TapNone, env.journal);
|
||||||
|
auto const t1 = clock::now();
|
||||||
|
samples.push_back(
|
||||||
|
static_cast<double>(
|
||||||
|
std::chrono::duration_cast<std::chrono::nanoseconds>(t1 - t0).count()) /
|
||||||
|
crossPerRep / 1000.0);
|
||||||
|
}
|
||||||
|
return median(samples);
|
||||||
|
};
|
||||||
|
|
||||||
|
double const off = runArm(false);
|
||||||
|
double const on = runArm(true);
|
||||||
|
OrderBookIndex::setEnabled(true); // restore default
|
||||||
|
|
||||||
|
double const speedup = on > 0 ? off / on : 0.0;
|
||||||
|
|
||||||
|
log << "\n=== Arm 4: crossing-apply, index ON vs OFF (no ledger close) ===\n"
|
||||||
|
<< " book pages : " << pages << "\n"
|
||||||
|
<< " crossings / rep : " << crossPerRep << "\n"
|
||||||
|
<< " index OFF us/crossing : " << off << " (baseline succ() walk)\n"
|
||||||
|
<< " index ON us/crossing : " << on << " (in-memory cursor)\n"
|
||||||
|
<< " speedup : " << speedup << "x\n"
|
||||||
|
<< std::endl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arm 5 (P9.6 headline): the REALISTIC per-tx path. Each crossing is applied
|
||||||
|
// to a fresh COW copy of the prior OpenView — exactly what OpenLedger::modify
|
||||||
|
// does per transaction — so the persistent index warms via the clone (no
|
||||||
|
// pre-seed) and the clone cost is INCLUDED in the timing. Index ON should now
|
||||||
|
// beat OFF on this path (the warm cursor amortizes the one cold rebuild),
|
||||||
|
// unlike the non-persistent index which cold-started and rebuilt every tx.
|
||||||
|
void
|
||||||
|
testCrossingWarmArm()
|
||||||
|
{
|
||||||
|
testcase("Arm 5: realistic per-tx-copy crossing, index ON vs OFF");
|
||||||
|
using namespace jtx;
|
||||||
|
|
||||||
|
int const pages = 400; // deep enough to stay populated across the batch
|
||||||
|
int const crossPerRep = 100;
|
||||||
|
int const reps = 200;
|
||||||
|
|
||||||
|
Env env{*this};
|
||||||
|
auto const gw = Account{"gw"};
|
||||||
|
auto const USD = gw["USD"];
|
||||||
|
auto const book = buildDeepBook(env, gw, pages);
|
||||||
|
|
||||||
|
Account const taker{"taker"};
|
||||||
|
env.fund(XRP(100'000'000), taker);
|
||||||
|
env.close();
|
||||||
|
env.trust(USD(1'000'000'000), taker);
|
||||||
|
env.close();
|
||||||
|
|
||||||
|
std::uint32_t const startSeq = env.seq(taker);
|
||||||
|
std::vector<std::shared_ptr<STTx const>> txns;
|
||||||
|
txns.reserve(crossPerRep);
|
||||||
|
for (int i = 0; i < crossPerRep; ++i)
|
||||||
|
txns.push_back(
|
||||||
|
env.jt(offer(taker, USD(100), XRP(500 + (i % pages))), Seq(startSeq + i), Fee(100))
|
||||||
|
.stx);
|
||||||
|
|
||||||
|
auto const base = env.current();
|
||||||
|
|
||||||
|
auto runArm = [&](bool indexEnabled) {
|
||||||
|
OrderBookIndex::setEnabled(indexEnabled);
|
||||||
|
std::vector<double> samples;
|
||||||
|
for (int rep = 0; rep < reps; ++rep)
|
||||||
|
{
|
||||||
|
// Fresh cold OpenView over the closed book (index empty).
|
||||||
|
auto current = std::make_shared<OpenView>(kOpenLedger, base->rules(), base);
|
||||||
|
auto const t0 = clock::now();
|
||||||
|
for (auto const& tx : txns)
|
||||||
|
{
|
||||||
|
// The per-tx COW copy (clones the persistent index) — exactly
|
||||||
|
// what OpenLedger::modify does per transaction.
|
||||||
|
auto next = std::make_shared<OpenView>(*current);
|
||||||
|
apply(env.app(), *next, *tx, TapNone, env.journal);
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
auto const t1 = clock::now();
|
||||||
|
samples.push_back(
|
||||||
|
static_cast<double>(
|
||||||
|
std::chrono::duration_cast<std::chrono::nanoseconds>(t1 - t0).count()) /
|
||||||
|
crossPerRep / 1000.0);
|
||||||
|
}
|
||||||
|
return median(samples);
|
||||||
|
};
|
||||||
|
|
||||||
|
double const off = runArm(false);
|
||||||
|
double const on = runArm(true);
|
||||||
|
OrderBookIndex::setEnabled(true);
|
||||||
|
|
||||||
|
double const speedup = on > 0 ? off / on : 0.0;
|
||||||
|
|
||||||
|
log << "\n=== Arm 5: realistic per-tx-copy crossing (clones index per tx) ===\n"
|
||||||
|
<< " book pages : " << pages << "\n"
|
||||||
|
<< " crossings / rep : " << crossPerRep << "\n"
|
||||||
|
<< " index OFF us/crossing : " << off << " (succ() per offer, per tx)\n"
|
||||||
|
<< " index ON us/crossing : " << on << " (warm cursor; clone+rebuild amortized)\n"
|
||||||
|
<< " speedup : " << speedup << "x\n"
|
||||||
|
<< std::endl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public:
|
||||||
|
void
|
||||||
|
run() override
|
||||||
|
{
|
||||||
|
// BENCH_PROFILE=1 runs only the crossing-apply loop (long) for `sample`.
|
||||||
|
if (std::getenv("BENCH_PROFILE") != nullptr)
|
||||||
|
{
|
||||||
|
testCrossingApplyProfile();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
testReadPath();
|
||||||
|
testEndToEnd();
|
||||||
|
testCrossingApplyProfile();
|
||||||
|
testCrossingIndexArm();
|
||||||
|
testCrossingWarmArm();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
BEAST_DEFINE_TESTSUITE_MANUAL_PRIO(TopOfBookCacheBench, app, xrpl, 20);
|
||||||
|
|
||||||
|
} // namespace xrpl::test
|
||||||
257
src/test/ledger/OrderBookIndex_test.cpp
Normal file
257
src/test/ledger/OrderBookIndex_test.cpp
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
#include <test/jtx/Account.h>
|
||||||
|
#include <test/jtx/Env.h>
|
||||||
|
#include <test/jtx/amount.h>
|
||||||
|
#include <test/jtx/fee.h>
|
||||||
|
#include <test/jtx/offer.h>
|
||||||
|
#include <test/jtx/pay.h>
|
||||||
|
#include <test/jtx/seq.h>
|
||||||
|
#include <test/jtx/trust.h>
|
||||||
|
|
||||||
|
#include <xrpl/beast/unit_test/suite.h>
|
||||||
|
#include <xrpl/ledger/ApplyView.h>
|
||||||
|
#include <xrpl/ledger/OpenView.h>
|
||||||
|
#include <xrpl/ledger/OrderBookIndex.h>
|
||||||
|
#include <xrpl/protocol/Indexes.h>
|
||||||
|
#include <xrpl/protocol/Issue.h>
|
||||||
|
#include <xrpl/protocol/SField.h>
|
||||||
|
#include <xrpl/tx/apply.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace xrpl::test {
|
||||||
|
|
||||||
|
/** Proves OrderBookIndex's rebuild/walk against a real SHAMap-backed book, and
|
||||||
|
that an index maintained by inserting offers in creation order matches the
|
||||||
|
canonical directory walk (the determinism assumption behind P9.3). */
|
||||||
|
class OrderBookIndex_test : public beast::unit_test::Suite
|
||||||
|
{
|
||||||
|
// Read an offer's quality-directory root (the key the index levels on).
|
||||||
|
static uint256
|
||||||
|
bookDirOf(ReadView const& view, uint256 const& offerKey)
|
||||||
|
{
|
||||||
|
auto const sle = view.read(keylet::offer(offerKey));
|
||||||
|
return sle ? sle->getFieldH256(sfBookDirectory) : uint256{};
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
testRebuildMatchesWalk()
|
||||||
|
{
|
||||||
|
testcase("rebuild matches SHAMap walk, ordered best-quality-first");
|
||||||
|
using namespace jtx;
|
||||||
|
|
||||||
|
Env env{*this};
|
||||||
|
auto const gw = Account{"gw"};
|
||||||
|
auto const USD = gw["USD"];
|
||||||
|
Account const maker{"maker"};
|
||||||
|
|
||||||
|
env.fund(XRP(10'000'000), gw, maker);
|
||||||
|
env.close();
|
||||||
|
env.trust(USD(100'000'000), maker);
|
||||||
|
env.close();
|
||||||
|
env(pay(gw, maker, USD(10'000'000)));
|
||||||
|
env.close();
|
||||||
|
|
||||||
|
// Book the maker's offers populate: in = TakerPays asset (XRP),
|
||||||
|
// out = TakerGets asset (USD). (OfferCreate.cpp builds it this way.)
|
||||||
|
Book const book{xrpIssue(), USD.issue(), std::nullopt};
|
||||||
|
|
||||||
|
// Place offers, recording each offer's key in creation order.
|
||||||
|
// - 5 distinct qualities (distinct TakerPays => distinct levels)
|
||||||
|
// - one quality with 40 offers to force a multi-page directory level
|
||||||
|
// (exercises cdirNext across pages in the walk).
|
||||||
|
std::vector<uint256> created;
|
||||||
|
auto place = [&](int xrpPays, int usdGets) {
|
||||||
|
auto const seq = env.seq(maker);
|
||||||
|
env(offer(maker, XRP(xrpPays), USD(usdGets)));
|
||||||
|
created.push_back(keylet::offer(maker, seq).key);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (int q = 0; q < 5; ++q)
|
||||||
|
place(500 + q, 100); // 5 distinct qualities
|
||||||
|
for (int i = 0; i < 40; ++i)
|
||||||
|
place(800, 100); // 40 offers at one shared quality
|
||||||
|
env.close();
|
||||||
|
|
||||||
|
auto const view = env.closed();
|
||||||
|
|
||||||
|
// Rebuild from the authoritative state.
|
||||||
|
OrderBookIndex rebuilt;
|
||||||
|
rebuilt.rebuildBook(*view, book);
|
||||||
|
|
||||||
|
BEAST_EXPECT(rebuilt.offerCount(book) == created.size());
|
||||||
|
BEAST_EXPECT(rebuilt.validateMatchesShaMap(*view, book));
|
||||||
|
BEAST_EXPECT(rebuilt.rebuilds() == 1u);
|
||||||
|
|
||||||
|
// Flattened order must be non-decreasing in quality (best first).
|
||||||
|
auto const flat = rebuilt.flatten(book);
|
||||||
|
BEAST_EXPECT(flat.size() == created.size());
|
||||||
|
bool ordered = true;
|
||||||
|
for (std::size_t i = 1; i < flat.size(); ++i)
|
||||||
|
{
|
||||||
|
auto const prev = getQuality(bookDirOf(*view, flat[i - 1]));
|
||||||
|
auto const cur = getQuality(bookDirOf(*view, flat[i]));
|
||||||
|
if (cur < prev)
|
||||||
|
ordered = false;
|
||||||
|
}
|
||||||
|
BEAST_EXPECT(ordered);
|
||||||
|
|
||||||
|
// An index maintained by inserting in creation order (simulating the
|
||||||
|
// P9.3 apply-path hooks, no deletions) must equal the rebuilt index.
|
||||||
|
OrderBookIndex maintained;
|
||||||
|
for (auto const& offerKey : created)
|
||||||
|
maintained.insertOffer(book, bookDirOf(*view, offerKey), offerKey);
|
||||||
|
BEAST_EXPECT(maintained.flatten(book) == flat);
|
||||||
|
BEAST_EXPECT(maintained.validateMatchesShaMap(*view, book));
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
testEmptyAndAbsentBook()
|
||||||
|
{
|
||||||
|
testcase("rebuild of an empty book yields nothing");
|
||||||
|
using namespace jtx;
|
||||||
|
Env env{*this};
|
||||||
|
env.fund(XRP(10'000), Account{"gw"});
|
||||||
|
env.close();
|
||||||
|
|
||||||
|
Book const book{xrpIssue(), Account{"gw"}["USD"].issue(), std::nullopt};
|
||||||
|
OrderBookIndex idx;
|
||||||
|
idx.rebuildBook(*env.closed(), book);
|
||||||
|
BEAST_EXPECT(idx.offerCount(book) == 0u);
|
||||||
|
BEAST_EXPECT(idx.bookCount() == 0u);
|
||||||
|
BEAST_EXPECT(idx.validateMatchesShaMap(*env.closed(), book));
|
||||||
|
}
|
||||||
|
|
||||||
|
// P9.3: an index seeded from state and then maintained through real
|
||||||
|
// OfferCreate apply (crossings delete offers, placements insert them) must
|
||||||
|
// stay byte-exactly equal to a fresh SHAMap walk. This proves the notify
|
||||||
|
// hooks keep the index in sync without any read-path/seam involvement.
|
||||||
|
void
|
||||||
|
testMaintenanceInSync()
|
||||||
|
{
|
||||||
|
testcase("index stays in sync through real crossing/placement apply");
|
||||||
|
using namespace jtx;
|
||||||
|
|
||||||
|
Env env{*this};
|
||||||
|
auto const gw = Account{"gw"};
|
||||||
|
auto const USD = gw["USD"];
|
||||||
|
Account const maker{"maker"};
|
||||||
|
Account const taker{"taker"};
|
||||||
|
|
||||||
|
env.fund(XRP(10'000'000), gw, maker, taker);
|
||||||
|
env.close();
|
||||||
|
env.trust(USD(100'000'000), maker, taker);
|
||||||
|
env.close();
|
||||||
|
env(pay(gw, maker, USD(10'000'000)));
|
||||||
|
env.close();
|
||||||
|
|
||||||
|
Book const book{xrpIssue(), USD.issue(), std::nullopt};
|
||||||
|
|
||||||
|
// Resting book: 30 offers across distinct qualities.
|
||||||
|
for (int i = 0; i < 30; ++i)
|
||||||
|
env(offer(maker, XRP(500 + i), USD(100)));
|
||||||
|
env.close();
|
||||||
|
|
||||||
|
// Owned OpenView over the closed state; seed the index by rebuild
|
||||||
|
// (the attach-time / startup model).
|
||||||
|
auto const base = env.current();
|
||||||
|
OpenView accum(kOpenLedger, base->rules(), base);
|
||||||
|
accum.orderBookIndex().rebuildBook(accum, book);
|
||||||
|
BEAST_EXPECT(accum.orderBookIndex().validateMatchesShaMap(accum, book));
|
||||||
|
BEAST_EXPECT(accum.orderBookIndex().offerCount(book) == 30u);
|
||||||
|
|
||||||
|
// Pre-sign a mixed batch: taker crossings (consume → delete) and maker
|
||||||
|
// placements at new qualities (insert), with explicit sequences.
|
||||||
|
std::vector<std::shared_ptr<STTx const>> txns;
|
||||||
|
std::uint32_t takerSeq = env.seq(taker);
|
||||||
|
std::uint32_t makerSeq = env.seq(maker);
|
||||||
|
for (int i = 0; i < 15; ++i)
|
||||||
|
{
|
||||||
|
txns.push_back(
|
||||||
|
env.jt(offer(taker, USD(100), XRP(500 + i)), Seq(takerSeq++), Fee(100)).stx);
|
||||||
|
txns.push_back(
|
||||||
|
env.jt(offer(maker, XRP(700 + i), USD(100)), Seq(makerSeq++), Fee(100)).stx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply to the owned view; the index is maintained via the notify
|
||||||
|
// hooks (flushed on each apply). Validate after every tx so a desync
|
||||||
|
// is pinned to the exact transaction that caused it.
|
||||||
|
for (auto const& tx : txns)
|
||||||
|
{
|
||||||
|
auto const r = apply(env.app(), accum, *tx, TapNone, env.journal);
|
||||||
|
BEAST_EXPECT(r.applied);
|
||||||
|
BEAST_EXPECT(accum.orderBookIndex().validateMatchesShaMap(accum, book));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The index actually did work (both directions exercised).
|
||||||
|
BEAST_EXPECT(accum.orderBookIndex().inserts() > 0u);
|
||||||
|
BEAST_EXPECT(accum.orderBookIndex().deletes() > 0u);
|
||||||
|
}
|
||||||
|
|
||||||
|
// P9.6 Stage E: across a ledger close the open-round index is not carried
|
||||||
|
// (the next round starts cold and warms via rebuild-on-touch). Confirm that
|
||||||
|
// after real crossings + a close, the post-close state rebuilds clean — i.e.
|
||||||
|
// the close handoff leaves no index/SHAMap drift.
|
||||||
|
void
|
||||||
|
testCloseHandoff()
|
||||||
|
{
|
||||||
|
testcase("index rebuilds clean across a ledger close");
|
||||||
|
using namespace jtx;
|
||||||
|
|
||||||
|
Env env{*this};
|
||||||
|
auto const gw = Account{"gw"};
|
||||||
|
auto const USD = gw["USD"];
|
||||||
|
Account const maker{"maker"};
|
||||||
|
Account const taker{"taker"};
|
||||||
|
|
||||||
|
env.fund(XRP(10'000'000), gw, maker, taker);
|
||||||
|
env.close();
|
||||||
|
env.trust(USD(100'000'000), maker, taker);
|
||||||
|
env.close();
|
||||||
|
env(pay(gw, maker, USD(10'000'000)));
|
||||||
|
env.close();
|
||||||
|
|
||||||
|
Book const book{xrpIssue(), USD.issue(), std::nullopt};
|
||||||
|
|
||||||
|
for (int i = 0; i < 20; ++i)
|
||||||
|
env(offer(maker, XRP(500 + i), USD(100)));
|
||||||
|
env.close();
|
||||||
|
|
||||||
|
// Round 1: real crossings through the open ledger, then close.
|
||||||
|
for (int i = 0; i < 8; ++i)
|
||||||
|
env(offer(taker, USD(100), XRP(500 + i)));
|
||||||
|
env.close();
|
||||||
|
|
||||||
|
// After the close, a fresh index rebuilt from the post-close ledger must
|
||||||
|
// match the SHAMap walk (no drift left by the round's crossings).
|
||||||
|
{
|
||||||
|
OrderBookIndex idx;
|
||||||
|
idx.rebuildBook(*env.closed(), book);
|
||||||
|
BEAST_EXPECT(idx.validateMatchesShaMap(*env.closed(), book));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round 2: more crossings on top of the post-close state, then re-check.
|
||||||
|
for (int i = 8; i < 16; ++i)
|
||||||
|
env(offer(taker, USD(100), XRP(500 + i)));
|
||||||
|
env.close();
|
||||||
|
{
|
||||||
|
OrderBookIndex idx;
|
||||||
|
idx.rebuildBook(*env.closed(), book);
|
||||||
|
BEAST_EXPECT(idx.validateMatchesShaMap(*env.closed(), book));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public:
|
||||||
|
void
|
||||||
|
run() override
|
||||||
|
{
|
||||||
|
testRebuildMatchesWalk();
|
||||||
|
testEmptyAndAbsentBook();
|
||||||
|
testMaintenanceInSync();
|
||||||
|
testCloseHandoff();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
BEAST_DEFINE_TESTSUITE(OrderBookIndex, ledger, xrpl);
|
||||||
|
|
||||||
|
} // namespace xrpl::test
|
||||||
@@ -39,6 +39,10 @@ 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)
|
||||||
|
|
||||||
|
xrpl_add_test(ledger)
|
||||||
|
target_link_libraries(xrpl.test.ledger PRIVATE xrpl.imports.test)
|
||||||
|
add_dependencies(xrpl.tests xrpl.test.ledger)
|
||||||
|
|
||||||
xrpl_add_test(protocol_autogen)
|
xrpl_add_test(protocol_autogen)
|
||||||
target_link_libraries(xrpl.test.protocol_autogen PRIVATE xrpl.imports.test)
|
target_link_libraries(xrpl.test.protocol_autogen PRIVATE xrpl.imports.test)
|
||||||
add_dependencies(xrpl.tests xrpl.test.protocol_autogen)
|
add_dependencies(xrpl.tests xrpl.test.protocol_autogen)
|
||||||
|
|||||||
175
src/tests/libxrpl/ledger/OrderBookIndex.cpp
Normal file
175
src/tests/libxrpl/ledger/OrderBookIndex.cpp
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
#include <xrpl/ledger/OrderBookIndex.h>
|
||||||
|
|
||||||
|
#include <xrpl/protocol/AccountID.h>
|
||||||
|
#include <xrpl/protocol/Asset.h>
|
||||||
|
#include <xrpl/protocol/Book.h>
|
||||||
|
#include <xrpl/protocol/Indexes.h>
|
||||||
|
#include <xrpl/protocol/Issue.h>
|
||||||
|
#include <xrpl/protocol/UintTypes.h>
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
namespace xrpl::test {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Synthetic-but-consistent IOU book (XRP <-> tagged currency), matching the
|
||||||
|
// TopOfBookCache test helper so the two suites stay comparable.
|
||||||
|
Book
|
||||||
|
makeIOUBook(std::uint8_t tag)
|
||||||
|
{
|
||||||
|
Currency c{};
|
||||||
|
c.data()[19] = tag;
|
||||||
|
AccountID issuer{};
|
||||||
|
issuer.data()[19] = tag;
|
||||||
|
Issue const inIssue{c, issuer};
|
||||||
|
return Book{Asset{inIssue}, Asset{Issue{xrpCurrency(), xrpAccount()}}, std::nullopt};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quality-directory root key for a book at a given rate. Lower rate => lower
|
||||||
|
// key => better quality (the ordering the index relies on).
|
||||||
|
uint256
|
||||||
|
dirKey(Book const& book, std::uint64_t rate)
|
||||||
|
{
|
||||||
|
return keylet::quality(keylet::kBook(book), rate).key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arbitrary distinct offer key.
|
||||||
|
uint256
|
||||||
|
offerKey(std::uint8_t tag)
|
||||||
|
{
|
||||||
|
uint256 k{};
|
||||||
|
k.data()[0] = tag;
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST(OrderBookIndex, EmptyBook)
|
||||||
|
{
|
||||||
|
OrderBookIndex idx;
|
||||||
|
Book const book = makeIOUBook(1);
|
||||||
|
EXPECT_TRUE(idx.flatten(book).empty());
|
||||||
|
EXPECT_FALSE(idx.firstOffer(book).has_value());
|
||||||
|
EXPECT_EQ(idx.bookCount(), 0u);
|
||||||
|
EXPECT_EQ(idx.offerCount(book), 0u);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(OrderBookIndex, InsertWithinLevelPreservesAppendOrder)
|
||||||
|
{
|
||||||
|
OrderBookIndex idx;
|
||||||
|
Book const book = makeIOUBook(2);
|
||||||
|
uint256 const lvl = dirKey(book, 1'000'000u);
|
||||||
|
|
||||||
|
idx.insertOffer(book, lvl, offerKey(1));
|
||||||
|
idx.insertOffer(book, lvl, offerKey(2));
|
||||||
|
idx.insertOffer(book, lvl, offerKey(3));
|
||||||
|
|
||||||
|
std::vector<uint256> const expect{offerKey(1), offerKey(2), offerKey(3)};
|
||||||
|
EXPECT_EQ(idx.flatten(book), expect);
|
||||||
|
EXPECT_EQ(idx.firstOffer(book), offerKey(1));
|
||||||
|
EXPECT_EQ(idx.offerCount(book), 3u);
|
||||||
|
EXPECT_EQ(idx.inserts(), 3u);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(OrderBookIndex, LevelsOrderedBestQualityFirstRegardlessOfInsertOrder)
|
||||||
|
{
|
||||||
|
OrderBookIndex idx;
|
||||||
|
Book const book = makeIOUBook(3);
|
||||||
|
uint256 const best = dirKey(book, 1'000'000u);
|
||||||
|
uint256 const mid = dirKey(book, 2'000'000u);
|
||||||
|
uint256 const worst = dirKey(book, 3'000'000u);
|
||||||
|
ASSERT_LT(best, mid);
|
||||||
|
ASSERT_LT(mid, worst);
|
||||||
|
|
||||||
|
// Insert worst-first to prove ordering is by quality, not insertion.
|
||||||
|
idx.insertOffer(book, worst, offerKey(30));
|
||||||
|
idx.insertOffer(book, best, offerKey(10));
|
||||||
|
idx.insertOffer(book, mid, offerKey(20));
|
||||||
|
|
||||||
|
std::vector<uint256> const expect{offerKey(10), offerKey(20), offerKey(30)};
|
||||||
|
EXPECT_EQ(idx.flatten(book), expect);
|
||||||
|
EXPECT_EQ(idx.firstOffer(book), offerKey(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(OrderBookIndex, DeletePreservesOrderAndDropsEmptyLevel)
|
||||||
|
{
|
||||||
|
OrderBookIndex idx;
|
||||||
|
Book const book = makeIOUBook(4);
|
||||||
|
uint256 const a = dirKey(book, 1'000u);
|
||||||
|
uint256 const b = dirKey(book, 2'000u);
|
||||||
|
|
||||||
|
idx.insertOffer(book, a, offerKey(1));
|
||||||
|
idx.insertOffer(book, a, offerKey(2));
|
||||||
|
idx.insertOffer(book, a, offerKey(3));
|
||||||
|
idx.insertOffer(book, b, offerKey(4));
|
||||||
|
|
||||||
|
// Remove a middle offer: relative order of the rest is preserved.
|
||||||
|
idx.deleteOffer(book, a, offerKey(2));
|
||||||
|
std::vector<uint256> const expect1{offerKey(1), offerKey(3), offerKey(4)};
|
||||||
|
EXPECT_EQ(idx.flatten(book), expect1);
|
||||||
|
EXPECT_EQ(idx.deletes(), 1u);
|
||||||
|
|
||||||
|
// Empty the first level: it is dropped, second becomes the front.
|
||||||
|
idx.deleteOffer(book, a, offerKey(1));
|
||||||
|
idx.deleteOffer(book, a, offerKey(3));
|
||||||
|
EXPECT_EQ(idx.firstOffer(book), offerKey(4));
|
||||||
|
EXPECT_EQ(idx.flatten(book), std::vector<uint256>{offerKey(4)});
|
||||||
|
|
||||||
|
// Empty the book entirely: it is removed from the index.
|
||||||
|
idx.deleteOffer(book, b, offerKey(4));
|
||||||
|
EXPECT_TRUE(idx.flatten(book).empty());
|
||||||
|
EXPECT_EQ(idx.bookCount(), 0u);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(OrderBookIndex, DeleteAbsentIsNoOp)
|
||||||
|
{
|
||||||
|
OrderBookIndex idx;
|
||||||
|
Book const book = makeIOUBook(5);
|
||||||
|
uint256 const lvl = dirKey(book, 1'000u);
|
||||||
|
idx.insertOffer(book, lvl, offerKey(1));
|
||||||
|
|
||||||
|
idx.deleteOffer(book, lvl, offerKey(99)); // absent key
|
||||||
|
idx.deleteOffer(book, dirKey(book, 9u), offerKey(1)); // absent level
|
||||||
|
idx.deleteOffer(makeIOUBook(6), lvl, offerKey(1)); // absent book
|
||||||
|
|
||||||
|
EXPECT_EQ(idx.flatten(book), std::vector<uint256>{offerKey(1)});
|
||||||
|
EXPECT_EQ(idx.deletes(), 0u);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(OrderBookIndex, DistinctBooksIndependent)
|
||||||
|
{
|
||||||
|
OrderBookIndex idx;
|
||||||
|
Book const a = makeIOUBook(7);
|
||||||
|
Book const b = makeIOUBook(8);
|
||||||
|
|
||||||
|
idx.insertOffer(a, dirKey(a, 100u), offerKey(1));
|
||||||
|
idx.insertOffer(b, dirKey(b, 100u), offerKey(2));
|
||||||
|
EXPECT_EQ(idx.bookCount(), 2u);
|
||||||
|
|
||||||
|
idx.eraseBook(a);
|
||||||
|
EXPECT_TRUE(idx.flatten(a).empty());
|
||||||
|
EXPECT_EQ(idx.flatten(b), std::vector<uint256>{offerKey(2)});
|
||||||
|
EXPECT_EQ(idx.bookCount(), 1u);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(OrderBookIndex, ClearEmptiesEverything)
|
||||||
|
{
|
||||||
|
OrderBookIndex idx;
|
||||||
|
Book const book = makeIOUBook(9);
|
||||||
|
idx.insertOffer(book, dirKey(book, 1u), offerKey(1));
|
||||||
|
idx.clear();
|
||||||
|
EXPECT_EQ(idx.bookCount(), 0u);
|
||||||
|
EXPECT_TRUE(idx.flatten(book).empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(OrderBookIndex, KillSwitchToggleable)
|
||||||
|
{
|
||||||
|
EXPECT_TRUE(OrderBookIndex::enabled());
|
||||||
|
OrderBookIndex::setEnabled(false);
|
||||||
|
EXPECT_FALSE(OrderBookIndex::enabled());
|
||||||
|
OrderBookIndex::setEnabled(true);
|
||||||
|
EXPECT_TRUE(OrderBookIndex::enabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace xrpl::test
|
||||||
151
src/tests/libxrpl/ledger/PersistentOrderTree.cpp
Normal file
151
src/tests/libxrpl/ledger/PersistentOrderTree.cpp
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
#include <xrpl/ledger/detail/PersistentOrderTree.h>
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstring>
|
||||||
|
#include <map>
|
||||||
|
#include <random>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace xrpl::detail {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// 256-bit value whose numeric order matches integer order (n written
|
||||||
|
// big-endian into the low 8 bytes; base_uint compares MSB-first).
|
||||||
|
uint256
|
||||||
|
u256(std::uint64_t n)
|
||||||
|
{
|
||||||
|
uint256 k;
|
||||||
|
std::memset(k.data(), 0, k.size());
|
||||||
|
auto* end = k.data() + k.size();
|
||||||
|
for (int i = 0; i < 8; ++i)
|
||||||
|
end[-1 - i] = static_cast<unsigned char>((n >> (8 * i)) & 0xff);
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
|
||||||
|
using RefKey = std::pair<uint256, std::uint64_t>; // (dirRoot, insertSeq)
|
||||||
|
|
||||||
|
// Reference inorder: std::map orders by (dirRoot, insertSeq); collect offers.
|
||||||
|
std::vector<uint256>
|
||||||
|
refInorder(std::map<RefKey, uint256> const& ref)
|
||||||
|
{
|
||||||
|
std::vector<uint256> out;
|
||||||
|
out.reserve(ref.size());
|
||||||
|
for (auto const& [k, off] : ref)
|
||||||
|
out.push_back(off);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<uint256>
|
||||||
|
treeInorder(OrderTreePtr const& t)
|
||||||
|
{
|
||||||
|
std::vector<uint256> out;
|
||||||
|
otInorder(t, out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
int
|
||||||
|
height(OrderTreePtr const& t)
|
||||||
|
{
|
||||||
|
if (!t)
|
||||||
|
return 0;
|
||||||
|
return 1 + std::max(height(t->left), height(t->right));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify subtree size fields are consistent.
|
||||||
|
std::uint32_t
|
||||||
|
checkSize(OrderTreePtr const& t)
|
||||||
|
{
|
||||||
|
if (!t)
|
||||||
|
return 0;
|
||||||
|
auto const s = checkSize(t->left) + checkSize(t->right) + 1;
|
||||||
|
EXPECT_EQ(s, t->size);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST(PersistentOrderTree, MatchesStdMapUnderRandomOps)
|
||||||
|
{
|
||||||
|
std::mt19937_64 rng(0xC0FFEEu); // fixed seed → deterministic
|
||||||
|
std::map<RefKey, uint256> ref;
|
||||||
|
OrderTreePtr tree;
|
||||||
|
|
||||||
|
// A small set of dirRoots (quality levels) so levels hold multiple offers,
|
||||||
|
// exercising within-level ordering and the dirRoot-range delete search.
|
||||||
|
constexpr std::uint64_t kDirs = 8;
|
||||||
|
std::uint64_t seqCounter = 0;
|
||||||
|
std::uint64_t offerCounter = 0;
|
||||||
|
std::vector<RefKey> live;
|
||||||
|
|
||||||
|
for (int op = 0; op < 4000; ++op)
|
||||||
|
{
|
||||||
|
bool const doInsert = live.empty() || (rng() % 100) < 60;
|
||||||
|
if (doInsert)
|
||||||
|
{
|
||||||
|
uint256 const dir = u256(rng() % kDirs);
|
||||||
|
std::uint64_t const seq = ++seqCounter; // unique → unique key
|
||||||
|
uint256 const off = u256(1'000'000 + (++offerCounter));
|
||||||
|
RefKey const key{dir, seq};
|
||||||
|
ref.emplace(key, off);
|
||||||
|
tree = otInsert(tree, dir, seq, off);
|
||||||
|
live.push_back(key);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Delete a random live key by (dirRoot, offerKey) lookup, exactly
|
||||||
|
// like OrderBookIndex::deleteOffer does.
|
||||||
|
auto const idx = rng() % live.size();
|
||||||
|
RefKey const key = live[idx];
|
||||||
|
uint256 const off = ref.at(key);
|
||||||
|
|
||||||
|
auto const foundSeq = otFindSeq(tree, key.first, off);
|
||||||
|
ASSERT_TRUE(foundSeq.has_value());
|
||||||
|
EXPECT_EQ(*foundSeq, key.second);
|
||||||
|
|
||||||
|
tree = otDelete(tree, key.first, *foundSeq);
|
||||||
|
ref.erase(key);
|
||||||
|
live[idx] = live.back();
|
||||||
|
live.pop_back();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inorder equivalence after every op.
|
||||||
|
ASSERT_EQ(treeInorder(tree), refInorder(ref));
|
||||||
|
// Size field integrity + element count.
|
||||||
|
EXPECT_EQ(otSize(tree), ref.size());
|
||||||
|
checkSize(tree);
|
||||||
|
// first == reference begin's offer.
|
||||||
|
if (ref.empty())
|
||||||
|
EXPECT_FALSE(otFirst(tree).has_value());
|
||||||
|
else
|
||||||
|
EXPECT_EQ(otFirst(tree), ref.begin()->second);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weight-balanced height stays logarithmic (loose bound).
|
||||||
|
auto const n = otSize(tree);
|
||||||
|
if (n > 0)
|
||||||
|
EXPECT_LE(height(tree), 3 * (static_cast<int>(std::log2(n)) + 1) + 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(PersistentOrderTree, StructuralSharingImmutability)
|
||||||
|
{
|
||||||
|
OrderTreePtr base;
|
||||||
|
for (std::uint64_t i = 0; i < 200; ++i)
|
||||||
|
base = otInsert(base, u256(i % 4), i + 1, u256(10'000 + i));
|
||||||
|
|
||||||
|
auto const before = treeInorder(base);
|
||||||
|
|
||||||
|
// Mutate copies; the captured `base` must be unaffected (immutable nodes).
|
||||||
|
auto inserted = otInsert(base, u256(2), 99'999, u256(42));
|
||||||
|
auto deleted = otDelete(base, u256(0), 1);
|
||||||
|
|
||||||
|
EXPECT_EQ(treeInorder(base), before); // base unchanged by insert
|
||||||
|
EXPECT_EQ(otSize(inserted), otSize(base) + 1); // derived tree grew
|
||||||
|
EXPECT_EQ(otSize(deleted), otSize(base) - 1); // derived tree shrank
|
||||||
|
EXPECT_EQ(treeInorder(base), before); // base unchanged by delete
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace xrpl::detail
|
||||||
200
src/tests/libxrpl/ledger/TopOfBookCache.cpp
Normal file
200
src/tests/libxrpl/ledger/TopOfBookCache.cpp
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
#include <xrpl/ledger/TopOfBookCache.h>
|
||||||
|
|
||||||
|
#include <xrpl/protocol/AccountID.h>
|
||||||
|
#include <xrpl/protocol/Asset.h>
|
||||||
|
#include <xrpl/protocol/Book.h>
|
||||||
|
#include <xrpl/protocol/Indexes.h>
|
||||||
|
#include <xrpl/protocol/Issue.h>
|
||||||
|
#include <xrpl/protocol/UintTypes.h>
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
namespace xrpl::test {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Construct a synthetic-but-consistent IOU book. The currency byte
|
||||||
|
// distinguishes books for cache lookups; pairs are XRP <-> <currency>.
|
||||||
|
Book
|
||||||
|
makeIOUBook(std::uint8_t tag)
|
||||||
|
{
|
||||||
|
Currency c{};
|
||||||
|
c.data()[19] = tag;
|
||||||
|
AccountID issuer{};
|
||||||
|
issuer.data()[19] = tag;
|
||||||
|
Issue const inIssue{c, issuer};
|
||||||
|
return Book{Asset{inIssue}, Asset{Issue{xrpCurrency(), xrpAccount()}}, std::nullopt};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive the directory keylet (first-page key) for a given book at a given
|
||||||
|
// quality rate. Two distinct rates produce two distinct, prefix-comparable
|
||||||
|
// keys for the same book.
|
||||||
|
uint256
|
||||||
|
dirKey(Book const& book, std::uint64_t rate)
|
||||||
|
{
|
||||||
|
return keylet::quality(keylet::kBook(book), rate).key;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST(TopOfBookCache, EmptyCacheMisses)
|
||||||
|
{
|
||||||
|
TopOfBookCache cache;
|
||||||
|
EXPECT_FALSE(cache.get(makeIOUBook(1)).has_value());
|
||||||
|
EXPECT_EQ(cache.size(), 0u);
|
||||||
|
EXPECT_EQ(cache.hits(), 0u);
|
||||||
|
EXPECT_EQ(cache.misses(), 1u);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TopOfBookCache, RecordThenHit)
|
||||||
|
{
|
||||||
|
TopOfBookCache cache;
|
||||||
|
Book const book = makeIOUBook(2);
|
||||||
|
uint256 const key = dirKey(book, 1'000'000u);
|
||||||
|
|
||||||
|
cache.record(book, key, /*seq=*/42);
|
||||||
|
|
||||||
|
auto const got = cache.get(book);
|
||||||
|
ASSERT_TRUE(got.has_value());
|
||||||
|
EXPECT_EQ(got->firstPageKey, key);
|
||||||
|
EXPECT_EQ(got->bestQuality, getQuality(key));
|
||||||
|
EXPECT_EQ(got->asOfLedger, 42u);
|
||||||
|
EXPECT_EQ(cache.hits(), 1u);
|
||||||
|
EXPECT_EQ(cache.misses(), 0u);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TopOfBookCache, OnOfferInsertBetterReplacesTop)
|
||||||
|
{
|
||||||
|
TopOfBookCache cache;
|
||||||
|
Book const book = makeIOUBook(3);
|
||||||
|
uint256 const worse = dirKey(book, 2'000'000u);
|
||||||
|
uint256 const better = dirKey(book, 1'000'000u);
|
||||||
|
// Higher rate keys sort higher (worse quality). Sanity check.
|
||||||
|
ASSERT_LT(better, worse);
|
||||||
|
|
||||||
|
cache.record(book, worse, 1);
|
||||||
|
cache.onOfferInsert(book, better, 2);
|
||||||
|
|
||||||
|
auto const got = cache.get(book);
|
||||||
|
ASSERT_TRUE(got.has_value());
|
||||||
|
EXPECT_EQ(got->firstPageKey, better);
|
||||||
|
EXPECT_EQ(got->asOfLedger, 2u);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TopOfBookCache, OnOfferInsertSameLeavesTop)
|
||||||
|
{
|
||||||
|
TopOfBookCache cache;
|
||||||
|
Book const book = makeIOUBook(4);
|
||||||
|
uint256 const key = dirKey(book, 1'000'000u);
|
||||||
|
|
||||||
|
cache.record(book, key, 5);
|
||||||
|
cache.onOfferInsert(book, key, 6);
|
||||||
|
|
||||||
|
auto const got = cache.get(book);
|
||||||
|
ASSERT_TRUE(got.has_value());
|
||||||
|
EXPECT_EQ(got->firstPageKey, key);
|
||||||
|
// asOfLedger preserved — same-quality insert is a no-op.
|
||||||
|
EXPECT_EQ(got->asOfLedger, 5u);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TopOfBookCache, OnOfferInsertWorseLeavesTop)
|
||||||
|
{
|
||||||
|
TopOfBookCache cache;
|
||||||
|
Book const book = makeIOUBook(5);
|
||||||
|
uint256 const best = dirKey(book, 1'000'000u);
|
||||||
|
uint256 const worse = dirKey(book, 3'000'000u);
|
||||||
|
ASSERT_LT(best, worse);
|
||||||
|
|
||||||
|
cache.record(book, best, 5);
|
||||||
|
cache.onOfferInsert(book, worse, 9);
|
||||||
|
|
||||||
|
auto const got = cache.get(book);
|
||||||
|
ASSERT_TRUE(got.has_value());
|
||||||
|
EXPECT_EQ(got->firstPageKey, best);
|
||||||
|
EXPECT_EQ(got->asOfLedger, 5u);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TopOfBookCache, OnOfferInsertWithoutEntryIsNoOp)
|
||||||
|
{
|
||||||
|
TopOfBookCache cache;
|
||||||
|
Book const book = makeIOUBook(6);
|
||||||
|
uint256 const key = dirKey(book, 1'000u);
|
||||||
|
|
||||||
|
// No prior entry — we don't speculatively populate.
|
||||||
|
cache.onOfferInsert(book, key, 1);
|
||||||
|
EXPECT_FALSE(cache.get(book).has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TopOfBookCache, OnOfferDeleteOfTopInvalidates)
|
||||||
|
{
|
||||||
|
TopOfBookCache cache;
|
||||||
|
Book const book = makeIOUBook(7);
|
||||||
|
uint256 const top = dirKey(book, 1'000u);
|
||||||
|
|
||||||
|
cache.record(book, top, 1);
|
||||||
|
cache.onOfferDelete(book, top);
|
||||||
|
|
||||||
|
EXPECT_FALSE(cache.get(book).has_value());
|
||||||
|
EXPECT_EQ(cache.invalidations(), 1u);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TopOfBookCache, OnOfferDeleteOfOtherPageLeavesTop)
|
||||||
|
{
|
||||||
|
TopOfBookCache cache;
|
||||||
|
Book const book = makeIOUBook(8);
|
||||||
|
uint256 const top = dirKey(book, 1'000u);
|
||||||
|
uint256 const worsePage = dirKey(book, 4'000u);
|
||||||
|
|
||||||
|
cache.record(book, top, 1);
|
||||||
|
cache.onOfferDelete(book, worsePage);
|
||||||
|
|
||||||
|
auto const got = cache.get(book);
|
||||||
|
ASSERT_TRUE(got.has_value());
|
||||||
|
EXPECT_EQ(got->firstPageKey, top);
|
||||||
|
EXPECT_EQ(cache.invalidations(), 0u);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TopOfBookCache, DistinctBooksIndependent)
|
||||||
|
{
|
||||||
|
TopOfBookCache cache;
|
||||||
|
Book const a = makeIOUBook(10);
|
||||||
|
Book const b = makeIOUBook(11);
|
||||||
|
|
||||||
|
cache.record(a, dirKey(a, 100u), 1);
|
||||||
|
cache.record(b, dirKey(b, 200u), 2);
|
||||||
|
|
||||||
|
EXPECT_TRUE(cache.get(a).has_value());
|
||||||
|
EXPECT_TRUE(cache.get(b).has_value());
|
||||||
|
EXPECT_EQ(cache.size(), 2u);
|
||||||
|
|
||||||
|
cache.onOfferDelete(a, dirKey(a, 100u));
|
||||||
|
EXPECT_FALSE(cache.get(a).has_value());
|
||||||
|
EXPECT_TRUE(cache.get(b).has_value());
|
||||||
|
EXPECT_EQ(cache.size(), 1u);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TopOfBookCache, InvalidateUnconditional)
|
||||||
|
{
|
||||||
|
TopOfBookCache cache;
|
||||||
|
Book const book = makeIOUBook(12);
|
||||||
|
cache.record(book, dirKey(book, 100u), 1);
|
||||||
|
cache.invalidate(book);
|
||||||
|
EXPECT_FALSE(cache.get(book).has_value());
|
||||||
|
EXPECT_EQ(cache.invalidations(), 1u);
|
||||||
|
// Re-invalidating doesn't double-count.
|
||||||
|
cache.invalidate(book);
|
||||||
|
EXPECT_EQ(cache.invalidations(), 1u);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TopOfBookCache, KillSwitchToggleable)
|
||||||
|
{
|
||||||
|
EXPECT_TRUE(TopOfBookCache::enabled());
|
||||||
|
TopOfBookCache::setEnabled(false);
|
||||||
|
EXPECT_FALSE(TopOfBookCache::enabled());
|
||||||
|
TopOfBookCache::setEnabled(true);
|
||||||
|
EXPECT_TRUE(TopOfBookCache::enabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
} // 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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user