diff --git a/API-CHANGELOG.md b/API-CHANGELOG.md index 79fb8ff5226..14ad75fef1e 100644 --- a/API-CHANGELOG.md +++ b/API-CHANGELOG.md @@ -26,6 +26,14 @@ This version is supported by all `xrpld` versions. For WebSocket and HTTP JSON-R This section contains changes targeting a future version. +### Changes + +- `path_find` / `ripple_path_find`: Path selection no longer reserves a full-liquidity "covering" (spare) path and no longer requires the last alternative to alone fill the payment. The server returns up to **six** ranked path alternatives per source asset (`paths_computed`), filled by quality and liquidity only. Previously, the set was capped at four paths, the final slot had to be able to complete the payment by itself (assuming no liquidity overlap), and if the combined set failed with `tecPATH_PARTIAL` / `terNO_LINE` the calculation could be retried with an extra covering path. That retry path is removed. Pathfinding remains best-effort. This is an intentional product tradeoff: under concurrent load, clients benefit more from a fuller set of real alternatives than from a reserved single full-liquidity spare. See implementation comments in `Pathfinder::getBestPaths` / `findPaths` and `PathRequest`. ([#7962](https://github.com/XRPLF/rippled/pull/7962)) + - **Optional new field:** WebSocket `path_find` updates may include `warning` values: + - `"path_lines_partial"` — trust lines for accounts used by **this** subscription are still being filled progressively (owner-directory chunk load). + - `"path_revalidate_failed"` — incremental revalidate found no live paths; the server re-sent the previous `alternatives` for display only. `full_reply` is `false` in this case. Treat as best-effort / possibly stale; a later closed-ledger update may full-search again. + Clients that ignore unknown fields are unaffected. The field is omitted when neither condition applies (or on errors). + ### Additions - `account_tx`: Added an optional `delegate` request object to filter delegated transactions. The object requires `delegate_filter`, which must be either `actor` for transactions owned by the requested account but signed by another account, or `authorizer` for transactions signed by the requested account on behalf of another account. The optional `counter_party` account narrows the results to a specific signer/delegate for `actor` or a specific owner/delegator for `authorizer`. Malformed `delegate`, `delegate_filter`, and `counter_party` values return standard invalid field errors, and invalid account IDs return `actMalformed`. diff --git a/cfg/xrpld-example.cfg b/cfg/xrpld-example.cfg index 747bafe077a..939e91d6582 100644 --- a/cfg/xrpld-example.cfg +++ b/cfg/xrpld-example.cfg @@ -851,6 +851,35 @@ # # The default is: 2 # +# [path_find] +# Optional knobs for concurrent WebSocket path_find / shared AssetCache. +# Pathfinding remains best-effort; these only tune cost vs freshness. +# +# # Ledgers a cached trust-line vector may be reused without reload. +# # Larger = less owner-dir thrash under load; smaller = fresher lines. +# # Range: 0–64. Default: 6 +# cache_reuse_ledgers=6 +# +# # Trust lines loaded per account per WS load/expand step (slow fill). +# # One-shot ripple_path_find still loads up to max_lines_per_account. +# # Range: 1–1024. Default: 64 +# line_chunk_size=64 +# +# # Closed-ledger interval between full Pathfinder rediscoveries (staggered +# # per session). Range: 1–100. Default: 3 +# full_search_interval=3 +# +# # Open-ledger revalidate-only tick period while path_find sessions are live +# # (milliseconds). Range: 50–10000. Default: 500 +# mid_close_ms=500 +# +# # Soft caps on PathFindTrustLine objects retained in the shared cache. +# # max_total_lines minimum 1000. Default: 1000000 +# max_total_lines=1000000 +# # max_lines_per_account range: 64–max_total_lines. Default: min(50000, +# # max_total_lines) when omitted (so max_total_lines=1000 alone is valid). +# max_lines_per_account=50000 +# # # # [fee_default] diff --git a/include/xrpl/config/Constants.h b/include/xrpl/config/Constants.h index 85d9e3f1473..9ea957bd0d0 100644 --- a/include/xrpl/config/Constants.h +++ b/include/xrpl/config/Constants.h @@ -33,6 +33,7 @@ struct Sections static constexpr auto kNodeSeed = "node_seed"; static constexpr auto kNodeSize = "node_size"; static constexpr auto kOverlay = "overlay"; + static constexpr auto kPathFind = "path_find"; static constexpr auto kPathSearch = "path_search"; static constexpr auto kPathSearchFast = "path_search_fast"; static constexpr auto kPathSearchMax = "path_search_max"; @@ -96,6 +97,7 @@ struct Keys static constexpr auto kBlockSize = "block_size"; static constexpr auto kCacheAge = "cache_age"; static constexpr auto kCacheMb = "cache_mb"; + static constexpr auto kCacheReuseLedgers = "cache_reuse_ledgers"; static constexpr auto kCacheSize = "cache_size"; static constexpr auto kClientMaxWindowBits = "client_max_window_bits"; static constexpr auto kClientNoContextTakeover = "client_no_context_takeover"; @@ -108,6 +110,7 @@ struct Keys static constexpr auto kFileSizeMult = "file_size_mult"; static constexpr auto kFilterBits = "filter_bits"; static constexpr auto kFilterFull = "filter_full"; + static constexpr auto kFullSearchInterval = "full_search_interval"; static constexpr auto kHardSet = "hard_set"; static constexpr auto kHighThreads = "high_threads"; static constexpr auto kHoldTime = "hold_time"; @@ -116,15 +119,19 @@ struct Keys static constexpr auto kJournalSizeLimit = "journal_size_limit"; static constexpr auto kLedgersInQueue = "ledgers_in_queue"; static constexpr auto kLimit = "limit"; + static constexpr auto kLineChunkSize = "line_chunk_size"; static constexpr auto kLogInterval = "log_interval"; static constexpr auto kMaxDivergedTime = "max_diverged_time"; static constexpr auto kMaxLedgerCountsToStore = "max_ledger_counts_to_store"; + static constexpr auto kMaxLinesPerAccount = "max_lines_per_account"; + static constexpr auto kMaxTotalLines = "max_total_lines"; static constexpr auto kMaxTrustedCount = "max_trusted_count"; static constexpr auto kMaxUnknownTime = "max_unknown_time"; static constexpr auto kMaxUntrustedCount = "max_untrusted_count"; static constexpr auto kMaximumTxnInLedger = "maximum_txn_in_ledger"; static constexpr auto kMaximumTxnPerAccount = "maximum_txn_per_account"; static constexpr auto kMemoryLevel = "memory_level"; + static constexpr auto kMidCloseMs = "mid_close_ms"; static constexpr auto kMinLedgersToComputeSizeLimit = "min_ledgers_to_compute_size_limit"; static constexpr auto kMinimumEscalationMultiplier = "minimum_escalation_multiplier"; static constexpr auto kMinimumLastLedgerBuffer = "minimum_last_ledger_buffer"; diff --git a/include/xrpl/core/Job.h b/include/xrpl/core/Job.h index 93b39701bed..163e3d98267 100644 --- a/include/xrpl/core/Job.h +++ b/include/xrpl/core/Job.h @@ -39,7 +39,8 @@ enum JobType { JtSweep, // Sweep for stale structures JtValidationUt, // A validation from an untrusted source JtManifest, // A validator's manifest - JtUpdatePf, // Update pathfinding requests + JtUpdatePf, // Update pathfinding requests (orchestrator; limit 1) + JtPathFindWork, // Parallel path_find revalidate unit of work JtTransactionL, // A local transaction JtReplayReq, // Peer request a ledger delta or a skip list JtLedgerReq, // Peer request ledger/txnset data @@ -78,6 +79,16 @@ enum JobType { JtNsWrite, }; +/** + * JobTypes limit for JtPathFindWork and the requested steady-revalidate + * parallelism (rpc::tuning::kPathSteadyUpdateParallelism must equal this). + * + * Effective concurrent units are still gated by JobQueue size in + * PathRequestManager::runParallel: serial when workers < 3, else at most + * workers - 1 per batch. + */ +inline constexpr int kPathFindWorkLimit = 32; + class Job : public CountedObject { public: diff --git a/include/xrpl/core/JobQueue.h b/include/xrpl/core/JobQueue.h index 0c9fc76357f..a37eeb2d1df 100644 --- a/include/xrpl/core/JobQueue.h +++ b/include/xrpl/core/JobQueue.h @@ -241,6 +241,15 @@ class JobQueue : private Workers::Callback void rendezvous(); + /** + * Number of worker threads configured for this queue (Workers desired + * count). Stable after construction under normal operation; used by + * path_find fan-out to decide serial vs fork-join without re-deriving + * Application's sizing formula from Config. + */ + [[nodiscard]] int + getWorkerCount() const noexcept; + void stop(); diff --git a/include/xrpl/core/JobTypes.h b/include/xrpl/core/JobTypes.h index cc2f3ecbf56..8bcc0e85785 100644 --- a/include/xrpl/core/JobTypes.h +++ b/include/xrpl/core/JobTypes.h @@ -70,6 +70,11 @@ class JobTypes add(JtClientWebsocket, "clientWebsocket", maxLimit, 2000ms, 5000ms); add(JtRpc, "RPC", maxLimit, 0ms, 0ms); add(JtUpdatePf, "updatePaths", 1, 0ms, 0ms); + // Steady path_find revalidate units (fork-join siblings of updateAll). + // Limit is kPathFindWorkLimit (== kPathSteadyUpdateParallelism). Actual + // concurrency is also capped by JobQueue workers in runParallel + // (serial if workers < 3; else batch ≤ workers - 1). + add(JtPathFindWork, "pathFindWork", kPathFindWorkLimit, 0ms, 0ms); add(JtTransaction, "transaction", maxLimit, 250ms, 1000ms); add(JtBatch, "batch", maxLimit, 250ms, 1000ms); add(JtAdvance, "advanceLedger", maxLimit, 0ms, 0ms); diff --git a/src/libxrpl/core/detail/JobQueue.cpp b/src/libxrpl/core/detail/JobQueue.cpp index 8f95a8a5fa1..5d41ad8236b 100644 --- a/src/libxrpl/core/detail/JobQueue.cpp +++ b/src/libxrpl/core/detail/JobQueue.cpp @@ -21,6 +21,12 @@ namespace xrpl { +int +JobQueue::getWorkerCount() const noexcept +{ + return workers_.getNumberOfThreads(); +} + JobQueue::JobQueue( int threadCount, beast::insight::Collector::ptr const& collector, diff --git a/src/test/jtx/impl/paths.cpp b/src/test/jtx/impl/paths.cpp index eb4b36ae4ff..9c4d6490ecb 100644 --- a/src/test/jtx/impl/paths.cpp +++ b/src/test/jtx/impl/paths.cpp @@ -54,9 +54,8 @@ Paths::operator()(Env& env, JTx& jt) const if (!pf.findPaths(depth_)) return; - STPath fp; pf.computePathRanks(limit_); - auto const found = pf.getBestPaths(limit_, fp, {}, in_.getIssuer()); + auto const found = pf.getBestPaths(limit_, {}, in_.getIssuer()); // VFALCO TODO API to allow caller to examine the STPathSet // VFALCO isDefault should be renamed to empty() diff --git a/src/test/rpc/AssetCache_test.cpp b/src/test/rpc/AssetCache_test.cpp new file mode 100644 index 00000000000..2c928805762 --- /dev/null +++ b/src/test/rpc/AssetCache_test.cpp @@ -0,0 +1,700 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2026 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +#include +#include +#include +#include + +namespace xrpl { + +class AssetCache_test : public beast::unit_test::Suite +{ + /** + * Give `holder` N distinct outgoing trust lines (holder → peer IOUs). + * `tag` prefixes peer account names so multiple holders do not collide. + */ + void + fundManyLines( + test::jtx::Env& env, + test::jtx::Account const& holder, + int n, + std::string const& tag = "p") + { + using namespace test::jtx; + env.fund(XRP(10000), holder); + env.close(); + for (int i = 0; i < n; ++i) + { + Account peer{tag + std::to_string(i)}; + env.fund(XRP(1000), peer); + // Distinct holder-peer IOU lines (holder issues USD to peer). + env.trust(holder["USD"](1000), peer); + env(pay(holder, peer, holder["USD"](1))); + env.close(); + } + } + + void + testBudgetZeroEmptyStubAndExpand() + { + testcase("budget zero: empty incomplete stub then expand with budget"); + using namespace test::jtx; + Env env(*this); + Account const alice{"alice"}; + Account const gw{"gw"}; + env.fund(XRP(10000), alice, gw); + env.close(); + env.trust(gw["USD"](1000), alice); + env(pay(gw, alice, gw["USD"](10))); + env.close(); + + // maxTotalLines=0: first load cannot admit lines; empty incomplete stub. + auto cache = std::make_shared( + env.current(), + env.app().getJournal("AssetCache"), + /*maxTotalLines=*/0, + /*maxLinesPerAccount=*/1000, + /*cacheReuseLedgers=*/12, + /*lineChunkSize=*/64); + + { + AssetCache::SessionPin pin{1}; + auto lines = cache->getRippleLines(alice.id()); + BEAST_EXPECT(!lines || lines->empty()); + BEAST_EXPECT(cache->hasIncompleteLinesForSession(1)); + BEAST_EXPECT(cache->totalLineCount() == 0); + BEAST_EXPECT(cache->overBudget()); + } + + // New cache with budget: expand/load can admit lines. + cache = std::make_shared( + env.current(), + env.app().getJournal("AssetCache"), + /*maxTotalLines=*/10000, + /*maxLinesPerAccount=*/1000, + /*cacheReuseLedgers=*/12, + /*lineChunkSize=*/64); + { + AssetCache::SessionPin pin{2}; + auto lines = cache->getRippleLines(alice.id()); + BEAST_EXPECT(lines && !lines->empty()); + BEAST_EXPECT(cache->totalLineCount() >= 1); + BEAST_EXPECT(!cache->overBudget()); + } + cache->releaseSession(2); + BEAST_EXPECT(cache->totalLineCount() == 0); + } + + /** + * Accounts with no trust lines must still publish an empty complete vector + * so reuse hits. Leaving lines==null re-scanned the owner dir under the + * exclusive lock on every Pathfinder hop. + */ + void + testEmptyAccountCachedNotRescanned() + { + testcase("empty account: complete miss is cached, not re-scanned"); + using namespace test::jtx; + Env env(*this); + Account const bare{"bare"}; // funded, no trust lines + Account const alice{"alice"}; + Account const gw{"gw"}; + env.fund(XRP(10000), bare, alice, gw); + env.close(); + env.trust(gw["USD"](1000), alice); + env(pay(gw, alice, gw["USD"](10))); + env.close(); + + auto cache = std::make_shared( + env.current(), + env.app().getJournal("AssetCache"), + /*maxTotalLines=*/10000, + /*maxLinesPerAccount=*/1000, + /*cacheReuseLedgers=*/12, + /*lineChunkSize=*/64); + + auto const misses0 = cache->cacheMisses(); + auto const hits0 = cache->cacheHits(); + + { + AssetCache::SessionPin pin{1}; + auto first = cache->getRippleLines(bare.id()); + // API still returns nullptr for empty (no lines to walk). + BEAST_EXPECT(!first); + BEAST_EXPECT(cache->cacheMisses() == misses0 + 1); + BEAST_EXPECT(!cache->hasIncompleteLines()); + BEAST_EXPECT(!cache->hasIncompleteLinesForSession(1)); + + // Second lookup must reuse the empty complete entry (hit), not miss. + auto second = cache->getRippleLines(bare.id()); + BEAST_EXPECT(!second); + BEAST_EXPECT(cache->cacheMisses() == misses0 + 1); + BEAST_EXPECT(cache->cacheHits() >= hits0 + 1); + } + cache->releaseSession(1); + + // Non-empty account still loads normally after empty caching. + { + AssetCache::SessionPin pin{2}; + auto lines = cache->getRippleLines(alice.id()); + BEAST_EXPECT(lines && !lines->empty()); + } + cache->releaseSession(2); + } + + void + testPendingExpandWhileShared() + { + testcase("expand while shared: published vector stable"); + using namespace test::jtx; + Env env(*this); + Account const alice{"alice"}; + fundManyLines(env, alice, 5, "pe"); + + auto cache = std::make_shared( + env.current(), + env.app().getJournal("AssetCache"), + /*maxTotalLines=*/100000, + /*maxLinesPerAccount=*/1000, + /*cacheReuseLedgers=*/12, + /*lineChunkSize=*/1); + + AssetCache::SessionPin pin{7}; + auto held = cache->getRippleLines(alice.id()); + BEAST_EXPECT(held); + auto const firstSize = held->size(); + BEAST_EXPECT(firstSize >= 1); + + // External hold keeps use_count > 1; expand must not mutate `held`. + auto const before = firstSize; + bool grew = cache->expandIncompleteLines(); + BEAST_EXPECT(held->size() == before); + // New publish may include more lines after coalesce on next get. + auto again = cache->getRippleLines(alice.id()); + BEAST_EXPECT(again); + if (grew) + BEAST_EXPECT(again->size() >= before); + } + + void + testSessionPinsSharedHub() + { + testcase("session pins: shared hub freed only when last session ends"); + using namespace test::jtx; + Env env(*this); + Account const alice{"alice"}; + Account const gw{"gw"}; + env.fund(XRP(10000), alice, gw); + env.close(); + env.trust(gw["USD"](1000), alice); + env(pay(gw, alice, gw["USD"](5))); + env.close(); + + auto cache = + std::make_shared(env.current(), env.app().getJournal("AssetCache")); + + { + AssetCache::SessionPin pinA{10}; + BEAST_EXPECT(cache->getRippleLines(alice.id())); + } + { + AssetCache::SessionPin pinB{11}; + BEAST_EXPECT(cache->getRippleLines(alice.id())); + } + BEAST_EXPECT(cache->totalLineCount() >= 1); + + // First session ends — hub remains while second holds it. + auto const freed10 = cache->releaseSession(10); + BEAST_EXPECT(freed10 == 0); + BEAST_EXPECT(cache->totalLineCount() >= 1); + + auto const freed11 = cache->releaseSession(11); + BEAST_EXPECT(freed11 >= 1); + BEAST_EXPECT(cache->totalLineCount() == 0); + } + + void + testAdvanceLedgerSoftRetainAndForceClear() + { + testcase("advanceLedger: soft retain vs forceClear"); + using namespace test::jtx; + Env env(*this); + Account const alice{"alice"}; + Account const gw{"gw"}; + env.fund(XRP(10000), alice, gw); + env.close(); + env.trust(gw["USD"](1000), alice); + env(pay(gw, alice, gw["USD"](10))); + env.close(); + + auto cache = std::make_shared( + env.current(), + env.app().getJournal("AssetCache"), + /*maxTotalLines=*/10000, + /*maxLinesPerAccount=*/1000, + /*cacheReuseLedgers=*/12, + /*lineChunkSize=*/64); + + { + AssetCache::SessionPin pin{1}; + BEAST_EXPECT(cache->getRippleLines(alice.id())); + } + auto const linesBefore = cache->totalLineCount(); + BEAST_EXPECT(linesBefore >= 1); + auto const hitsBefore = cache->cacheHits(); + auto const advancesBefore = cache->ledgerAdvances(); + + // Soft advance to next closed ledger: retain vectors. + env.close(); + cache->advanceLedger(env.closed(), /*forceClear=*/false); + BEAST_EXPECT(cache->ledgerAdvances() == advancesBefore + 1); + BEAST_EXPECT(cache->totalLineCount() == linesBefore); + BEAST_EXPECT(cache->getLedger()->seq() == env.closed()->seq()); + + // Same-seq no-op without forceClear. + auto const advMid = cache->ledgerAdvances(); + cache->advanceLedger(env.closed(), /*forceClear=*/false); + BEAST_EXPECT(cache->ledgerAdvances() == advMid); + + // Hit path: reuse within cacheReuseLedgers. + { + AssetCache::SessionPin pin{1}; + BEAST_EXPECT(cache->getRippleLines(alice.id())); + } + BEAST_EXPECT(cache->cacheHits() > hitsBefore); + BEAST_EXPECT(cache->totalLineCount() == linesBefore); + + // forceClear drops all entries and pins. + cache->advanceLedger(env.closed(), /*forceClear=*/true); + BEAST_EXPECT(cache->totalLineCount() == 0); + BEAST_EXPECT(cache->ledgerAdvances() == advMid + 1); + + // After force-clear, next load is a miss that reloads. + auto const missesBefore = cache->cacheMisses(); + { + AssetCache::SessionPin pin{2}; + BEAST_EXPECT(cache->getRippleLines(alice.id())); + } + BEAST_EXPECT(cache->cacheMisses() > missesBefore); + BEAST_EXPECT(cache->totalLineCount() >= 1); + cache->releaseSession(2); + } + + void + testSoftAdvanceResetsIncompleteCursor() + { + testcase("soft advance keeps progress hint; reload on next access (Option A)"); + using namespace test::jtx; + Env env(*this); + Account const alice{"alice"}; + // Enough lines that a single first-load chunk cannot finish the account. + fundManyLines(env, alice, 10, "ic"); + + auto cache = std::make_shared( + env.current(), + env.app().getJournal("AssetCache"), + /*maxTotalLines=*/100000, + /*maxLinesPerAccount=*/1000, + /*cacheReuseLedgers=*/12, + /*lineChunkSize=*/2); + + { + AssetCache::SessionPin pin{1}; + // First load only (2 lines) — do not call expandIncompleteLines() + // which would multi-chunk up to kPathExpandLinesPerWave and finish. + auto partialLines = cache->getRippleLines(alice.id()); + BEAST_EXPECT(partialLines && partialLines->size() == 2); + } + // pinCount remains until releaseSession (SessionPin only sets TLS). + BEAST_EXPECT(cache->hasIncompleteLines()); + BEAST_EXPECT(cache->totalLineCount() == 2); + + env.close(); + cache->advanceLedger(env.closed(), /*forceClear=*/false); + // Option A: advance does not re-walk — drops line memory, keeps pin + hint. + BEAST_EXPECT(cache->totalLineCount() == 0); + BEAST_EXPECT(cache->hasIncompleteLines()); // stub still incomplete + + { + AssetCache::SessionPin pin{1}; + // On-demand reload from page 0 with want = prev(2) + chunk(2) = 4. + auto lines = cache->getRippleLines(alice.id()); + BEAST_EXPECT(lines && lines->size() == 4); + } + BEAST_EXPECT(cache->totalLineCount() == 4); + BEAST_EXPECT(cache->hasIncompleteLines()); + cache->releaseSession(1); + // Last pin released → entry erased (releaseSession). + BEAST_EXPECT(cache->totalLineCount() == 0); + BEAST_EXPECT(!cache->hasIncompleteLines()); + + // Unpinned incomplete is erased on soft advance (no progress-hint work). + { + auto unpinned = cache->getRippleLines(alice.id()); // no SessionPin + BEAST_EXPECT(unpinned && unpinned->size() == 2); + } + BEAST_EXPECT(cache->hasIncompleteLines()); + env.close(); + cache->advanceLedger(env.closed(), /*forceClear=*/false); + BEAST_EXPECT(cache->totalLineCount() == 0); + BEAST_EXPECT(!cache->hasIncompleteLines()); + } + + void + testLoadScopeDrainsIncompleteSharedHit() + { + testcase("LoadScope drains incomplete shared-cache hit (one-shot dest currencies)"); + using namespace test::jtx; + Env env(*this); + Account const alice{"alice"}; + fundManyLines(env, alice, 8, "ls"); + + // Shared progressive partial (WS-style chunk of 2). + auto cache = std::make_shared( + env.current(), + env.app().getJournal("AssetCache"), + /*maxTotalLines=*/100000, + /*maxLinesPerAccount=*/1000, + /*cacheReuseLedgers=*/12, + /*lineChunkSize=*/2); + + { + AssetCache::SessionPin pin{1}; + auto partial = cache->getRippleLines(alice.id()); + BEAST_EXPECT(partial && partial->size() == 2); + BEAST_EXPECT(cache->hasIncompleteLines()); + } + + // One-shot LoadScope must finish the account on the same cache hit. + { + AssetCache::LoadScope oneShot{1000}; + AssetCache::SessionPin pin{2}; + auto full = cache->getRippleLines(alice.id()); + BEAST_EXPECT(full); + BEAST_EXPECT(full->size() == 8); + BEAST_EXPECT(!cache->hasIncompleteLinesForSession(2)); + } + cache->releaseSession(1); + cache->releaseSession(2); + } + + void + testReuseWindowExpiryReloads() + { + testcase("cache reuse window expiry forces reload"); + using namespace test::jtx; + Env env(*this); + Account const alice{"alice"}; + Account const gw{"gw"}; + env.fund(XRP(10000), alice, gw); + env.close(); + env.trust(gw["USD"](1000), alice); + env(pay(gw, alice, gw["USD"](10))); + env.close(); + + // Tiny reuse window so a few closes force stale reload. + auto cache = std::make_shared( + env.current(), + env.app().getJournal("AssetCache"), + /*maxTotalLines=*/10000, + /*maxLinesPerAccount=*/1000, + /*cacheReuseLedgers=*/1, + /*lineChunkSize=*/64); + + { + AssetCache::SessionPin pin{1}; + BEAST_EXPECT(cache->getRippleLines(alice.id())); + } + auto const misses0 = cache->cacheMisses(); + auto const loadedAt = cache->getLedger()->seq(); + + // Soft-advance past reuse window (age > cacheReuseLedgers_). + // Note: first close may only upgrade open→closed at the same seq. + for (int i = 0; i < 4; ++i) + { + env.close(); + cache->advanceLedger(env.closed(), false); + } + BEAST_EXPECT(cache->getLedger()->seq() > loadedAt + 1); + + { + AssetCache::SessionPin pin{1}; + BEAST_EXPECT(cache->getRippleLines(alice.id())); + } + BEAST_EXPECT(cache->cacheMisses() > misses0); + cache->releaseSession(1); + } + + void + testMaxLinesPerAccountAndChunk() + { + testcase("maxLinesPerAccount cap and chunked LoadScope"); + using namespace test::jtx; + Env env(*this); + Account const alice{"alice"}; + fundManyLines(env, alice, 8, "pl"); + + // Cap at 3 lines; chunk size 2 so progressive fill stops at cap. + auto cache = std::make_shared( + env.current(), + env.app().getJournal("AssetCache"), + /*maxTotalLines=*/100000, + /*maxLinesPerAccount=*/3, + /*cacheReuseLedgers=*/12, + /*lineChunkSize=*/2); + + { + AssetCache::SessionPin pin{1}; + auto lines = cache->getRippleLines(alice.id()); + BEAST_EXPECT(lines); + BEAST_EXPECT(lines->size() <= 3); + // Drain progressive expand until complete or cap. + for (int i = 0; i < 20; ++i) + { + if (!cache->expandIncompleteLines()) + break; + } + lines = cache->getRippleLines(alice.id()); + BEAST_EXPECT(lines); + BEAST_EXPECT(lines->size() <= 3); + BEAST_EXPECT(cache->totalLineCount() <= 3); + // Cap should mark complete so no incomplete warning for session. + BEAST_EXPECT(!cache->hasIncompleteLinesForSession(1) || lines->size() == 3); + } + cache->releaseSession(1); + + // LoadScope one-shot: pull full chunk in one reply (still capped). + cache = std::make_shared( + env.current(), + env.app().getJournal("AssetCache"), + /*maxTotalLines=*/100000, + /*maxLinesPerAccount=*/5, + /*cacheReuseLedgers=*/12, + /*lineChunkSize=*/1); + { + AssetCache::LoadScope oneShot{5}; + AssetCache::SessionPin pin{2}; + auto lines = cache->getRippleLines(alice.id()); + BEAST_EXPECT(lines); + BEAST_EXPECT(lines->size() <= 5); + BEAST_EXPECT(lines->size() >= 1); + } + cache->releaseSession(2); + } + + void + testGlobalBudgetBlocksNewLines() + { + testcase("global maxTotalLines blocks further admits"); + using namespace test::jtx; + Env env(*this); + Account const alice{"alice"}; + Account const bob{"bob"}; + fundManyLines(env, alice, 6, "pa"); + fundManyLines(env, bob, 3, "pb"); + + // Tiny global budget: alice loads a few; bob may be blocked. + auto cache = std::make_shared( + env.current(), + env.app().getJournal("AssetCache"), + /*maxTotalLines=*/2, + /*maxLinesPerAccount=*/100, + /*cacheReuseLedgers=*/12, + /*lineChunkSize=*/2); + + { + AssetCache::SessionPin pin{1}; + auto aLines = cache->getRippleLines(alice.id()); + BEAST_EXPECT(aLines); + BEAST_EXPECT(cache->totalLineCount() <= 2); + // May be incomplete if budget blocked mid-fill. + auto bLines = cache->getRippleLines(bob.id()); + // Bob may get empty incomplete stub when budget exhausted. + if (cache->overBudget() || cache->totalLineCount() >= 2) + { + BEAST_EXPECT(cache->totalLineCount() <= 2); + if (!bLines || bLines->empty()) + BEAST_EXPECT(cache->hasIncompleteLinesForSession(1)); + } + } + cache->releaseSession(1); + BEAST_EXPECT(cache->totalLineCount() == 0); + } + + void + testLineEpochBumpsOnLoadAndExpand() + { + testcase("lineEpoch bumps on load and expand"); + using namespace test::jtx; + Env env(*this); + Account const alice{"alice"}; + fundManyLines(env, alice, 4, "le"); + + auto cache = std::make_shared( + env.current(), + env.app().getJournal("AssetCache"), + /*maxTotalLines=*/100000, + /*maxLinesPerAccount=*/1000, + /*cacheReuseLedgers=*/12, + /*lineChunkSize=*/1); + + BEAST_EXPECT(cache->lineEpoch() == 0); + { + AssetCache::SessionPin pin{1}; + BEAST_EXPECT(cache->getRippleLines(alice.id())); + } + auto const epoch1 = cache->lineEpoch(); + BEAST_EXPECT(epoch1 >= 1); + + if (cache->hasIncompleteLines()) + { + BEAST_EXPECT(cache->expandIncompleteLines()); + BEAST_EXPECT(cache->lineEpoch() > epoch1); + } + cache->releaseSession(1); + } + + void + testConcurrentReadersAndAdvance() + { + // Multi-threaded stress intended to run under TSan CI builds as well as + // the default unit-test matrix. Catches data races on shared_mutex + // cache hits, session pins, and soft ledger advances. + testcase("concurrent getRippleLines / expand / advanceLedger"); + using namespace test::jtx; + Env env(*this); + Account const alice{"alice"}; + Account const bob{"bob"}; + fundManyLines(env, alice, 6, "ca"); + fundManyLines(env, bob, 4, "cb"); + + auto cache = std::make_shared( + env.current(), + env.app().getJournal("AssetCache"), + /*maxTotalLines=*/100000, + /*maxLinesPerAccount=*/1000, + /*cacheReuseLedgers=*/8, + /*lineChunkSize=*/2); + + std::atomic errors{0}; + std::atomic ops{0}; + constexpr int kThreads = 8; + constexpr int kIters = 40; + + auto worker = [&](int sessionId, AccountID const account) { + for (int i = 0; i < kIters; ++i) + { + try + { + AssetCache::SessionPin pin{sessionId}; + auto lines = cache->getRippleLines(account); + if (lines && lines->empty()) + ++errors; + cache->expandIncompleteLines(); + (void)cache->cacheHits(); + (void)cache->cacheMisses(); + (void)cache->totalLineCount(); + (void)cache->lineEpoch(); + (void)cache->hasIncompleteLinesForSession(sessionId); + ++ops; + } + catch (...) + { + ++errors; + } + } + }; + + std::vector threads; + threads.reserve(kThreads + 1); + for (int t = 0; t < kThreads; ++t) + { + auto const& acct = (t % 2 == 0) ? alice.id() : bob.id(); + threads.emplace_back(worker, t + 1, acct); + } + + // Soft-advance worker: re-point at current/closed views (Env is not + // thread-safe — no env.close() off the main thread). + auto const viewA = env.closed(); + env.close(); + auto const viewB = env.closed(); + threads.emplace_back([&, viewA, viewB] { + for (int i = 0; i < kIters; ++i) + { + try + { + cache->advanceLedger(i % 2 == 0 ? viewB : viewA, /*forceClear=*/false); + ++ops; + } + catch (...) + { + ++errors; + } + } + }); + + for (auto& th : threads) + th.join(); + + BEAST_EXPECT(errors == 0); + BEAST_EXPECT(ops >= kThreads * kIters); + // At least one real soft advance when seq differs (viewA → viewB). + BEAST_EXPECT(cache->ledgerAdvances() >= 1); + + // Release all sessions — memory reclaims. + for (int t = 0; t < kThreads; ++t) + cache->releaseSession(t + 1); + BEAST_EXPECT(cache->totalLineCount() == 0); + } + +public: + void + run() override + { + testBudgetZeroEmptyStubAndExpand(); + testEmptyAccountCachedNotRescanned(); + testPendingExpandWhileShared(); + testSessionPinsSharedHub(); + testAdvanceLedgerSoftRetainAndForceClear(); + testSoftAdvanceResetsIncompleteCursor(); + testLoadScopeDrainsIncompleteSharedHit(); + testReuseWindowExpiryReloads(); + testMaxLinesPerAccountAndChunk(); + testGlobalBudgetBlocksNewLines(); + testLineEpochBumpsOnLoadAndExpand(); + testConcurrentReadersAndAdvance(); + } +}; + +BEAST_DEFINE_TESTSUITE(AssetCache, rpc, xrpl); + +} // namespace xrpl diff --git a/src/test/rpc/PathFindSub_test.cpp b/src/test/rpc/PathFindSub_test.cpp new file mode 100644 index 00000000000..1c6f361a1fb --- /dev/null +++ b/src/test/rpc/PathFindSub_test.cpp @@ -0,0 +1,906 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2026 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { +namespace test { + +/** + * Unit / integration coverage for concurrent path_find subscription machinery: + * incremental revalidate, multi-session cache sharing, six-path shape, mid-close + * revalidate-only waves, cache counters, and partial-liquidity behavior after + * covering-path removal. + * + * Complements stock xrpl.app.Path / PathMPT (one-shot ripple_path_find) and + * xrpl.rpc.AssetCache (direct cache unit tests including TSan-friendly + * concurrency). + * + * updateAll is scheduled on the JobQueue (JtClient) to mirror production + * (JtUpdatePf / JtRpc). Steady revalidate only fork-joins when JobQueue + * workers >= 3; with fewer workers it runs serially. Multi-session cases still + * go through the JobQueue so the workers < 3 serial path and the workers >= 3 + * fan-out path are both exercised under realistic scheduling. + */ +class PathFindSub_test : public beast::unit_test::Suite +{ + static json::Value + pfCreate( + jtx::Account const& src, + jtx::Account const& dst, + STAmount const& dstAmt, + std::optional const& srcCurrency = std::nullopt) + { + json::Value req; + req[jss::subcommand] = "create"; + req[jss::source_account] = src.human(); + req[jss::destination_account] = dst.human(); + req[jss::destination_amount] = dstAmt.getJson(JsonOptions::Values::None); + if (srcCurrency) + { + auto& sc = (req[jss::source_currencies] = json::ValueType::Array); + json::Value c; + c[jss::currency] = *srcCurrency; + sc.append(c); + } + return req; + } + + std::optional + waitPathFindUpdate( + WSClient& wsc, + std::chrono::milliseconds timeout = std::chrono::seconds{3}, + bool requireAlts = false) + { + return wsc.findMsg(timeout, [&](json::Value const& jv) { + if (!jv.isMember(jss::type) || jv[jss::type] != "path_find") + return false; + if (!requireAlts) + return true; + return jv.isMember(jss::alternatives) && jv[jss::alternatives].isArray() && + jv[jss::alternatives].size() > 0; + }); + } + + void + drainPathFind(WSClient& wsc) + { + using namespace std::chrono_literals; + while (wsc.findMsg(50ms, [](json::Value const& jv) { + return jv.isMember(jss::type) && jv[jss::type] == "path_find"; + })) + { + } + } + + /** + * Run updateAll on a JobQueue worker (same pool that production uses for + * JtUpdatePf / JtRpc). With workers >= 3, steady revalidate may fan out + * JtPathFindWork from that worker; with workers < 3 it stays serial. + * Returns false if the job did not finish in time. + */ + bool + runUpdateAll( + jtx::Env& env, + std::shared_ptr const& ledger, + bool midClose = false) + { + using namespace std::chrono_literals; + // cv notify — avoid 200×25ms wall sleep that can exceed unit-test budget + // under load while still allowing a short absolute deadline. + auto done = std::make_shared>(false); + auto mtx = std::make_shared(); + auto cv = std::make_shared(); + bool const queued = env.app().getJobQueue().addJob( + JtClient, "PathFindSub-updateAll", [done, mtx, cv, &env, ledger, midClose]() { + env.app().getPathRequestManager().updateAll(ledger, midClose); + { + std::lock_guard const lk(*mtx); + done->store(true, std::memory_order_release); + } + cv->notify_one(); + }); + if (!queued) + { + // Queue full / stopping: run inline. Safe for multi-session when + // workers < 3 (serial path). Prefer the JobQueue path above for + // workers >= 3 so fan-out is exercised from a real pool thread. + env.app().getPathRequestManager().updateAll(ledger, midClose); + return true; + } + std::unique_lock lk(*mtx); + return cv->wait_for(lk, 5s, [&] { return done->load(std::memory_order_acquire); }); + } + + void + waveClosed(jtx::Env& env) + { + env.close(); + BEAST_EXPECT(runUpdateAll(env, env.closed())); + } + + jtx::Env + makeEnv(bool multiWorker = true, int workers = 4) + { + using namespace jtx; + return Env(*this, envconfig([multiWorker, workers](std::unique_ptr cfg) { + // Stand-alone without forceMultiThread → Application always builds + // 1 JobQueue thread even if [workers] is higher (see Application.cpp). + // runParallel is serial for workers < 3 and may fan out for workers >= 3 + // (batch ≤ workers - 1). Default multiWorker workers=4 exercises fan-out. + if (multiWorker) + { + cfg->forceMultiThread = true; + cfg->workers = workers; + } + else + { + cfg->forceMultiThread = false; + // Intentionally cfg.workers=2 while JobQueue is still 1-thread — + // catches jobQueueWorkerCount checking workers before standalone. + cfg->workers = 2; + } + cfg->pathFullSearchInterval = 2; + cfg->pathCacheReuseLedgers = 4; + cfg->pathMidCloseDelay = std::chrono::milliseconds{200}; + cfg->pathFindLineChunkSize = 64; + return cfg; + })); + } + + void + setupUsdCorridor( + jtx::Env& env, + jtx::Account const& gw, + jtx::Account const& alice, + jtx::Account const& bob) + { + using namespace jtx; + auto const usd = gw["USD"]; + env.fund(XRP(100000), alice, bob, gw); + env.close(); + env.trust(usd(10000), alice); + env.trust(usd(10000), bob); + env(pay(gw, alice, usd(5000))); + env(pay(gw, bob, usd(100))); + env.close(); + } + + void + testRevalidateAcrossCloses() + { + testcase("revalidate: second closed-ledger update keeps alternatives"); + using namespace jtx; + using namespace std::chrono_literals; + Env env = makeEnv(); + Account const gw{"gateway"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + setupUsdCorridor(env, gw, alice, bob); + + auto wsc = makeWSClient(env.app().config()); + auto const create = wsc->invoke("path_find", pfCreate(alice, bob, bob["USD"](20), "USD")); + auto const& cr = create[jss::result]; + BEAST_EXPECT(!cr.isMember(jss::error)); + BEAST_EXPECT(cr.isMember(jss::alternatives)); + BEAST_EXPECT(cr[jss::alternatives].size() >= 1); + + BEAST_EXPECT(runUpdateAll(env, env.closed())); + auto first = waitPathFindUpdate(*wsc, 5s, /*requireAlts=*/true); + BEAST_EXPECT(first); + drainPathFind(*wsc); + + auto const hitsBefore = [&]() -> double { + auto gc = env.rpc("get_counts")[jss::result]; + if (gc.isMember("pathfind_cache_hits")) + return gc["pathfind_cache_hits"].asDouble(); + return 0; + }(); + + waveClosed(env); + auto second = waitPathFindUpdate(*wsc, 5s, /*requireAlts=*/true); + BEAST_EXPECT(second); + if (second) + { + BEAST_EXPECT((*second)[jss::alternatives].isArray()); + BEAST_EXPECT((*second)[jss::alternatives].size() >= 1); + if (second->isMember(jss::full_reply)) + BEAST_EXPECT((*second)[jss::full_reply].asBool()); + } + drainPathFind(*wsc); + + auto gc = env.rpc("get_counts")[jss::result]; + BEAST_EXPECT(gc.isMember("pathfind_cache_hits")); + BEAST_EXPECT(gc.isMember("pathfind_cache_misses")); + BEAST_EXPECT(gc.isMember("pathfind_cache_lines")); + BEAST_EXPECT(gc["pathfind_cache_lines"].asDouble() > 0); + BEAST_EXPECT(gc["pathfind_cache_hits"].asDouble() >= hitsBefore); + + waveClosed(env); + auto third = waitPathFindUpdate(*wsc, 5s, /*requireAlts=*/true); + BEAST_EXPECT(third); + + json::Value closeReq; + closeReq[jss::subcommand] = "close"; + auto closed = wsc->invoke("path_find", closeReq)[jss::result]; + BEAST_EXPECT(!closed.isMember(jss::error) || closed[jss::status] == "success"); + wsc.reset(); + + for (int i = 0; i < 40; ++i) + { + gc = env.rpc("get_counts")[jss::result]; + if (gc["pathfind_cache_lines"].asDouble() == 0) + break; + std::this_thread::sleep_for(25ms); + } + BEAST_EXPECT(gc["pathfind_cache_lines"].asDouble() == 0); + } + + void + testMultiSessionSharedCache() + { + testcase("multi-session: shared cache live while sessions open, reclaims on close"); + using namespace jtx; + using namespace std::chrono_literals; + Env env = makeEnv(); + Account const gw{"gateway"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const carol{"carol"}; + Account const dan{"dan"}; + setupUsdCorridor(env, gw, alice, bob); + env.fund(XRP(100000), carol, dan); + env.close(); + env.trust(gw["USD"](10000), carol); + env.trust(gw["USD"](10000), dan); + env(pay(gw, carol, gw["USD"](2000))); + env(pay(gw, dan, gw["USD"](50))); + env.close(); + + constexpr int kSessions = 4; + std::vector> clients; + clients.reserve(kSessions); + std::vector> pairs = { + {alice, bob}, {carol, dan}, {alice, dan}, {carol, bob}}; + + for (int i = 0; i < kSessions; ++i) + { + clients.push_back(makeWSClient(env.app().config())); + auto const& [src, dst] = pairs[static_cast(i)]; + auto jr = clients.back()->invoke( + "path_find", pfCreate(src, dst, dst["USD"](5), "USD"))[jss::result]; + BEAST_EXPECT(!jr.isMember(jss::error)); + BEAST_EXPECT(jr.isMember(jss::alternatives)); + } + + // First-update wave for all new sessions (always serial; JobQueue path). + BEAST_EXPECT(runUpdateAll(env, env.closed())); + int firstWave = 0; + for (auto& c : clients) + { + if (waitPathFindUpdate(*c, 5s, true)) + ++firstWave; + drainPathFind(*c); + } + BEAST_EXPECT(firstWave >= kSessions); + + auto gc = env.rpc("get_counts")[jss::result]; + BEAST_EXPECT(gc["pathfind_cache_lines"].asDouble() > 0); + auto const linesWhileOpen = gc["pathfind_cache_lines"].asDouble(); + + // Steady closed wave across all sessions (exercises parallel revalidate). + waveClosed(env); + int refreshed = 0; + for (auto& c : clients) + { + if (waitPathFindUpdate(*c, 5s, /*requireAlts=*/false)) + ++refreshed; + drainPathFind(*c); + } + BEAST_EXPECT(refreshed == kSessions); + + gc = env.rpc("get_counts")[jss::result]; + BEAST_EXPECT(gc["pathfind_cache_lines"].asDouble() > 0); + // Shared hubs should not thrash to empty between sessions. + BEAST_EXPECT(gc["pathfind_cache_lines"].asDouble() >= linesWhileOpen * 0.5); + + // Close half — cache should remain while others hold pins. + for (int i = 0; i < kSessions / 2; ++i) + { + json::Value closeReq; + closeReq[jss::subcommand] = "close"; + (void)clients[static_cast(i)]->invoke("path_find", closeReq); + clients[static_cast(i)].reset(); + } + gc = env.rpc("get_counts")[jss::result]; + BEAST_EXPECT(gc["pathfind_cache_lines"].asDouble() > 0); + + for (auto& c : clients) + { + if (!c) + continue; + json::Value closeReq; + closeReq[jss::subcommand] = "close"; + (void)c->invoke("path_find", closeReq); + } + clients.clear(); + + for (int i = 0; i < 40; ++i) + { + gc = env.rpc("get_counts")[jss::result]; + if (gc["pathfind_cache_lines"].asDouble() == 0) + break; + std::this_thread::sleep_for(25ms); + } + BEAST_EXPECT(gc["pathfind_cache_lines"].asDouble() == 0); + } + + void + multiSessionSteadyNoHang(jtx::Env& env, int expectedSessions) + { + using namespace jtx; + using namespace std::chrono_literals; + Account const gw{"gateway"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const carol{"carol"}; + Account const dan{"dan"}; + setupUsdCorridor(env, gw, alice, bob); + env.fund(XRP(100000), carol, dan); + env.close(); + env.trust(gw["USD"](10000), carol); + env.trust(gw["USD"](10000), dan); + env(pay(gw, carol, gw["USD"](2000))); + env(pay(gw, dan, gw["USD"](50))); + env.close(); + + std::vector> clients; + std::vector> pairs = {{alice, bob}, {carol, dan}, {alice, dan}}; + BEAST_EXPECT(static_cast(pairs.size()) >= expectedSessions); + + for (int i = 0; i < expectedSessions; ++i) + { + clients.push_back(makeWSClient(env.app().config())); + auto const& [src, dst] = pairs[static_cast(i)]; + auto jr = clients.back()->invoke( + "path_find", pfCreate(src, dst, dst["USD"](5), "USD"))[jss::result]; + BEAST_EXPECT(!jr.isMember(jss::error)); + } + + // First wave (new sessions) then steady wave. + BEAST_EXPECT(runUpdateAll(env, env.closed())); + for (auto& c : clients) + drainPathFind(*c); + + waveClosed(env); + int refreshed = 0; + for (auto& c : clients) + { + if (waitPathFindUpdate(*c, 5s, /*requireAlts=*/false)) + ++refreshed; + drainPathFind(*c); + } + BEAST_EXPECT(refreshed == expectedSessions); + + for (auto& c : clients) + { + json::Value closeReq; + closeReq[jss::subcommand] = "close"; + (void)c->invoke("path_find", closeReq); + } + } + + void + testSingleWorkerMultiSessionNoHang() + { + // Regression: stand-alone without forceMultiThread has 1 JobQueue + // thread even when cfg.workers > 1. jobQueueWorkerCount must report 1 + // (standalone before workers) so runParallel stays serial (workers < 3). + // Fan-out on a 1-thread pool hangs forever on doneCv. + testcase("single worker: multi-session steady wave does not hang"); + auto env = makeEnv(/*multiWorker=*/false); + multiSessionSteadyNoHang(env, /*expectedSessions=*/3); + } + + void + testTwoWorkerMultiSessionNoHang() + { + // Regression: forceMultiThread + workers=2 is a real 2-thread JobQueue. + // runParallel must stay serial (workers < 3). Fan-out here hangs when a + // second updateAll blocks on waveMutex_ — zero threads left for + // JtPathFindWork while the parent waits on doneCv. + testcase("two workers: multi-session steady wave does not hang"); + using namespace std::chrono_literals; + auto env = makeEnv(/*multiWorker=*/true, /*workers=*/2); + + // Establish sessions and run a closed wave (serial: workers == 2 < 3). + multiSessionSteadyNoHang(env, /*expectedSessions=*/3); + + // Re-open sessions and race closed + mid-close updateAll on the two + // workers (the historical hang shape). + using namespace jtx; + Account const gw{"gateway"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const carol{"carol"}; + Account const dan{"dan"}; + + constexpr int kSessions = 3; + std::vector> clients; + std::vector> pairs = {{alice, bob}, {carol, dan}, {alice, dan}}; + for (int i = 0; i < kSessions; ++i) + { + clients.push_back(makeWSClient(env.app().config())); + auto const& [src, dst] = pairs[static_cast(i)]; + auto jr = clients.back()->invoke( + "path_find", pfCreate(src, dst, dst["USD"](5), "USD"))[jss::result]; + BEAST_EXPECT(!jr.isMember(jss::error)); + } + BEAST_EXPECT(runUpdateAll(env, env.closed())); + for (auto& c : clients) + drainPathFind(*c); + + auto doneClosed = std::make_shared>(false); + auto doneMid = std::make_shared>(false); + auto const closed = env.closed(); + auto const open = env.current(); + bool const q1 = env.app().getJobQueue().addJob( + JtClient, "PathFindSub-closed", [doneClosed, &env, closed]() { + env.app().getPathRequestManager().updateAll(closed, /*midClose=*/false); + doneClosed->store(true, std::memory_order_release); + }); + bool const q2 = + env.app().getJobQueue().addJob(JtClient, "PathFindSub-mid", [doneMid, &env, open]() { + env.app().getPathRequestManager().updateAll(open, /*midClose=*/true); + doneMid->store(true, std::memory_order_release); + }); + BEAST_EXPECT(q1 && q2); + + bool bothDone = false; + for (int i = 0; i < 400; ++i) + { + if (doneClosed->load(std::memory_order_acquire) && + doneMid->load(std::memory_order_acquire)) + { + bothDone = true; + break; + } + std::this_thread::sleep_for(25ms); + } + BEAST_EXPECT(bothDone); + + for (auto& c : clients) + { + json::Value closeReq; + closeReq[jss::subcommand] = "close"; + (void)c->invoke("path_find", closeReq); + } + } + + void + testSixPathShape() + { + testcase("path set shape: up to six alternatives (no covering spare)"); + using namespace jtx; + Env env = makeEnv(); + Account const g1{"g1"}; + Account const g2{"g2"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const m1{"m1"}; + Account const m2{"m2"}; + Account const m3{"m3"}; + Account const m4{"m4"}; + + env.fund(XRP(1000000), alice, bob, g1, g2, m1, m2, m3, m4); + env.close(); + + env.trust(g1["USD"](100000), alice); + env.trust(g2["HKD"](100000), bob); + env(pay(g1, alice, g1["USD"](50000))); + env.close(); + + for (auto const& m : {m1, m2, m3, m4}) + { + env.trust(g1["USD"](100000), m); + env.trust(g2["HKD"](100000), m); + env(pay(g1, m, g1["USD"](10000))); + env(pay(g2, m, g2["HKD"](10000))); + } + env.close(); + + env(offer(m1, g1["USD"](1000), g2["HKD"](1000))); + env(offer(m2, g1["USD"](1000), g2["HKD"](990))); + env(offer(m3, g1["USD"](1000), g2["HKD"](980))); + env(offer(m4, g1["USD"](1000), g2["HKD"](970))); + env(offer(m1, g1["USD"](500), XRP(500))); + env(offer(m1, XRP(500), g2["HKD"](500))); + env(offer(m2, g1["USD"](500), XRP(480))); + env(offer(m2, XRP(480), g2["HKD"](500))); + env.close(); + + json::Value params; + params[jss::source_account] = alice.human(); + params[jss::destination_account] = bob.human(); + params[jss::destination_amount] = bob["HKD"](10).value().getJson(JsonOptions::Values::None); + { + auto& sc = (params[jss::source_currencies] = json::ValueType::Array); + json::Value c; + c[jss::currency] = "USD"; + c[jss::issuer] = g1.human(); + sc.append(c); + } + + auto const resp = env.rpc("json", "ripple_path_find", to_string(params)); + auto const& result = resp[jss::result]; + BEAST_EXPECT(!result.isMember(jss::error)); + BEAST_EXPECT(result.isMember(jss::alternatives)); + auto const& alts = result[jss::alternatives]; + BEAST_EXPECT(alts.isArray()); + BEAST_EXPECT(alts.size() >= 1); + + unsigned maxPathsInAlt = 0; + for (unsigned i = 0; i < alts.size(); ++i) + { + auto const& alt = alts[i]; + if (!alt.isMember(jss::paths_computed)) + continue; + auto const n = alt[jss::paths_computed].size(); + if (n > maxPathsInAlt) + maxPathsInAlt = n; + BEAST_EXPECT(n <= static_cast(rpc::tuning::kPathFindMaxPaths)); + } + BEAST_EXPECT(maxPathsInAlt >= 1); + BEAST_EXPECT(rpc::tuning::kPathFindMaxPaths == 6); + } + + void + testPartialLiquidityNoCoveringSpare() + { + testcase("partial liquidity: no covering-path retry (best-effort alts)"); + using namespace jtx; + Env env = makeEnv(); + Account const gw{"gateway"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const thin{"thin"}; + Account const eve{"eve"}; + + env.fund(XRP(100000), alice, bob, gw, thin, eve); + env.close(); + env.trust(gw["USD"](10000), alice); + env.trust(gw["USD"](10000), bob); + env.trust(gw["USD"](10000), thin); + env(pay(gw, alice, gw["USD"](5000))); + env(pay(gw, thin, gw["USD"](5))); + env.close(); + env(offer(thin, XRP(5), gw["USD"](5))); + env.close(); + + // eve pays bob USD using only XRP via a 5-USD book for dest 100 USD. + // Covering-path removal: failed maxPaths set is dropped (empty alts OK). + json::Value params; + params[jss::source_account] = eve.human(); + params[jss::destination_account] = bob.human(); + params[jss::destination_amount] = + bob["USD"](100).value().getJson(JsonOptions::Values::None); + { + auto& sc = (params[jss::source_currencies] = json::ValueType::Array); + json::Value c; + c[jss::currency] = "XRP"; + sc.append(c); + } + + auto const resp = env.rpc("json", "ripple_path_find", to_string(params)); + auto const& result = resp[jss::result]; + BEAST_EXPECT(!result.isMember(jss::error)); + BEAST_EXPECT(result.isMember(jss::alternatives)); + BEAST_EXPECT(result[jss::alternatives].isArray()); + BEAST_EXPECT(result[jss::alternatives].size() <= 1); + if (result[jss::alternatives].size() == 1 && + result[jss::alternatives][0u].isMember(jss::paths_computed)) + { + BEAST_EXPECT( + result[jss::alternatives][0u][jss::paths_computed].size() <= + static_cast(rpc::tuning::kPathFindMaxPaths)); + } + } + + void + testStaggeredRediscoverySurvivesManyCloses() + { + testcase("stagger: many closes keep alternatives (interval=2)"); + using namespace jtx; + using namespace std::chrono_literals; + Env env = makeEnv(); + Account const gw{"gateway"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + setupUsdCorridor(env, gw, alice, bob); + + auto wsc = makeWSClient(env.app().config()); + auto jr = + wsc->invoke("path_find", pfCreate(alice, bob, bob["USD"](10), "USD"))[jss::result]; + BEAST_EXPECT(!jr.isMember(jss::error)); + + BEAST_EXPECT(runUpdateAll(env, env.closed())); + BEAST_EXPECT(waitPathFindUpdate(*wsc, 5s, true)); + drainPathFind(*wsc); + + int updates = 0; + for (int i = 0; i < 6; ++i) + { + waveClosed(env); + if (waitPathFindUpdate(*wsc, 5s, true)) + ++updates; + drainPathFind(*wsc); + } + BEAST_EXPECT(updates >= 4); + + json::Value closeReq; + closeReq[jss::subcommand] = "close"; + (void)wsc->invoke("path_find", closeReq); + } + + void + testMidCloseRevalidateOnly() + { + testcase("mid-close: revalidate-only wave pushes updates"); + using namespace jtx; + using namespace std::chrono_literals; + Env env = makeEnv(); + Account const gw{"gateway"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + setupUsdCorridor(env, gw, alice, bob); + + auto wsc = makeWSClient(env.app().config()); + auto jr = + wsc->invoke("path_find", pfCreate(alice, bob, bob["USD"](10), "USD"))[jss::result]; + BEAST_EXPECT(!jr.isMember(jss::error)); + + BEAST_EXPECT(runUpdateAll(env, env.closed())); + BEAST_EXPECT(waitPathFindUpdate(*wsc, 5s, true)); + drainPathFind(*wsc); + + // Mid-close on open ledger: revalidateOnly, does not pin lastIndex_. + BEAST_EXPECT(runUpdateAll(env, env.current(), /*midClose=*/true)); + auto mid = waitPathFindUpdate(*wsc, 5s, /*requireAlts=*/false); + BEAST_EXPECT(mid); + drainPathFind(*wsc); + + // Same-seq closed wave still runs (mid-close did not pin lastIndex_). + waveClosed(env); + auto after = waitPathFindUpdate(*wsc, 5s, true); + BEAST_EXPECT(after); + + json::Value closeReq; + closeReq[jss::subcommand] = "close"; + (void)wsc->invoke("path_find", closeReq); + } + + /** + * Mid-close must not consume pathFindNewRequest_. If it did, LedgerMaster:: + * updatePaths can exit with "Nothing to do" and a brand-new path_find client + * waits until the next closed ledger for its first full Pathfinder result. + */ + void + testMidClosePreservesNewSubscriptionSignal() + { + testcase("mid-close: does not swallow new path_find subscription signal"); + using namespace jtx; + using namespace std::chrono_literals; + Env env = makeEnv(); + Account const gw{"gateway"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const carol{"carol"}; + setupUsdCorridor(env, gw, alice, bob); + env.fund(XRP(100000), carol); + env.close(); + env.trust(gw["USD"](10000), carol); + env(pay(gw, carol, gw["USD"](5000))); + env.close(); + + // Established session A so mid-close has work and the timer path is live. + auto wscA = makeWSClient(env.app().config()); + auto jrA = + wscA->invoke("path_find", pfCreate(alice, bob, bob["USD"](10), "USD"))[jss::result]; + BEAST_EXPECT(!jrA.isMember(jss::error)); + BEAST_EXPECT(runUpdateAll(env, env.closed())); + BEAST_EXPECT(waitPathFindUpdate(*wscA, 5s, true)); + drainPathFind(*wscA); + + auto& lm = env.app().getLedgerMaster(); + // Drop any residual create signal from session A. + (void)lm.isNewPathRequest(); + + // Brand-new client B. makePathRequest sets pathFindNewRequest_ and may + // queue PthFindNewReq. Mid-close must not clear that flag. + auto wscB = makeWSClient(env.app().config()); + auto jrB = + wscB->invoke("path_find", pfCreate(carol, bob, bob["USD"](5), "USD"))[jss::result]; + BEAST_EXPECT(!jrB.isMember(jss::error)); + + // Pure mid-close wave (same entry as periodic revalidate). Must not + // first-Pathfind B (isFirst skip) and must not steal the create signal. + BEAST_EXPECT(runUpdateAll(env, env.current(), /*midClose=*/true)); + + // Flag preservation: re-arm a create signal and ensure mid-close leaves + // it set. Concurrent updatePaths may drain the flag after mid-close + // releases waveMutex_, so retry a few times; with the bug mid-close + // always consumes and preserved stays 0 when mid-close runs with the + // flag set. + int attempted = 0; + int preserved = 0; + for (int i = 0; i < 40; ++i) + { + (void)lm.isNewPathRequest(); + if (!lm.newPathRequest()) + continue; + ++attempted; + // Inline mid-close holds waveMutex_ so a concurrent create updateAll + // blocks before isNewPathRequest — only mid-close could clear it. + env.app().getPathRequestManager().updateAll(env.current(), /*midClose=*/true); + if (lm.isNewPathRequest()) + ++preserved; + } + BEAST_EXPECT(attempted > 0); + // Fix: mid-close never consumes → preserved tracks attempted (minus a + // rare post-unlock drain). Bug: mid-close always consumes → preserved≈0. + BEAST_EXPECT(preserved * 2 >= attempted); + + // B must still receive a first full result without waiting for a new + // closed ledger (create-style open wave / queued PthFindNewReq). + if (!waitPathFindUpdate(*wscB, 100ms, true)) + { + // Explicit create wake if the JobQueue job already lost the race. + BEAST_EXPECT(runUpdateAll(env, env.current(), /*midClose=*/false)); + } + BEAST_EXPECT(waitPathFindUpdate(*wscB, 5s, true)); + + json::Value closeReq; + closeReq[jss::subcommand] = "close"; + (void)wscA->invoke("path_find", closeReq); + (void)wscB->invoke("path_find", closeReq); + } + + /** + * Soft auto-source cap is 16. Pure alphabetical order puts "XRP" after + * many 3-letter IOU codes, so multi-currency accounts would drop XRP and + * miss the usual cheapest route. XRP must be retained under the soft cap. + */ + void + testAutoSourceKeepsXrpUnderSoftCap() + { + testcase("auto source: XRP retained under soft cap with many IOUs"); + using namespace jtx; + using namespace std::chrono_literals; + Env env = makeEnv(); + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const gw{"gateway"}; + // More than kMaxAutoSrcCurSub (16) IOU currencies that sort before "XRP". + constexpr int kIouCount = 20; + env.fund(XRP(1000000), alice, bob, gw); + env.close(); + + for (int i = 0; i < kIouCount; ++i) + { + // "A00".."A19" — all lexicographically before "XRP". + char code[4] = { + 'A', static_cast('0' + (i / 10)), static_cast('0' + (i % 10)), '\0'}; + auto const iou = gw[code]; + env.trust(iou(10000), alice); + env(pay(gw, alice, iou(100))); + } + // Only viable payment path: spend XRP into bob's gw/USD via book. + // None of the Axx IOUs have books, so dropping XRP under the soft cap + // would yield no alternatives. + env.trust(gw["USD"](10000), bob); + env(pay(gw, bob, gw["USD"](1))); + env(offer(gw, XRP(100), gw["USD"](100))); + env.close(); + + auto wsc = makeWSClient(env.app().config()); + // No source_currencies → auto set (soft-capped at 16). + auto jr = wsc->invoke("path_find", pfCreate(alice, bob, gw["USD"](10)))[jss::result]; + BEAST_EXPECT(!jr.isMember(jss::error)); + BEAST_EXPECT(runUpdateAll(env, env.closed())); + auto upd = waitPathFindUpdate(*wsc, 5s, true); + BEAST_EXPECT(upd); + + bool sawXrpSource = false; + if (upd && upd->isMember(jss::alternatives) && (*upd)[jss::alternatives].isArray()) + { + for (auto const& alt : (*upd)[jss::alternatives]) + { + if (!alt.isMember(jss::source_amount)) + continue; + auto const& sa = alt[jss::source_amount]; + // Native XRP is often a drops string; IOUs are objects with currency. + if (sa.isString() || + (sa.isObject() && + (!sa.isMember(jss::currency) || sa[jss::currency].asString() == "XRP"))) + { + sawXrpSource = true; + break; + } + } + } + BEAST_EXPECT(sawXrpSource); + + json::Value closeReq; + closeReq[jss::subcommand] = "close"; + (void)wsc->invoke("path_find", closeReq); + } + +public: + void + run() override + { + testRevalidateAcrossCloses(); + testMultiSessionSharedCache(); + testSingleWorkerMultiSessionNoHang(); + testTwoWorkerMultiSessionNoHang(); + testSixPathShape(); + testPartialLiquidityNoCoveringSpare(); + testStaggeredRediscoverySurvivesManyCloses(); + testMidCloseRevalidateOnly(); + testMidClosePreservesNewSubscriptionSignal(); + testAutoSourceKeepsXrpUnderSoftCap(); + } +}; + +BEAST_DEFINE_TESTSUITE(PathFindSub, rpc, xrpl); + +} // namespace test +} // namespace xrpl diff --git a/src/xrpld/core/Config.h b/src/xrpld/core/Config.h index ac28b6e224b..12d44c5e651 100644 --- a/src/xrpld/core/Config.h +++ b/src/xrpld/core/Config.h @@ -200,6 +200,23 @@ class Config : public BasicConfig int pathSearchFast = 2; int pathSearchMax = 3; + // Concurrent path_find (WebSocket + AssetCache) knobs. Defaults match + // rpc::tuning::* constants. Override via the [path_find] config section. + // + // pathCacheReuseLedgers: how many ledger advances a cached trust-line + // vector may be reused without reload (best-effort staleness). Larger + // values cut owner-dir thrash under load; smaller values are fresher. + std::uint32_t pathCacheReuseLedgers = 6; + // WS progressive owner-dir load size (lines per load/expand step). + std::size_t pathFindLineChunkSize = 64; + // Closed-ledger interval between full Pathfinder rediscoveries (staggered). + std::uint32_t pathFullSearchInterval = 3; + // Open-ledger revalidate-only tick period for live path_find sessions. + std::chrono::milliseconds pathMidCloseDelay{500}; + // Soft global / per-account caps on PathFindTrustLine objects in AssetCache. + std::size_t pathFindMaxTotalLines = 1'000'000; + std::size_t pathFindMaxLinesPerAccount = 50'000; + // Validation std::optional validationQuorum; // validations to consider ledger authoritative diff --git a/src/xrpld/core/detail/Config.cpp b/src/xrpld/core/detail/Config.cpp index e93ccec56e7..e1c9ba71e6f 100644 --- a/src/xrpld/core/detail/Config.cpp +++ b/src/xrpld/core/detail/Config.cpp @@ -735,6 +735,85 @@ Config::loadFromString(std::string const& fileContents) if (getSingleSection(secConfig, Sections::kPathSearchMax, strTemp, j_)) pathSearchMax = beast::lexicalCastThrow(strTemp); + // Concurrent path_find / AssetCache knobs (optional multi-key section). + if (exists(Sections::kPathFind)) + { + auto const sec = section(Sections::kPathFind); + + // Parse as signed so negative config values reject cleanly. valueOr on + // uint32_t cannot express a lower bound of 0 (unsigned wrap / cast). + if (auto const raw = sec.get(Keys::kCacheReuseLedgers)) + { + if (*raw < 0 || *raw > 64) + { + Throw( + std::string("Invalid ") + Sections::kPathFind + " " + Keys::kCacheReuseLedgers + + ": must be between 0 and 64 inclusive"); + } + pathCacheReuseLedgers = static_cast(*raw); + } + + pathFindLineChunkSize = sec.valueOr(Keys::kLineChunkSize, pathFindLineChunkSize); + if (pathFindLineChunkSize < 1 || pathFindLineChunkSize > 1024) + { + Throw( + std::string("Invalid ") + Sections::kPathFind + " " + Keys::kLineChunkSize + + ": must be between 1 and 1024 inclusive"); + } + + pathFullSearchInterval = sec.valueOr(Keys::kFullSearchInterval, pathFullSearchInterval); + if (pathFullSearchInterval < 1 || pathFullSearchInterval > 100) + { + Throw( + std::string("Invalid ") + Sections::kPathFind + " " + Keys::kFullSearchInterval + + ": must be between 1 and 100 inclusive"); + } + + { + auto const ms = sec.valueOr( + Keys::kMidCloseMs, static_cast(pathMidCloseDelay.count())); + if (ms < 50 || ms > 10'000) + { + Throw( + std::string("Invalid ") + Sections::kPathFind + " " + Keys::kMidCloseMs + + ": must be between 50 and 10000 inclusive"); + } + pathMidCloseDelay = std::chrono::milliseconds{ms}; + } + + pathFindMaxTotalLines = sec.valueOr(Keys::kMaxTotalLines, pathFindMaxTotalLines); + if (pathFindMaxTotalLines < 1'000) + { + Throw( + std::string("Invalid ") + Sections::kPathFind + " " + Keys::kMaxTotalLines + + ": must be at least 1000"); + } + + // max_lines_per_account defaults to 50000. If the operator only sets + // max_total_lines to the documented minimum (1000), that default would + // exceed the total budget and refuse to start. When per-account is + // omitted, clamp the default under max_total_lines. Explicit values + // outside [64, max_total_lines] still error. + if (sec.exists(Keys::kMaxLinesPerAccount)) + { + pathFindMaxLinesPerAccount = + sec.valueOr(Keys::kMaxLinesPerAccount, pathFindMaxLinesPerAccount); + if (pathFindMaxLinesPerAccount < 64 || + pathFindMaxLinesPerAccount > pathFindMaxTotalLines) + { + Throw( + std::string("Invalid ") + Sections::kPathFind + " " + + Keys::kMaxLinesPerAccount + + ": must be between 64 and max_total_lines inclusive"); + } + } + else + { + pathFindMaxLinesPerAccount = + std::min(pathFindMaxLinesPerAccount, pathFindMaxTotalLines); + } + } + if (getSingleSection(secConfig, Sections::kDebugLogfile, strTemp, j_)) debugLogfile_ = strTemp; diff --git a/src/xrpld/rpc/detail/AccountAssets.cpp b/src/xrpld/rpc/detail/AccountAssets.cpp index 67b9174fe32..ae612d9b1f4 100644 --- a/src/xrpld/rpc/detail/AccountAssets.cpp +++ b/src/xrpld/rpc/detail/AccountAssets.cpp @@ -25,11 +25,12 @@ accountSourceAssets( if (includeXRP) assets.insert(xrpCurrency()); - if (auto const lines = lrCache->getRippleLines(account, LineDirection::Outgoing)) + // Full (unfiltered) load: need every currency the account can send. + if (auto const lines = lrCache->getRippleLines(account)) { for (auto const& rspEntry : *lines) { - auto& saBalance = rspEntry.getBalance(); + auto const& saBalance = rspEntry.getBalance(); // Filter out non if (saBalance > beast::kZero @@ -69,11 +70,12 @@ accountDestAssets( assets.insert(xrpCurrency()); // Even if account doesn't exist - if (auto const lines = lrCache->getRippleLines(account, LineDirection::Outgoing)) + // Full (unfiltered) load: need every currency the account can receive. + if (auto const lines = lrCache->getRippleLines(account)) { for (auto const& rspEntry : *lines) { - auto& saBalance = rspEntry.getBalance(); + auto const& saBalance = rspEntry.getBalance(); if (saBalance < rspEntry.getLimit()) // Can take more assets.insert(saBalance.get().currency); diff --git a/src/xrpld/rpc/detail/AssetCache.cpp b/src/xrpld/rpc/detail/AssetCache.cpp index e29f5659f9e..9b36e56a97c 100644 --- a/src/xrpld/rpc/detail/AssetCache.cpp +++ b/src/xrpld/rpc/detail/AssetCache.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -15,115 +16,678 @@ #include #include +#include #include #include +#include #include #include namespace xrpl { +namespace { -AssetCache::AssetCache(std::shared_ptr ledger, beast::Journal j) - : ledger_(std::move(ledger)), journal_(j) +// Thread-local session id for automatic pin on getRippleLines (set by SessionPin). +thread_local int tlsPinSessionId = 0; + +// Thread-local chunk override from LoadScope. 0 = use AssetCache::lineChunkSize_. +thread_local std::size_t tlsChunkOverride = 0; + +} // namespace + +AssetCache::SessionPin::SessionPin(int sessionId) noexcept : prev_(tlsPinSessionId) +{ + tlsPinSessionId = sessionId; +} + +AssetCache::SessionPin::~SessionPin() noexcept +{ + tlsPinSessionId = prev_; +} + +AssetCache::LoadScope::LoadScope(std::size_t chunkLines) noexcept : prev_(tlsChunkOverride) +{ + tlsChunkOverride = chunkLines == 0 ? 0 : chunkLines; +} + +AssetCache::LoadScope::~LoadScope() noexcept { - JLOG(journal_.debug()) << "created for ledger " << ledger_->header().seq; + tlsChunkOverride = prev_; +} + +AssetCache::AssetCache( + std::shared_ptr ledger, + beast::Journal j, + std::size_t maxTotalLines, + std::size_t maxLinesPerAccount, + std::uint32_t cacheReuseLedgers, + std::size_t lineChunkSize) + : ledger_(std::move(ledger)) + , journal_(j) + , maxTotalLines_(maxTotalLines) + , maxLinesPerAccount_(maxLinesPerAccount) + , cacheReuseLedgers_(cacheReuseLedgers) + , lineChunkSize_(lineChunkSize == 0 ? rpc::tuning::kPathFindLineChunkSize : lineChunkSize) +{ + JLOG(journal_.debug()) << "created for ledger " << ledger_->header().seq + << " maxTotalLines=" << maxTotalLines_ + << " maxLinesPerAccount=" << maxLinesPerAccount_ + << " cacheReuseLedgers=" << cacheReuseLedgers_ + << " lineChunkSize=" << lineChunkSize_; } AssetCache::~AssetCache() { JLOG(journal_.debug()) << "destroyed for ledger " << ledger_->header().seq << " with " - << lines_.size() << " accounts and " << totalLineCount_ - << " distinct trust lines."; + << lines_.size() << " accounts and " + << totalLineCount_.load(std::memory_order_relaxed) + << " trust lines (hits=" << cacheHits_.load(std::memory_order_relaxed) + << " misses=" << cacheMisses_.load(std::memory_order_relaxed) + << " loaded=" << linesLoaded_.load(std::memory_order_relaxed) + << " advances=" << ledgerAdvances_.load(std::memory_order_relaxed) + << ")"; +} + +std::shared_ptr +AssetCache::getLedger() const +{ + std::shared_lock const sl(lock_); + return ledger_; +} + +void +AssetCache::advanceLedger(std::shared_ptr const& ledger, bool forceClear) +{ + std::unique_lock const sl(lock_); + if (!ledger) + return; + + auto const oldSeq = ledger_->header().seq; + auto const newSeq = ledger->header().seq; + // Same-seq open → closed is a real view upgrade (mid-close then close). + // Same-seq closed → open / identical open is a no-op without forceClear. + bool const sameSeqUpgrade = oldSeq == newSeq && ledger_->open() && !ledger->open(); + if (oldSeq == newSeq && !forceClear && !sameSeqUpgrade) + return; + + ledger_ = ledger; + ++ledgerAdvances_; + + // MPTs are cheap; always drop on advance. + mpts_.clear(); + + if (forceClear) + { + lines_.clear(); + sessionAccounts_.clear(); + totalLineCount_.store(0, std::memory_order_relaxed); + JLOG(journal_.info()) << "advanceLedger force-cleared cache for ledger " << newSeq; + return; + } + + // Soft retain complete vectors (content reused within cacheReuseLedgers_). + // + // Incomplete progressive fills cannot resume a DirCursor across ledgers + // (owner-dir pages split/merge → dup/skip). Re-walking prevCount+chunk for + // every incomplete hub on every close destroyed 100-session path_find + // throughput. Option A: + // - pinned incomplete: drop line memory, keep pinCount + reloadMinLines + // progress hint; next getRippleLines reloads from page 0 with that want + // - unpinned incomplete: erase entirely (no work on advance) + for (auto it = lines_.begin(); it != lines_.end();) + { + if (it->second.cursor.complete) + { + ++it; + continue; + } + + auto const prevCount = it->second.storedLineCount(); + if (prevCount > 0) + totalLineCount_.fetch_sub(prevCount, std::memory_order_relaxed); + + if (it->second.pinCount == 0) + { + it = lines_.erase(it); + continue; + } + + // Pinned: cheap stub — no owner-dir walk under this lock. + std::size_t hint = std::max(prevCount + lineChunkSize_, lineChunkSize_); + hint = std::min(hint, maxLinesPerAccount_); + // Keep any larger prior hint (should not happen, but be monotonic). + hint = std::max(hint, it->second.reloadMinLines); + + it->second.lines = nullptr; + it->second.pending.clear(); + it->second.cursor = {}; + it->second.loadedSeq = newSeq; + it->second.reloadMinLines = hint; + ++it; + } + + JLOG(journal_.debug()) << "advanceLedger " << oldSeq << " -> " << newSeq + << (sameSeqUpgrade ? " (open->closed)" : "") << " retained " + << lines_.size() << " accounts / " + << totalLineCount_.load(std::memory_order_relaxed) << " lines"; +} + +std::size_t +AssetCache::effectiveChunkSize() const +{ + return tlsChunkOverride != 0 ? tlsChunkOverride : lineChunkSize_; +} + +void +AssetCache::pinAccountUnlocked(int sessionId, AccountID const& accountID) +{ + // First pin of this account by this session increments pinCount. + auto& held = sessionAccounts_[sessionId]; + if (!held.insert(accountID).second) + return; // already pinned by this session + + auto it = lines_.find(accountID); + if (it == lines_.end()) + { + // Should not happen: pin only after load. Roll back session set entry. + held.erase(accountID); + if (held.empty()) + sessionAccounts_.erase(sessionId); + return; + } + ++it->second.pinCount; +} + +std::size_t +AssetCache::remainingBudgetUnlocked() const +{ + auto const total = totalLineCount_.load(std::memory_order_relaxed); + return maxTotalLines_ > total ? maxTotalLines_ - total : 0; +} + +void +AssetCache::coalescePendingUnlocked(LineEntry& entry) +{ + if (entry.pending.empty()) + return; + + auto appendPending = [&](std::vector& dest) { + for (auto& part : entry.pending) + { + if (!part) + continue; + dest.reserve(dest.size() + part->size()); + for (auto& line : *part) + dest.push_back(std::move(line)); + } + entry.pending.clear(); + }; + + if (!entry.lines) + { + entry.lines = std::make_shared>(); + appendPending(*entry.lines); + return; + } + + // Sole owner: absorb pending in place (no full duplicate of published lines). + if (entry.lines.use_count() == 1) + { + appendPending(*entry.lines); + return; + } + + // Still shared with a reader: publish a new vector for future callers. + // Prior readers keep their stable snapshot. Peak cost is paid once on + // first publish after concurrent expand — never on the expand itself. + auto grown = std::make_shared>(); + grown->reserve(entry.storedLineCount()); + for (auto const& line : *entry.lines) + grown->push_back(line); + appendPending(*grown); + entry.lines = std::move(grown); +} + +std::size_t +AssetCache::expandAccountUnlocked(AccountID const& accountID, LineEntry& entry) +{ + if (entry.cursor.complete) + return 0; + + auto const have = entry.storedLineCount(); + if (have >= maxLinesPerAccount_) + { + entry.cursor.complete = true; + return 0; + } + + auto const remaining = remainingBudgetUnlocked(); + if (remaining == 0) + return 0; + + // Expand size follows LoadScope when set; otherwise configured lineChunkSize_ + // (WS slow load). One-shot sets a large LoadScope to finish in one pass. + std::size_t want = effectiveChunkSize(); + want = std::min(want, remaining); + want = std::min(want, maxLinesPerAccount_ - have); + if (want == 0) + return 0; + + auto chunk = PathFindTrustLine::getItemsChunk( + accountID, *ledger_, LineDirection::Outgoing, entry.cursor, want); + + entry.cursor = chunk.cursor; + + if (chunk.lines.empty()) + { + // No matching lines in this span; cursor still advanced / completed. + if (have >= maxLinesPerAccount_) + entry.cursor.complete = true; + // Complete with nothing published yet → empty vector so later lookups + // reuse instead of re-scanning (same as first-load empty complete). + if (entry.cursor.complete && !entry.lines && entry.pending.empty()) + entry.lines = std::make_shared>(); + return 0; + } + + auto const added = chunk.lines.size(); + // Never full-copy the published vector on expand: + // - sole owner → append in place (after absorbing any pending) + // - shared with readers → push a pending chunk only (+chunk memory) + // PathFindTrustLine is constructible but not assignable — only push_back. + if (!entry.lines && entry.pending.empty()) + { + entry.lines = std::make_shared>(std::move(chunk.lines)); + } + else if (entry.lines && entry.lines.use_count() == 1) + { + if (!entry.pending.empty()) + coalescePendingUnlocked(entry); + entry.lines->reserve(entry.lines->size() + added); + for (auto& line : chunk.lines) + entry.lines->push_back(std::move(line)); + } + else + { + entry.pending.push_back( + std::make_shared>(std::move(chunk.lines))); + } + + totalLineCount_.fetch_add(added, std::memory_order_relaxed); + linesLoaded_.fetch_add(added, std::memory_order_relaxed); + lineEpoch_.fetch_add(1, std::memory_order_relaxed); + + if (entry.storedLineCount() >= maxLinesPerAccount_) + entry.cursor.complete = true; + + if (!entry.cursor.complete) + { + JLOG(journal_.debug()) << "expandAccount partial account=" << accountID + << " lines=" << entry.storedLineCount() + << " pending_chunks=" << entry.pending.size() + << " page=" << entry.cursor.page + << " idx=" << entry.cursor.indexInPage; + } + + return added; +} + +std::shared_ptr> +AssetCache::loadOutgoingUnlocked(AccountID const& accountID) +{ + // Caller holds unique lock_. + auto const curSeq = ledger_->header().seq; + auto it = lines_.find(accountID); + + std::size_t preservedPins = 0; + std::size_t progressHint = 0; + if (it != lines_.end()) + { + auto const age = curSeq >= it->second.loadedSeq ? curSeq - it->second.loadedSeq : curSeq; + // Reuse published results within the reuse window — including empty + // complete vectors (accounts with no trust lines). Soft-advance stubs + // keep lines==null and must refill; budget-blocked incomplete misses + // also leave lines null so a later load can admit rows. + if (age <= cacheReuseLedgers_ && it->second.lines) + { + ++cacheHits_; + return it->second.lines; + } + // Stale or progress stub: drop content but preserve pins + progress hint. + preservedPins = it->second.pinCount; + progressHint = it->second.reloadMinLines; + auto const size = it->second.storedLineCount(); + if (size > 0) + totalLineCount_.fetch_sub(size, std::memory_order_relaxed); + lines_.erase(it); + } + + ++cacheMisses_; + + LineEntry entry; + entry.loadedSeq = curSeq; + entry.pinCount = preservedPins; + entry.cursor = {}; + entry.lines = nullptr; + entry.pending.clear(); + entry.reloadMinLines = 0; + + auto const remaining = remainingBudgetUnlocked(); + // First-load want: LoadScope / lineChunkSize, or soft-advance progress hint + // so multi-close progressive fill compounds without re-walking on advance. + std::size_t want = std::max(effectiveChunkSize(), progressHint); + want = std::min(want, maxLinesPerAccount_); + if (remaining < want) + want = remaining; + + if (want > 0) + { + auto chunk = PathFindTrustLine::getItemsChunk( + accountID, *ledger_, LineDirection::Outgoing, entry.cursor, want); + entry.cursor = chunk.cursor; + if (!chunk.lines.empty()) + { + entry.lines = std::make_shared>(std::move(chunk.lines)); + totalLineCount_.fetch_add(entry.lines->size(), std::memory_order_relaxed); + linesLoaded_.fetch_add(entry.lines->size(), std::memory_order_relaxed); + lineEpoch_.fetch_add(1, std::memory_order_relaxed); + if (entry.lines->size() >= maxLinesPerAccount_) + entry.cursor.complete = true; + } + else if (entry.cursor.complete) + { + // Directory fully scanned with zero matching lines. Publish an + // empty vector (not null) so reuse hits and Pathfinder does not + // re-walk the owner directory under unique_lock on every hop. + entry.lines = std::make_shared>(); + } + } + // else: remaining == 0 → empty, incomplete (cursor still at start; lines null) + + if (!entry.cursor.complete) + { + // Budget exhaustion is the surprising case; normal progressive chunks log at debug. + if (remaining == 0 || remainingBudgetUnlocked() == 0) + { + JLOG(journal_.warn()) << "loadOutgoing budget-blocked account=" << accountID + << " lines=" << (entry.lines ? entry.lines->size() : 0) + << " total=" << totalLineCount_.load(std::memory_order_relaxed); + } + else + { + JLOG(journal_.debug()) << "loadOutgoing chunked account=" << accountID + << " lines=" << (entry.lines ? entry.lines->size() : 0) + << " hint=" << progressHint << " complete=false"; + } + } + + auto [ins, ok] = lines_.emplace(accountID, std::move(entry)); + (void)ok; + + JLOG(journal_.trace()) << "loadOutgoingUnlocked ledger " << curSeq << " account " << accountID + << " lines=" << ins->second.storedLineCount() + << " complete=" << ins->second.cursor.complete + << " pins=" << ins->second.pinCount + << " total=" << totalLineCount_.load(std::memory_order_relaxed); + + return ins->second.lines; } std::shared_ptr> -AssetCache::getRippleLines(AccountID const& accountID, LineDirection direction) -{ - auto const hash = hasher_(accountID); - AccountKey key(accountID, direction, hash); - AccountKey otherkey( - accountID, - direction == LineDirection::Outgoing ? LineDirection::Incoming : LineDirection::Outgoing, - hash); - - std::scoped_lock const sl(lock_); - - auto [it, inserted] = [&]() { - if (auto otheriter = lines_.find(otherkey); otheriter != lines_.end()) - { - // The whole point of using the direction flag is to reduce the - // number of trust line objects held in memory. Ensure that there is - // only a single set of trustlines in the cache per account. - auto const size = otheriter->second ? otheriter->second->size() : 0; - JLOG(journal_.info()) - << "Request for " - << (direction == LineDirection::Outgoing ? "outgoing" : "incoming") - << " trust lines for account " << accountID << " found " << size - << (direction == LineDirection::Outgoing ? " incoming" : " outgoing") - << " trust lines. " - << (direction == LineDirection::Outgoing ? "Deleting the subset of incoming" - : "Returning the superset of outgoing") - << " trust lines. "; - if (direction == LineDirection::Outgoing) +AssetCache::getOrLoadOutgoing(AccountID const& accountID) +{ + // LoadScope (one-shot) needs exclusive expand of incomplete hits — cannot + // finish under shared_lock. + bool const oneShotFull = tlsChunkOverride != 0; + + { + std::shared_lock const sl(lock_); + auto const curSeq = ledger_->header().seq; + auto it = lines_.find(accountID); + if (it != lines_.end()) + { + auto const age = + curSeq >= it->second.loadedSeq ? curSeq - it->second.loadedSeq : curSeq; + // Fast path: fresh published results (including empty complete), + // no pending. Soft-advance stubs (lines==null) and one-shot + // incomplete hits fall through. + if (age <= cacheReuseLedgers_ && it->second.lines && it->second.pending.empty() && + (it->second.cursor.complete || !oneShotFull)) { - // This request is for the outgoing set, but there is already a - // subset of incoming lines in the cache. Erase that subset - // to be replaced by the full set. The full set will be built - // below, and will be returned, if needed, on subsequent calls - // for either value of outgoing. - XRPL_ASSERT( - size <= totalLineCount_, "xrpl::AssetCache::getRippleLines : maximum lines"); - totalLineCount_ -= size; - lines_.erase(otheriter); + ++cacheHits_; + return it->second.lines; } - else + } + } + + std::unique_lock const sl(lock_); + auto const curSeq = ledger_->header().seq; + auto it = lines_.find(accountID); + if (it != lines_.end()) + { + auto const age = curSeq >= it->second.loadedSeq ? curSeq - it->second.loadedSeq : curSeq; + // Soft-advance progress stub: no lines yet — materialize via load. + if (age <= cacheReuseLedgers_ && it->second.lines) + { + ++cacheHits_; + // One-shot LoadScope: drain incomplete so destination_currencies / + // Pathfinder see the full per-account set (budget permitting), even + // when the shared cache only held a WS progressive partial. + if (oneShotFull && !it->second.cursor.complete) { - // This request is for the incoming set, but there is - // already a superset of the outgoing trust lines in the cache. - // The path finding engine will disregard the non-rippling trust - // lines, so to prevent them from being stored twice, return the - // outgoing set. - key = otherkey; - return std::pair{otheriter, false}; + while (!it->second.cursor.complete) + { + if (expandAccountUnlocked(accountID, it->second) == 0) + break; + } } + coalescePendingUnlocked(it->second); + return it->second.lines; } - return lines_.emplace(key, nullptr); - }(); + } + return loadOutgoingUnlocked(accountID); +} - if (inserted) +std::shared_ptr> +AssetCache::getRippleLines(AccountID const& accountID) +{ + auto full = getOrLoadOutgoing(accountID); + + // Pin to the active path_find session (if any) so this account is only + // freed when that session ends — not when some other session closes. + // + // Hot path (already pinned): shared_lock membership check only. Pathfinder + // calls getRippleLines per hop under SessionPin; an unconditional unique_lock + // here would serialize the steady-revalidate workers on every hop (up to + // min(kPathSteadyUpdateParallelism, jobQueueWorkers - 1) concurrent units). + // Escalate to unique only on the first pin of this account for the session. + if (tlsPinSessionId != 0) { - XRPL_ASSERT(it->second == nullptr, "xrpl::Asset::getRippleLines : null lines"); - auto lines = PathFindTrustLine::getItems(accountID, *ledger_, direction); - if (!lines.empty()) + bool alreadyPinned = false; + { + std::shared_lock const sl(lock_); + auto const sit = sessionAccounts_.find(tlsPinSessionId); + if (sit != sessionAccounts_.end() && sit->second.count(accountID) != 0) + alreadyPinned = true; + } + if (!alreadyPinned) { - it->second = std::make_shared>(std::move(lines)); - totalLineCount_ += it->second->size(); + std::unique_lock const sl(lock_); + // Entry may have been evicted between load and pin (another + // session's releaseSession dropped pinCount to 0). Reload under + // this unique lock so pin bookkeeping is never silently lost. + if (lines_.find(accountID) == lines_.end()) + full = loadOutgoingUnlocked(accountID); + pinAccountUnlocked(tlsPinSessionId, accountID); } } - XRPL_ASSERT( - !it->second || !it->second->empty(), - "xrpl::AssetCache::getRippleLines : null or nonempty lines"); - auto const size = it->second ? it->second->size() : 0; - JLOG(journal_.trace()) << "getRippleLines for ledger " << ledger_->header().seq << " found " - << size - << (key.direction == LineDirection::Outgoing ? " outgoing" : " incoming") - << " lines for " << (inserted ? "new " : "existing ") << accountID - << " out of a total of " << lines_.size() << " accounts and " - << totalLineCount_ << " trust lines"; + if (!full || full->empty()) + return nullptr; + return full; +} - return it->second; +bool +AssetCache::expandIncompleteLines() +{ + std::unique_lock const sl(lock_); + + // Prefer multi-session hubs (high pinCount) so concurrent path_finds see + // useful line sets first. Unordered map iteration order is not meaningful. + std::vector> work; + work.reserve(lines_.size()); + for (auto const& [accountID, entry] : lines_) + { + if (!entry.cursor.complete) + work.emplace_back(entry.pinCount, accountID); + } + std::sort(work.begin(), work.end(), [](auto const& a, auto const& b) { + if (a.first != b.first) + return a.first > b.first; + return a.second < b.second; + }); + + bool grew = false; + // Bound unique_lock hold: multiple chunks per account, but stop after + // kPathExpandLinesPerWave new rows so a closed wave cannot drain the + // entire max_total_lines budget in one expand. + std::size_t remainingWave = rpc::tuning::kPathExpandLinesPerWave; + for (auto const& [pinCount, accountID] : work) + { + (void)pinCount; + if (remainingBudgetUnlocked() == 0 || remainingWave == 0) + break; + auto it = lines_.find(accountID); + if (it == lines_.end() || it->second.cursor.complete) + continue; + + // Several progressive chunks per account per wave — a 64-line default + // would otherwise leave large hubs under-represented for many closes. + while (!it->second.cursor.complete && remainingBudgetUnlocked() > 0 && remainingWave > 0) + { + auto const added = expandAccountUnlocked(accountID, it->second); + if (added == 0) + break; + grew = true; + remainingWave = added >= remainingWave ? 0 : remainingWave - added; + } + } + return grew; +} + +bool +AssetCache::expandIncompleteLinesForSession(int sessionId) +{ + std::unique_lock const sl(lock_); + auto sit = sessionAccounts_.find(sessionId); + if (sit == sessionAccounts_.end()) + return false; + + bool grew = false; + for (auto const& accountID : sit->second) + { + auto it = lines_.find(accountID); + if (it == lines_.end() || it->second.cursor.complete) + continue; + if (remainingBudgetUnlocked() == 0) + break; + // One expandAccount uses effectiveChunkSize() (LoadScope when set). Loop + // in the caller drains; still allow multi-chunk here when LoadScope is + // large so a single call can finish an account under budget. + while (!it->second.cursor.complete && remainingBudgetUnlocked() > 0) + { + auto const added = expandAccountUnlocked(accountID, it->second); + if (added == 0) + break; + grew = true; + // Without LoadScope, one chunk per outer call keeps WS expands light; + // with LoadScope, keep going until complete (one-shot drain). + if (tlsChunkOverride == 0) + break; + } + } + return grew; +} + +bool +AssetCache::hasIncompleteLines() const +{ + std::shared_lock const sl(lock_); + for (auto const& [_, entry] : lines_) + { + if (!entry.cursor.complete) + return true; + } + return false; } -std::shared_ptr> const& -AssetCache::getMPTs(xrpl::AccountID const& account) +bool +AssetCache::hasIncompleteLinesForSession(int sessionId) const { - std::scoped_lock const sl(lock_); + std::shared_lock const sl(lock_); + auto sit = sessionAccounts_.find(sessionId); + if (sit == sessionAccounts_.end()) + return false; + for (auto const& accountID : sit->second) + { + auto it = lines_.find(accountID); + if (it != lines_.end() && !it->second.cursor.complete) + return true; + } + return false; +} + +std::size_t +AssetCache::releaseSession(int sessionId) +{ + std::unique_lock const sl(lock_); + auto sit = sessionAccounts_.find(sessionId); + if (sit == sessionAccounts_.end()) + return 0; + + std::size_t freed = 0; + for (auto const& accountID : sit->second) + { + auto it = lines_.find(accountID); + if (it == lines_.end()) + continue; + + if (it->second.pinCount > 0) + --it->second.pinCount; + + if (it->second.pinCount == 0) + { + auto const size = it->second.storedLineCount(); + freed += size; + totalLineCount_.fetch_sub(size, std::memory_order_relaxed); + lines_.erase(it); + } + } + sessionAccounts_.erase(sit); + if (freed > 0) + { + JLOG(journal_.debug()) << "releaseSession id=" << sessionId << " freed=" << freed + << " remaining_lines=" + << totalLineCount_.load(std::memory_order_relaxed) + << " remaining_accounts=" << lines_.size(); + } + return freed; +} + +std::shared_ptr> +AssetCache::getMPTs(AccountID const& account) +{ + { + std::shared_lock const sl(lock_); + if (auto it = mpts_.find(account); it != mpts_.end()) + return it->second; + } + + std::unique_lock const sl(lock_); if (auto it = mpts_.find(account); it != mpts_.end()) return it->second; std::vector mpts; - // Get issued/authorized tokens forEachItem(*ledger_, account, [&](SLE::const_ref sle) { if (sle->getType() == ltMPTOKEN_ISSUANCE) { @@ -150,13 +714,12 @@ AssetCache::getMPTs(xrpl::AccountID const& account) if (mpts.empty()) { mpts_.emplace(account, nullptr); - } - else - { - mpts_.emplace(account, std::make_shared>(std::move(mpts))); + return nullptr; } - return mpts_[account]; + auto inserted = std::make_shared>(std::move(mpts)); + mpts_.emplace(account, inserted); + return inserted; } } // namespace xrpl diff --git a/src/xrpld/rpc/detail/AssetCache.h b/src/xrpld/rpc/detail/AssetCache.h index 71a7c262d4c..e8179405a83 100644 --- a/src/xrpld/rpc/detail/AssetCache.h +++ b/src/xrpld/rpc/detail/AssetCache.h @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -9,103 +10,326 @@ #include #include #include +#include +#include #include +#include #include #include +#include #include namespace xrpl { -// Used by Pathfinder +/** + * Shared cache of pathfinding assets (trust lines / MPTs). + * + * Design (tuned for continuous WS path_find under load): + * - One outgoing line vector per account, filled in chunks (kPathFindLineChunkSize) + * with a resumable owner-dir cursor so large accounts load across updates. + * - Callers apply currency / Incoming filters inline (zero per-hop alloc). + * - Vectors are reused across a few ledger advances without fingerprinting. + * - Global line budget bounds worst-case memory (hard stop when remaining == 0; + * no silent floor). Incomplete accounts grow on expandIncompleteLines. + * - Hits use shared_lock; misses/expands load under unique lock (single-flight). + * - Soft ledger advance keeps complete line vectors. Incomplete progressive + * fills never resume a DirCursor across ledgers (pages split/merge). Pinned + * incomplete entries keep a reloadMinLines hint and drop line memory; the + * next getRippleLines reloads from page 0 with that want. Unpinned incomplete + * entries are dropped (no unique-lock re-walk on every close). + * - Per-session account pins: an entry is freed only when every path_find that + * used it has ended. Shared hubs stay warm for remaining sessions (no LRU + * thrash during ramp-down). When the last subscription ends the whole cache + * is dropped by PathRequestManager. + */ class AssetCache final : public CountedObject { public: - explicit AssetCache(std::shared_ptr l, beast::Journal j); + explicit AssetCache( + std::shared_ptr l, + beast::Journal j, + std::size_t maxTotalLines = rpc::tuning::kPathFindMaxTotalLines, + std::size_t maxLinesPerAccount = rpc::tuning::kPathFindMaxLinesPerAccount, + std::uint32_t cacheReuseLedgers = rpc::tuning::kPathCacheReuseLedgers, + std::size_t lineChunkSize = rpc::tuning::kPathFindLineChunkSize); ~AssetCache(); - [[nodiscard]] std::shared_ptr const& - getLedger() const + /** + * RAII: pin account loads to a path_find session id for this thread. + * getRippleLines records pins so releaseSession can drop unreferenced + * accounts when that session ends — without evicting hubs still held by + * other live sessions. + */ + class SessionPin { - return ledger_; - } + public: + SessionPin(int sessionId) noexcept; + ~SessionPin() noexcept; + SessionPin(SessionPin const&) = delete; + SessionPin& + operator=(SessionPin const&) = delete; + + private: + int prev_{0}; + }; + + /** + * RAII: per-thread line load/expand budget (owner-dir chunk size). + * - WebSocket path_find: leave default (kPathFindLineChunkSize) so lines + * fill slowly across updates. + * - One-shot (ripple_path_find, transactionSign build_path): set to + * maxLinesPerAccount so first load / expandIncompleteLines pull as many + * lines as budget allows in a single request (not just 64). + */ + class LoadScope + { + public: + explicit LoadScope(std::size_t chunkLines) noexcept; + ~LoadScope() noexcept; + LoadScope(LoadScope const&) = delete; + LoadScope& + operator=(LoadScope const&) = delete; + + private: + std::size_t prev_{0}; + }; + + /** + * Snapshot of the current ledger view. Returns a shared_ptr by value under + * lock so callers keep a stable ReadView even if advanceLedger runs. + */ + [[nodiscard]] std::shared_ptr + getLedger() const; + + /** + * Point the cache at a newer ledger. + * forceClear drops all entries and session pins; otherwise vectors are + * retained and only reloaded on access once older than cacheReuseLedgers_. + */ + void + advanceLedger(std::shared_ptr const& ledger, bool forceClear = false); /** - * Find the trust lines associated with an account. + * Full outgoing trust-line vector for an account (shared ownership). + * When a SessionPin is active on this thread, the account is pinned to + * that session until releaseSession(sessionId). * - * @param accountID The account - * @param direction Whether the account is an "outgoing" link on the path. - * "Outgoing" is defined as the source account, or an account found via a - * trustline that has rippling enabled on the @accountID's side. If an - * account is "outgoing", all trust lines will be returned. If an account is - * not "outgoing", then any trust lines that don't have rippling enabled are - * not usable, so only return trust lines that have rippling enabled on - * @accountID's side. - * @return Returns a vector of the usable trust lines. + * First miss loads at most the thread LoadScope chunk (default + * lineChunkSize_). Call expandIncompleteLines to append more. */ std::shared_ptr> - getRippleLines(AccountID const& accountID, LineDirection direction); + getRippleLines(AccountID const& accountID); - std::shared_ptr> const& - getMPTs(AccountID const& account); + /** + * Grow incomplete cached accounts under unique_lock, while global budget + * allows. Multi-session hubs (higher pinCount) expand first; each pass + * admits at most kPathExpandLinesPerWave new rows and may take several + * progressive chunks per account (so 64-line defaults do not leave hubs + * under-filled for dozens of closes). Copy-on-write keeps published + * vectors stable for concurrent readers. + * + * @return true if any account's line vector grew. + */ + bool + expandIncompleteLines(); -private: - std::mutex lock_; + /** + * Like expandIncompleteLines, but only accounts pinned by sessionId. + * Used by one-shot ripple_path_find when it shares AssetCache with WS + * sessions so a legacy drain cannot expand every hub on the node. + * + * @return true if any pinned account's line vector grew. + */ + bool + expandIncompleteLinesForSession(int sessionId); - xrpl::HardenedHash<> hasher_; - std::shared_ptr ledger_; + /** + * True if any cached account still has a residual owner-dir cursor + * (partial line set). + */ + [[nodiscard]] bool + hasIncompleteLines() const; - beast::Journal journal_; + /** + * True if any account pinned by sessionId still has an incomplete + * owner-dir cursor. Used for path_find `warning: path_lines_partial` + * so unrelated sessions are not flagged for a shared-cache whale. + */ + [[nodiscard]] bool + hasIncompleteLinesForSession(int sessionId) const; - struct AccountKey final : public CountedObject + /** + * Bumped whenever any account's line vector grows (first load or expand). + * PathRequest compares against a per-session snapshot to decide whether + * progressive fills warrant a Pathfinder pass. + */ + [[nodiscard]] std::uint64_t + lineEpoch() const { - AccountID account; - LineDirection direction; - std::size_t hashValue; + return lineEpoch_.load(std::memory_order_relaxed); + } - AccountKey(AccountID const& account, LineDirection direction, std::size_t hash) - : account(account), direction(direction), hashValue(hash) - { - } + /** + * MPTs for an account. Returns shared_ptr by value (never a map reference). + */ + std::shared_ptr> + getMPTs(AccountID const& account); - AccountKey(AccountKey const& other) = default; + /** + * Drop all account pins held by sessionId. Any line vector whose pin count + * reaches zero is erased (PathFindTrustLine memory reclaimed). Safe and + * idempotent if called more than once for the same session. + * + * @return Number of PathFindTrustLine objects freed. + */ + std::size_t + releaseSession(int sessionId); - AccountKey& - operator=(AccountKey const& other) = default; + [[nodiscard]] std::size_t + totalLineCount() const + { + return totalLineCount_.load(std::memory_order_relaxed); + } - bool - operator==(AccountKey const& lhs) const - { - return hashValue == lhs.hashValue && account == lhs.account && - direction == lhs.direction; - } + [[nodiscard]] bool + overBudget() const + { + return totalLineCount_.load(std::memory_order_relaxed) >= maxTotalLines_; + } - [[nodiscard]] std::size_t - getHash() const - { - return hashValue; - } + [[nodiscard]] std::uint64_t + cacheHits() const + { + return cacheHits_.load(std::memory_order_relaxed); + } + [[nodiscard]] std::uint64_t + cacheMisses() const + { + return cacheMisses_.load(std::memory_order_relaxed); + } + [[nodiscard]] std::uint64_t + linesLoaded() const + { + return linesLoaded_.load(std::memory_order_relaxed); + } + /** + * Number of times advanceLedger advanced the cache view (soft or force). + * Not only full rebuilds — name reflects ledger advances. + */ + [[nodiscard]] std::uint64_t + ledgerAdvances() const + { + return ledgerAdvances_.load(std::memory_order_relaxed); + } - struct Hash - { - Hash() = default; +private: + struct LineEntry + { + /** + * Published line vector (may be held by concurrent Pathfinder readers). + * Non-null empty vector = complete scan found no lines (cacheable miss). + * Null = not yet published: soft-advance stub or budget-blocked load. + */ + std::shared_ptr> lines; + /** + * Chunks appended while `lines` was shared (use_count > 1). Expand never + * copies the published vector; readers see pending only after coalesce. + */ + std::vector>> pending; + std::uint32_t loadedSeq{0}; + /** + * Number of path_find sessions currently pinning this account. + */ + std::size_t pinCount{0}; + /** + * Resume point for progressive owner-dir fill. complete == true means + * the directory was fully scanned (or hit per-account cap). + */ + PathFindTrustLine::DirCursor cursor{}; + + /** + * After soft advance of an incomplete pin: next loadOutgoing wants at + * least this many lines from page 0 (progress hint). 0 = normal chunk. + * Never used to resume a cross-ledger DirCursor. + */ + std::size_t reloadMinLines{0}; - std::size_t - operator()(AccountKey const& key) const noexcept + [[nodiscard]] std::size_t + storedLineCount() const + { + std::size_t n = lines ? lines->size() : 0; + for (auto const& p : pending) { - return key.getHash(); + if (p) + n += p->size(); } - }; + return n; + } }; - // Use a shared_ptr so entries can be removed from the map safely. - // Even though a shared_ptr to a vector will take more memory just a vector, - // most accounts are not going to have any entries (estimated over 90%), so - // vectors will not need to be created for them. This should lead to far - // less memory usage overall. - hash_map>, AccountKey::Hash> lines_; - std::size_t totalLineCount_ = 0; + std::shared_ptr> + getOrLoadOutgoing(AccountID const& accountID); + + /** + * Caller must hold lock_ exclusively. + */ + std::shared_ptr> + loadOutgoingUnlocked(AccountID const& accountID); + + /** + * Caller must hold lock_ exclusively. Append at most one chunk. + * @return number of lines added. + */ + std::size_t + expandAccountUnlocked(AccountID const& accountID, LineEntry& entry); + + /** + * Fold pending chunks into lines for publication. Prefer in-place absorb + * when sole owner; only then may allocate a full replacement vector. + * Caller must hold lock_ exclusively. + */ + void + coalescePendingUnlocked(LineEntry& entry); + + /** + * Caller must hold lock_ exclusively. First pin per session increments. + */ + void + pinAccountUnlocked(int sessionId, AccountID const& accountID); + + [[nodiscard]] std::size_t + remainingBudgetUnlocked() const; + + /** + * LoadScope override if set, otherwise configured lineChunkSize_. + */ + [[nodiscard]] std::size_t + effectiveChunkSize() const; + + mutable std::shared_mutex lock_; + + std::shared_ptr ledger_; + beast::Journal journal_; + std::size_t maxTotalLines_; + std::size_t maxLinesPerAccount_; + std::uint32_t cacheReuseLedgers_; + std::size_t lineChunkSize_; + + hash_map lines_; + std::atomic totalLineCount_{0}; hash_map>> mpts_; + + /** + * sessionId → accounts that session has pinned (for O(session) release). + */ + hash_map> sessionAccounts_; + + std::atomic cacheHits_{0}; + std::atomic cacheMisses_{0}; + std::atomic linesLoaded_{0}; + std::atomic ledgerAdvances_{0}; + std::atomic lineEpoch_{0}; }; } // namespace xrpl diff --git a/src/xrpld/rpc/detail/PathRequest.cpp b/src/xrpld/rpc/detail/PathRequest.cpp index fb132199bc5..d7b072d5b41 100644 --- a/src/xrpld/rpc/detail/PathRequest.cpp +++ b/src/xrpld/rpc/detail/PathRequest.cpp @@ -45,6 +45,7 @@ #include #include #include +#include namespace xrpl { @@ -56,7 +57,7 @@ PathRequest::PathRequest( beast::Journal journal) : app_(app) , journal_(journal) - , owner_(owner) + , owner_(&owner) , wpSubscriber_(subscriber) , consumer_(subscriber->getConsumer()) , jvStatus_(json::ValueType::Object) @@ -64,6 +65,7 @@ PathRequest::PathRequest( , inProgress_(false) , iLevel_(0) , bLastSuccess_(false) + , lastFullSearchIndex_(0) , iIdentifier_(id) , created_(std::chrono::steady_clock::now()) { @@ -79,7 +81,7 @@ PathRequest::PathRequest( beast::Journal journal) : app_(app) , journal_(journal) - , owner_(owner) + , owner_(&owner) , fCompletion_(std::move(completion)) , consumer_(consumer) , jvStatus_(json::ValueType::Object) @@ -87,14 +89,27 @@ PathRequest::PathRequest( , inProgress_(false) , iLevel_(0) , bLastSuccess_(false) + , lastFullSearchIndex_(0) , iIdentifier_(id) , created_(std::chrono::steady_clock::now()) { JLOG(journal_.debug()) << iIdentifier_ << " created"; } +void +PathRequest::detachFromManager() noexcept +{ + owner_.store(nullptr, std::memory_order_release); +} + PathRequest::~PathRequest() { + // WS disconnect or last strong-ref drop: unhook from the manager so the + // shared AssetCache can be released when no sessions remain. owner_ is + // null if ~PathRequestManager already detached this session. + if (auto* owner = owner_.exchange(nullptr, std::memory_order_acq_rel)) + owner->removePathRequest(this); + using namespace std::chrono; auto stream = journal_.info(); if (!stream) @@ -119,12 +134,10 @@ PathRequest::~PathRequest() } bool -PathRequest::isNew() +PathRequest::isNew() const { std::scoped_lock const sl(indexLock_); - - // does this path request still need its first full path - return lastIndex_ == 0; + return !firstUpdateDone_; } bool @@ -138,13 +151,15 @@ PathRequest::needsUpdate(bool newOnly, LedgerIndex index) return false; } - if (newOnly && (lastIndex_ != 0)) + if (newOnly && firstUpdateDone_) { - // Only handling new requests, this isn't new + // Only handling brand-new sessions return false; } - if (lastIndex_ >= index) + // Already finished a pinned update for this ledger (or newer). Open first + // updates leave lastIndex_ at 0 so the same-seq closed wave still runs. + if (lastIndex_ != 0 && lastIndex_ >= index) { return false; } @@ -160,13 +175,30 @@ PathRequest::hasCompletion() } void -PathRequest::updateComplete() +PathRequest::updateComplete(std::optional pinSeq, bool completedWork) { std::scoped_lock const sl(indexLock_); - XRPL_ASSERT(inProgress_, "xrpl::PathRequest::updateComplete : in progress"); + // Idempotent: updateAll may release remaining batch claims after a throw + // while ClaimGuard already cleared the request that failed. + if (!inProgress_) + return; + inProgress_ = false; + if (!completedWork) + { + // Abandoned claim / drop: clear inProgress only. + return; + } + + firstUpdateDone_ = true; + if (pinSeq) + { + // Closed (or explicit pin): skip reprocess of this seq until next. + lastIndex_ = *pinSeq; + } + if (fCompletion_) { fCompletion_(); @@ -187,7 +219,8 @@ PathRequest::isValid(std::shared_ptr const& crCache) return false; } - auto const& lrLedger = crCache->getLedger(); + // By-value snapshot: keeps ReadView alive if the shared cache advances. + auto const lrLedger = crCache->getLedger(); if (!lrLedger->exists(keylet::account(*raSrcAccount_))) { @@ -251,8 +284,30 @@ PathRequest::doCreate(std::shared_ptr const& cache, json::Value cons if (parseJson(value) != PFR_PJ_INVALID) { valid = isValid(cache); + // WS subscription: run a fast Pathfinder for the create reply. Claim + // inProgress_ so concurrent updateAll / mid-close cannot race context_ + // (same PathRequest). Do not pin lastIndex_ — isNew() stays true until + // the first completed updateAll wave. if (!hasCompletion() && valid) - doUpdate(cache, true); + { + { + std::scoped_lock const sl(indexLock_); + // Create is single-threaded per request; should never be in flight. + XRPL_ASSERT(!inProgress_, "xrpl::PathRequest::doCreate : not in progress"); + inProgress_ = true; + } + try + { + doUpdate(cache, true); + } + catch (...) + { + updateComplete(); + throw; + } + // Clear claim without pinning so the first updateAll still runs. + updateComplete(); + } } if (auto stream = journal_.debug()) @@ -478,6 +533,10 @@ json::Value PathRequest::doClose() { JLOG(journal_.debug()) << iIdentifier_ << " closed"; + // Detach immediately so AssetCache can reclaim if this was the last session + // (do not wait for ~PathRequest / next updateAll scavenge). + if (auto* owner = owner_.load(std::memory_order_acquire)) + owner->removePathRequest(this); std::scoped_lock const sl(lock_); jvStatus_[jss::closed] = true; return jvStatus_; @@ -532,13 +591,91 @@ PathRequest::getPathFinder( return currencyMap[currency] = std::move(pathfinder); } +bool +PathRequest::revalidatePaths( + std::shared_ptr const& cache, + Asset const& asset, + STPathSet const& paths, + STAmount const& dstAmount, + json::Value& jvArray, + std::shared_ptr const& calcLedger) +{ + if (paths.empty()) + return false; + + auto const& sourceAccount = [&] { + if (!isXRP(asset.getIssuer())) + return asset.getIssuer(); + if (isXRP(asset)) + return xrpAccount(); + return *raSrcAccount_; + }(); + + STAmount const saMaxAmount = [&]() { + if (saSendMax_) + return *saSendMax_; + return asset.visit( + [&](Issue const& issue) { + return STAmount(Issue{issue.currency, sourceAccount}, 1u, 0, true); + }, + [](MPTIssue const& issue) { return STAmount(issue, 1u, 0, true); }); + }(); + + path::RippleCalc::Input rcInput; + if (convertAll_) + rcInput.partialPaymentAllowed = true; + + // Mid-close may pass the open ledger for fresh offers/balances while line + // vectors still come from the shared AssetCache. + auto const ledger = calcLedger ? calcLedger : cache->getLedger(); + PaymentSandbox sandbox(&*ledger, TapNone); + auto rc = path::RippleCalc::rippleCalculate( + sandbox, + saMaxAmount, + dstAmount, + *raDstAccount_, + *raSrcAccount_, + paths, + domain_, + app_, + &rcInput); + + if (rc.result() != tesSUCCESS) + { + JLOG(journal_.debug()) << iIdentifier_ << " revalidate failed: " << transHuman(rc.result()); + return false; + } + + json::Value jvEntry(json::ValueType::Object); + if (rc.actualAmountIn.holds()) + rc.actualAmountIn.get().account = sourceAccount; + jvEntry[jss::source_amount] = rc.actualAmountIn.getJson(JsonOptions::Values::None); + jvEntry[jss::paths_computed] = paths.getJson(JsonOptions::Values::None); + + if (convertAll_) + { + jvEntry[jss::destination_amount] = rc.actualAmountOut.getJson(JsonOptions::Values::None); + } + + if (hasCompletion()) + jvEntry[jss::paths_canonical] = json::ValueType::Array; + + jvArray.append(std::move(jvEntry)); + return true; +} + bool PathRequest::findPaths( std::shared_ptr const& cache, int const level, json::Value& jvArray, - std::function const& continueCallback) + std::function const& continueCallback, + bool fullSearch, + bool allowEscalate, + bool& didFullSearch, + std::shared_ptr const& calcLedger) { + didFullSearch = false; auto sourceAssets = sciSourceAssets_; if (sourceAssets.empty() && saSendMax_) { @@ -546,39 +683,141 @@ PathRequest::findPaths( } if (sourceAssets.empty()) { + // Absolute hard cap (legacy ripple_path_find): exceeding is an error. + // Soft cap is for WS subscriptions only so concurrent Pathfinder waves + // stay bounded. One-shot ripple_path_find never silent-truncates under + // load — clients expect a complete auto source set (or a hard error). + // Soft truncation is reported via path_source_currencies_truncated so + // clients know the alternative set is incomplete, not empty-of-routes. + std::size_t const hardMax = static_cast(rpc::tuning::kMaxAutoSrcCur); + std::size_t softMax = hardMax; + if (!hasCompletion()) + { + softMax = static_cast(rpc::tuning::kMaxAutoSrcCurSub); + if (app_.getFeeTrack().isLoadedLocal()) + { + softMax = + std::min(softMax, static_cast(rpc::tuning::kMaxAutoSrcCurLoaded)); + } + } + + // accountSourceAssets returns a hash_set (unspecified order). Build a + // deterministic list so soft truncation is stable across runs. + // + // XRP MUST sort first: pure to_string order places "XRP" near the end + // of 3-letter codes, so the WS soft cap (16 / 12 under load) would + // discard XRP for multi-currency accounts — usually the cheapest route. + // Remaining assets: currency/MPT codes sorted by string for stability. // NOLINTBEGIN(bugprone-unchecked-optional-access) isValid() ensures both are set auto assets = accountSourceAssets(*raSrcAccount_, cache, true); bool const sameAccount = *raSrcAccount_ == *raDstAccount_; // NOLINTEND(bugprone-unchecked-optional-access) + std::vector ordered; + ordered.reserve(assets.size()); for (auto const& asset : assets) + ordered.push_back(asset); + std::sort(ordered.begin(), ordered.end(), [](PathAsset const& a, PathAsset const& b) { + bool const aXrp = a.isXRP(); + bool const bXrp = b.isXRP(); + if (aXrp != bXrp) + return aXrp; // XRP before every IOU/MPT + return to_string(a) < to_string(b); + }); + + bool softTruncated = false; + for (auto const& asset : ordered) { - if (!std::visit( - [&](TAsset const& a) { - if (!sameAccount || a != saDstAmount_.asset()) + bool overHard = false; + bool atSoft = false; + std::visit( + [&](TAsset const& a) { + if (!sameAccount || a != saDstAmount_.asset()) + { + if (sourceAssets.size() >= hardMax) { - if (sourceAssets.size() >= rpc::tuning::kMaxAutoSrcCur) - return false; - if constexpr (std::is_same_v) - { - sourceAssets.insert( - Issue{a, a.isZero() ? xrpAccount() : *raSrcAccount_}); - } - else - { - sourceAssets.insert(MPTIssue{a}); - } + overHard = true; + return; } - return true; - }, - asset.value())) - { + if (sourceAssets.size() >= softMax) + { + atSoft = true; + return; + } + if constexpr (std::is_same_v) + { + sourceAssets.insert( + Issue{a, a.isZero() ? xrpAccount() : *raSrcAccount_}); + } + else + { + sourceAssets.insert(MPTIssue{a}); + } + } + }, + asset.value()); + if (overHard) return false; + if (atSoft) + { + softTruncated = true; + break; } } + // Stash for doUpdate warning (findPaths has no status object here). + sourceCurrenciesTruncated_ = softTruncated; + } + else + { + sourceCurrenciesTruncated_ = false; } auto const dstAmount = convertAmount(saDstAmount_, convertAll_); hash_map> currencyMap; + + // Cheap path: revalidate previously discovered paths without Pathfinder. + // Prefer this whenever a full graph search is not required (first/fast/ + // failed last / staggered rediscovery). + if (!fullSearch) + { + bool anyOk = false; + for (auto const& asset : sourceAssets) + { + if (continueCallback && !continueCallback()) + break; + auto it = context_.find(asset); + if (it == context_.end() || it->second.empty()) + continue; + if (revalidatePaths(cache, asset, it->second, dstAmount, jvArray, calcLedger)) + anyOk = true; + } + + if (anyOk) + { + int const size = static_cast(sourceAssets.size()); + consumer_.charge({std::clamp((size * size) / 2 + 20, 25, 200), "path revalidate"}); + JLOG(journal_.debug()) << iIdentifier_ << " incremental revalidate ok (" + << jvArray.size() << " alternatives)"; + return true; + } + + // No prior paths worked. Escalating on every failed revalidate was the + // main wave-cost blowup under load. Mid-close ticks pass + // allowEscalate=false; closed waves escalate only when fullSearch was + // already selected (rediscovery / failed backoff). + if (!allowEscalate) + { + JLOG(journal_.debug()) + << iIdentifier_ << " incremental revalidate empty/failed; no escalate"; + return true; + } + + JLOG(journal_.debug()) << iIdentifier_ + << " incremental revalidate empty/failed; full search"; + fullSearch = true; + } + + didFullSearch = true; + for (auto const& asset : sourceAssets) { if (continueCallback && !continueCallback()) @@ -594,9 +833,8 @@ PathRequest::findPaths( continue; } - STPath fullLiquidityPath; auto ps = pathfinder->getBestPaths( - kMaxPaths, fullLiquidityPath, context_[asset], asset.getIssuer(), continueCallback); + kMaxPaths, context_[asset], asset.getIssuer(), continueCallback); context_[asset] = ps; auto const& sourceAccount = [&] { @@ -624,7 +862,8 @@ PathRequest::findPaths( path::RippleCalc::Input rcInput; if (convertAll_) rcInput.partialPaymentAllowed = true; - auto sandbox = std::make_unique(&*cache->getLedger(), TapNone); + auto const ledger = calcLedger ? calcLedger : cache->getLedger(); + auto sandbox = std::make_unique(&*ledger, TapNone); auto rc = path::RippleCalc::rippleCalculate( *sandbox, saMaxAmount, // --> Amount to send is unlimited @@ -639,37 +878,8 @@ PathRequest::findPaths( app_, &rcInput); - if (!convertAll_ && !fullLiquidityPath.empty() && - (rc.result() == terNO_LINE || rc.result() == tecPATH_PARTIAL)) - { - JLOG(journal_.debug()) << iIdentifier_ << " Trying with an extra path element"; - - ps.pushBack(fullLiquidityPath); - sandbox = std::make_unique(&*cache->getLedger(), TapNone); - rc = path::RippleCalc::rippleCalculate( - *sandbox, - saMaxAmount, // --> Amount to send is unlimited - // to get an estimate. - dstAmount, // --> Amount to deliver. - // NOLINTBEGIN(bugprone-unchecked-optional-access) isValid() ensures both are set - *raDstAccount_, // --> Account to deliver to. - *raSrcAccount_, // --> Account sending from. - // NOLINTEND(bugprone-unchecked-optional-access) - ps, // --> Path set. - domain_, // --> Domain. - app_); - - if (!isTesSuccess(rc.result())) - { - JLOG(journal_.warn()) - << iIdentifier_ << " Failed with covering path " << transHuman(rc.result()); - } - else - { - JLOG(journal_.debug()) - << iIdentifier_ << " Extra path element gives " << transHuman(rc.result()); - } - } + // No covering/full-liquidity spare path: alternatives are exactly the + // best maxPaths set from getBestPaths. if (rc.result() == tesSUCCESS) { @@ -713,10 +923,25 @@ json::Value PathRequest::doUpdate( std::shared_ptr const& cache, bool fast, - std::function const& continueCallback) + std::function const& continueCallback, + bool revalidateOnly, + std::shared_ptr const& calcLedger) { using namespace std::chrono; - JLOG(journal_.debug()) << iIdentifier_ << " update " << (fast ? "fast" : "normal"); + JLOG(journal_.debug()) << iIdentifier_ << " update " << (fast ? "fast" : "normal") + << (revalidateOnly ? " revalidate_only" : ""); + + // Pin every account loaded via getRippleLines on this thread to this + // session so shared hubs stay cached until *this* path_find ends. + AssetCache::SessionPin const sessionPin{iIdentifier_}; + + // One-shot ripple_path_find: load/expand up to the per-account cap so the + // single reply sees the full line set (budget permitting). + // WebSocket path_find: default LoadScope (64-line chunks) and progressive + // expand across later closed-ledger updates. + std::optional lineLoadScope; + if (hasCompletion()) + lineLoadScope.emplace(app_.config().pathFindMaxLinesPerAccount); { std::scoped_lock const sl(lock_); @@ -729,7 +954,16 @@ PathRequest::doUpdate( if (hasCompletion()) { - // Old ripple_path_find API gives destination_currencies + // Old ripple_path_find API lists destination_currencies. Build it only + // after the destination account is pinned and any incomplete shared + // progressive fill is drained under LoadScope — otherwise a WS partial + // (64-line) cache hit would silently omit currencies the dest can receive. + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) isValid() ensures both are set + (void)cache->getRippleLines(*raDstAccount_); + while (cache->expandIncompleteLinesForSession(iIdentifier_)) + { + } + auto& destAssets = (newStatus[jss::destination_currencies] = json::ValueType::Array); // NOLINTNEXTLINE(bugprone-unchecked-optional-access) isValid() ensures both are set auto const assets = accountDestAssets(*raDstAccount_, cache, true); @@ -748,6 +982,7 @@ PathRequest::doUpdate( newStatus[jss::id] = jvId_; bool const loaded = app_.getFeeTrack().isLoadedLocal(); + bool const isSubscription = !hasCompletion(); if (iLevel_ == 0) { @@ -777,36 +1012,182 @@ PathRequest::doUpdate( } else { - // adjust as needed - if (!loaded && (iLevel_ < app_.config().pathSearchMax)) + // Failed last attempt: deepen search for one-shot/legacy requests. + // WS subscriptions freeze depth so concurrent rediscovery does not + // ratchet every session toward pathSearchMax. + if (!isSubscription && !loaded && (iLevel_ < app_.config().pathSearchMax)) ++iLevel_; if (loaded && (iLevel_ > app_.config().pathSearchFast)) --iLevel_; } - JLOG(journal_.debug()) << iIdentifier_ << " processing at level " << iLevel_; + // Subscriptions: hard-cap search depth so staggered rediscovery stays cheap. + if (isSubscription && !fast) + { + int const cap = loaded ? app_.config().pathSearchFast : app_.config().pathSearch; + if (iLevel_ > cap) + iLevel_ = cap; + } + + // Prefer calcLedger seq for rediscovery timing when mid-close passes open. + auto const ledgerForSeq = calcLedger ? calcLedger : cache->getLedger(); + auto const ledgerSeq = ledgerForSeq->seq(); + + // Full Pathfinder when: first/fast update, failed-search backoff elapsed, or + // staggered rediscovery is due. Timed rediscovery is skipped while the + // server is locally loaded (revalidate-only until load eases). + // + // revalidateOnly (mid-close / periodic only): never Pathfinder — keeps the + // 500ms tick cheap. Closed-ledger waves pass revalidateOnly=false so + // rediscovery and failed recovery still run (staggered / backoff). + // + // lastFullSearchIndex_ is only stamped for *non-fast* Pathfinder runs so a + // fast doCreate cannot block the first non-fast updateAll Pathfinder. + // + // Stagger: dueAt = lastFull + interval + (id % interval). + // Config clamps pathFullSearchInterval to 1–100; still guard % 0. + auto const interval = std::max(1, app_.config().pathFullSearchInterval); + bool rediscoveryDue = false; + if (!revalidateOnly && !fast && lastFullSearchIndex_ != 0 && bLastSuccess_ && !loaded) + { + auto const stagger = static_cast(iIdentifier_ % interval); + auto const dueAt = lastFullSearchIndex_ + interval + stagger; + rediscoveryDue = ledgerSeq >= dueAt; + } + + bool failedSearchDue = false; + if (!revalidateOnly && !fast && !bLastSuccess_) + { + if (lastFullSearchIndex_ == 0) + failedSearchDue = true; + else + failedSearchDue = + ledgerSeq >= lastFullSearchIndex_ + rpc::tuning::kPathFailedSearchInterval; + } + + // One-shot ripple_path_find: drain incomplete fills for *this session's* + // pinned accounts only. Must not call expandIncompleteLines() on a shared + // AssetCache — that walks every incomplete hub any WS session cached and + // can load up to max_total_lines under unique_lock in one RPC. + // WS subscriptions: PathRequestManager expands once per closed wave. + if (!revalidateOnly && hasCompletion()) + { + while (cache->expandIncompleteLinesForSession(iIdentifier_)) + { + } + } + + // WS only: progressive fills bump lineEpoch(); escalate Pathfinder when this + // session has not searched against the latest epoch. Stagger so concurrent + // subscriptions do not all full-search every close while a whale chunks in. + // One-shot already filled above and always full-searches via lastFull==0. + auto const lineEpoch = cache->lineEpoch(); + bool const linesNewer = isSubscription && (lineEpoch != lastLineEpoch_); + bool growthSearch = false; + if (linesNewer && !revalidateOnly && !loaded) + { + auto const growInterval = std::max(1, interval / 4); + auto const stagger = static_cast(iIdentifier_ % growInterval); + growthSearch = + lastFullSearchIndex_ == 0 || ledgerSeq >= lastFullSearchIndex_ + growInterval + stagger; + } + + bool const fullSearch = !revalidateOnly && + (fast || lastFullSearchIndex_ == 0 || failedSearchDue || rediscoveryDue || growthSearch); + + // Subscriptions never escalate a failed revalidate into Pathfinder unless + // this wave already chose fullSearch (first / rediscovery / failed backoff / + // progressive line growth). One-shot ripple_path_find may still escalate. + bool const allowEscalate = !revalidateOnly && (!isSubscription || fullSearch); + + JLOG(journal_.debug()) << iIdentifier_ << " processing at level " << iLevel_ + << (fullSearch ? (growthSearch ? " full_search(lines_grew)" + : rediscoveryDue ? " full_search(rediscovery)" + : failedSearchDue ? " full_search(failed_backoff)" + : " full_search") + : " revalidate"); json::Value jvArray = json::ValueType::Array; - if (findPaths(cache, iLevel_, jvArray, continueCallback)) + bool didFullSearch = false; + if (findPaths( + cache, + iLevel_, + jvArray, + continueCallback, + fullSearch, + allowEscalate, + didFullSearch, + calcLedger)) { - bLastSuccess_ = jvArray.size() != 0; + // Non-escalating revalidate produced nothing (mid-close revalidateOnly, + // or closed subscription revalidate without fullSearch): restore last + // alternatives for display so the client is not blanked, with an + // explicit warning. bLastSuccess_ is forced false so the next closed + // wave can Pathfinder via failedSearchDue (not a silent success). + bool restoredStale = false; + if (jvArray.size() == 0 && isSubscription && !didFullSearch) + { + std::scoped_lock const sl(lock_); + if (jvStatus_.isMember(jss::alternatives) && jvStatus_[jss::alternatives].size() > 0) + { + jvArray = jvStatus_[jss::alternatives]; + restoredStale = true; + } + } + + if (restoredStale) + { + bLastSuccess_ = false; + newStatus[jss::full_reply] = false; + newStatus[jss::warning] = "path_revalidate_failed"; + } + else + { + bLastSuccess_ = jvArray.size() != 0; + } + + // Stamp only non-fast Pathfinder runs. Fast create must leave + // lastFullSearchIndex_ at 0 so the first updateAll still full-searches. + if (didFullSearch && !fast) + lastFullSearchIndex_ = ledgerSeq; + // Capture post-search epoch (Pathfinder may have loaded new accounts). + if (didFullSearch) + lastLineEpoch_ = cache->lineEpoch(); newStatus[jss::alternatives] = std::move(jvArray); } else { bLastSuccess_ = false; + if (didFullSearch && !fast) + lastFullSearchIndex_ = ledgerSeq; + if (didFullSearch) + lastLineEpoch_ = cache->lineEpoch(); newStatus = rpcError(RpcInternal); } + // Incomplete owner-dir fill for accounts this session pinned (not cache-global). + // Do not overwrite path_revalidate_failed (stale restore takes precedence). + // Soft auto-source truncation is also a warning (not an error): results are + // still valid for the included currencies, just not exhaustive. + if (!newStatus.isMember(jss::error) && !newStatus.isMember(jss::warning)) + { + if (sourceCurrenciesTruncated_) + newStatus[jss::warning] = "path_source_currencies_truncated"; + else if (cache->hasIncompleteLinesForSession(iIdentifier_)) + newStatus[jss::warning] = "path_lines_partial"; + } + if (fast && quickReply_ == steady_clock::time_point{}) { quickReply_ = steady_clock::now(); - owner_.reportFast(duration_cast(quickReply_ - created_)); + if (auto* owner = owner_.load(std::memory_order_acquire)) + owner->reportFast(duration_cast(quickReply_ - created_)); } else if (!fast && fullReply_ == steady_clock::time_point{}) { fullReply_ = steady_clock::now(); - owner_.reportFull(duration_cast(fullReply_ - created_)); + if (auto* owner = owner_.load(std::memory_order_acquire)) + owner->reportFull(duration_cast(fullReply_ - created_)); } { diff --git a/src/xrpld/rpc/detail/PathRequest.h b/src/xrpld/rpc/detail/PathRequest.h index f56b3d0652e..4e2aa8a031c 100644 --- a/src/xrpld/rpc/detail/PathRequest.h +++ b/src/xrpld/rpc/detail/PathRequest.h @@ -3,12 +3,14 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -18,6 +20,7 @@ #include #include +#include #include #include #include @@ -72,13 +75,20 @@ class PathRequest final : public InfoSubRequest, ~PathRequest() override; bool - isNew(); + isNew() const; bool needsUpdate(bool newOnly, LedgerIndex index); - // Called when the PathRequest update is complete. + /** + * Finish a claimed update slot. + * @param pinSeq If set, record lastIndex_ so same-seq reprocess is skipped + * (typically closed ledgers only). + * @param completedWork If false, only clear inProgress_ (abandoned claim / + * drop without finishing). If true, clear isNew() even when pinSeq + * is nullopt (open first-update: allow same-seq closed wave). + */ void - updateComplete(); + updateComplete(std::optional pinSeq = std::nullopt, bool completedWork = false); std::pair doCreate(std::shared_ptr const&, json::Value const&); @@ -91,16 +101,42 @@ class PathRequest final : public InfoSubRequest, doAborting() const; // update jvStatus + /** + * @param revalidateOnly When true (mid-close / periodic refresh only), only + * re-run rippleCalculate on known paths. Never starts Pathfinder and + * never escalates a failed revalidate into a full graph search. + * Closed-ledger waves pass false so rediscovery / failed recovery work. + * @param calcLedger Optional ledger for PaymentSandbox (open mid-close). + * When null, uses cache->getLedger(). Line vectors still come from cache. + */ json::Value doUpdate( std::shared_ptr const&, bool fast, - std::function const& continueCallback = {}); + std::function const& continueCallback = {}, + bool revalidateOnly = false, + std::shared_ptr const& calcLedger = {}); InfoSub::pointer getSubscriber() const; bool hasCompletion(); + /** + * Called from ~PathRequestManager before the manager is destroyed so + * subsequent ~PathRequest does not call into a freed owner. + */ + void + detachFromManager() noexcept; + + /** + * Unique id for AssetCache session pins / release. + */ + [[nodiscard]] int + id() const + { + return iIdentifier_; + } + private: bool isValid(std::shared_ptr const& crCache); @@ -117,13 +153,37 @@ class PathRequest final : public InfoSubRequest, /** * Finds and sets a PathSet in the JSON argument. * Returns false if the source currencies are invalid. + * + * @param fullSearch If false and context_ has prior paths, only re-run + * rippleCalculate on those paths (skip Pathfinder graph search). + * @param allowEscalate If false, a failed revalidate does NOT fall through + * to Pathfinder (used for mid-close ticks). + * @param didFullSearch Set true if Pathfinder ran (vs pure revalidate). + * @param calcLedger Ledger for PaymentSandbox; null → cache->getLedger(). */ bool findPaths( std::shared_ptr const&, int const, json::Value&, - std::function const&); + std::function const&, + bool fullSearch, + bool allowEscalate, + bool& didFullSearch, + std::shared_ptr const& calcLedger = {}); + + /** + * Re-estimate liquidity for an existing path set on calcLedger (or cache). + * Returns true if rippleCalculate succeeded (tesSUCCESS). + */ + bool + revalidatePaths( + std::shared_ptr const& cache, + Asset const& asset, + STPathSet const& paths, + STAmount const& dstAmount, + json::Value& jvArray, + std::shared_ptr const& calcLedger = {}); int parseJson(json::Value const&); @@ -133,7 +193,11 @@ class PathRequest final : public InfoSubRequest, std::recursive_mutex lock_; - PathRequestManager& owner_; + // Nullable so ~PathRequestManager can detach live sessions before destroy + // (WS InfoSub may outlive the manager briefly during Application teardown). + // Atomic: detachFromManager races with ~PathRequest / reportFast / doClose + // (manager dtor or force-drop vs WS teardown on another thread). + std::atomic owner_; std::weak_ptr wpSubscriber_; // Who this request came from std::function fCompletion_; @@ -155,20 +219,50 @@ class PathRequest final : public InfoSubRequest, bool convertAll_{}; - std::recursive_mutex indexLock_; + /** + * Set when WS auto source-currency soft cap truncates the account set. + */ + bool sourceCurrenciesTruncated_{false}; + + mutable std::recursive_mutex indexLock_; + /** + * After a completed update that pins a ledger seq, needsUpdate skips the + * same (or older) ledger. 0 until the first closed-ledger pin (open first + * updates set firstUpdateDone_ without pinning so same-seq closed still runs). + */ LedgerIndex lastIndex_; + /** + * True after any finished doUpdate (open or closed). isNew() is the inverse. + * Distinct from lastIndex_ so open first-update can leave isNew false without + * suppressing the subsequent closed wave at the same sequence. + */ + bool firstUpdateDone_{false}; bool inProgress_; int iLevel_; bool bLastSuccess_; + /** + * Ledger index of the last *non-fast* Pathfinder search (0 = never completed + * a non-fast full search). Fast doCreate must not stamp this, or the first + * updateAll never runs Pathfinder at pathSearch depth. + */ + LedgerIndex lastFullSearchIndex_; + + /** + * AssetCache::lineEpoch() observed at the last Pathfinder-driven update. + * When the shared cache loads more trust-line chunks, this lags and the + * next non-revalidate update escalates to Pathfinder. + */ + std::uint64_t lastLineEpoch_{0}; + int const iIdentifier_; std::chrono::steady_clock::time_point const created_; std::chrono::steady_clock::time_point quickReply_; std::chrono::steady_clock::time_point fullReply_; - static unsigned int const kMaxPaths = 4; + static unsigned int const kMaxPaths = rpc::tuning::kPathFindMaxPaths; }; } // namespace xrpl diff --git a/src/xrpld/rpc/detail/PathRequestManager.cpp b/src/xrpld/rpc/detail/PathRequestManager.cpp index 117bfbda1e0..b2864c13caf 100644 --- a/src/xrpld/rpc/detail/PathRequestManager.cpp +++ b/src/xrpld/rpc/detail/PathRequestManager.cpp @@ -1,9 +1,13 @@ #include +#include #include +#include #include +#include #include #include +#include #include #include @@ -15,10 +19,15 @@ #include #include #include +#include #include +#include +#include #include +#include #include +#include #include #include #include @@ -26,210 +35,1101 @@ namespace xrpl { -/** - * Get the current AssetCache, updating it if necessary. - * Get the correct ledger to use. - */ +static_assert( + rpc::tuning::kPathSteadyUpdateParallelism == kPathFindWorkLimit, + "JtPathFindWork JobTypes limit must match path_find steady parallelism"); + +PathRequestManager::PathRequestManager( + Application& app, + beast::Journal journal, + beast::insight::Collector::ptr const& collector) + : app_(app) + , journal_(journal) + , midCloseBag_(std::make_shared()) + , midCloseTimer_(app.getIOContext()) + , lastIdentifier_(0) +{ + midCloseBag_->manager = this; + fast_ = collector->makeEvent("pathfind_fast"); + full_ = collector->makeEvent("pathfind_full"); + cacheHits_ = collector->makeCounter("pathfind_cache_hits"); + cacheMisses_ = collector->makeCounter("pathfind_cache_misses"); + linesLoaded_ = collector->makeCounter("pathfind_lines_loaded"); + cacheLedgerAdvances_ = collector->makeCounter("pathfind_cache_advances"); +} + +PathRequestManager::~PathRequestManager() +{ + // Detach live PathRequests first so ~PathRequest (WS may still hold + // shared_ptrs) never calls removePathRequest on a destroyed manager. + { + std::scoped_lock const sl(lock_); + for (auto const& wr : requests_) + { + if (auto req = wr.lock()) + req->detachFromManager(); + } + requests_.clear(); + assetCache_.reset(); + } + + // Stop new mid-close handlers from observing *this, cancel the timer under + // the same mutex that serializes expires_after/async_wait, then wait for + // any handler that already took a manager pointer (inFlight). + // Do not hold bag->mutex across refresh — that stalled io_context threads. + { + std::lock_guard const lk(midCloseBag_->mutex); + midCloseBag_->manager = nullptr; + cancelMidCloseTimerUnlocked(); + } + { + std::unique_lock lk(midCloseBag_->mutex); + midCloseBag_->idle.wait(lk, [this] { return midCloseBag_->inFlight == 0; }); + } +} + +void +PathRequestManager::publishCacheStats(AssetCache const& cache) +{ + // lastCache* baselines are shared with removePathRequest / dropRequest / + // releaseCacheIfIdleUnlocked (WS close threads). Always take lock_ — + // recursive so call sites that already hold it are fine; updateAll's + // end-of-wave publish must not race unlocked. + std::scoped_lock const sl(lock_); + + auto const hits = cache.cacheHits(); + auto const misses = cache.cacheMisses(); + auto const loaded = cache.linesLoaded(); + auto const advances = cache.ledgerAdvances(); + + if (hits > lastCacheHits_) + cacheHits_ += static_cast(hits - lastCacheHits_); + if (misses > lastCacheMisses_) + cacheMisses_ += static_cast(misses - lastCacheMisses_); + if (loaded > lastLinesLoaded_) + linesLoaded_ += static_cast(loaded - lastLinesLoaded_); + if (advances > lastLedgerAdvances_) + cacheLedgerAdvances_ += + static_cast(advances - lastLedgerAdvances_); + + lastCacheHits_ = hits; + lastCacheMisses_ = misses; + lastLinesLoaded_ = loaded; + lastLedgerAdvances_ = advances; + + JLOG(journal_.debug()) << "AssetCache stats lines=" << cache.totalLineCount() + << " hits=" << hits << " misses=" << misses << " loaded=" << loaded + << " advances=" << advances; +} + std::shared_ptr PathRequestManager::getAssetCache(std::shared_ptr const& ledger, bool authoritative) { std::scoped_lock const sl(lock_); - auto assetCache = assetCache_.lock(); - - std::uint32_t const lineSeq = assetCache ? assetCache->getLedger()->seq() : 0; + std::uint32_t const lineSeq = assetCache_ ? assetCache_->getLedger()->seq() : 0; std::uint32_t const lgrSeq = ledger->seq(); - JLOG(journal_.debug()) << "getLineCache has cache for " << lineSeq << ", considering " - << lgrSeq; + JLOG(journal_.debug()) << "getAssetCache has cache for " << lineSeq << ", considering " + << lgrSeq << " authoritative=" << authoritative; - if ((lineSeq == 0) || // no ledger - (authoritative && (lgrSeq > lineSeq)) || // newer authoritative ledger - (authoritative && ((lgrSeq + 8) < lineSeq)) || // we jumped way back for some reason - (lgrSeq > (lineSeq + 8))) // we jumped way forward for some reason + if (!assetCache_) { - JLOG(journal_.debug()) << "getLineCache creating new cache for " << lgrSeq; - // Assign to the local before the member, because the member is a - // weak_ptr, and will immediately discard it if there are no other - // references. - assetCache_ = assetCache = - std::make_shared(ledger, app_.getJournal("AssetCache")); + JLOG(journal_.debug()) << "getAssetCache creating new cache for " << lgrSeq; + auto const& cfg = app_.config(); + assetCache_ = std::make_shared( + ledger, + app_.getJournal("AssetCache"), + cfg.pathFindMaxTotalLines, + cfg.pathFindMaxLinesPerAccount, + cfg.pathCacheReuseLedgers, + cfg.pathFindLineChunkSize); + return assetCache_; } - return assetCache; + + if (lineSeq == lgrSeq) + { + // Same sequence: still prefer an authoritative closed view over a prior + // open mid-close view (open and closed share the upcoming seq). + if (authoritative && !ledger->open()) + { + auto const cur = assetCache_->getLedger(); + if (cur && cur->open()) + { + JLOG(journal_.debug()) + << "getAssetCache replacing open view with closed at seq " << lgrSeq; + assetCache_->advanceLedger(ledger, /*forceClear=*/false); + } + } + return assetCache_; + } + + // Only authoritative (validated/closed or closed create waves) mutate the + // shared cache. Non-authoritative callers (WS create / legacy doCreate) must + // not force-clear hubs for every other live session — they share the live + // cache view (soft reuse). Pathfinder snapshots cache->getLedger() at + // construct; line loads take AssetCache locks so advance is not racy, but + // create may observe a slightly older closed view than the open ledger + // passed in (intentional reuse window, not a second mutable cache). + if (!authoritative) + return assetCache_; + + // Large jumps force a rebuild so the soft-reuse window cannot straddle a + // huge gap. Matches historical getLineCache policy (authoritative only). + bool const largeJumpForward = lgrSeq > (lineSeq + 8); + bool const largeJumpBack = (lgrSeq + 8) < lineSeq; + if (largeJumpForward || largeJumpBack) + { + JLOG(journal_.info()) << "getAssetCache large ledger jump " << lineSeq << " -> " << lgrSeq + << "; force rebuild"; + assetCache_->advanceLedger(ledger, /*forceClear=*/true); + return assetCache_; + } + + // Soft-advance across sequence changes for closed/create waves. + if (lgrSeq > lineSeq) + { + assetCache_->advanceLedger(ledger, /*forceClear=*/false); + return assetCache_; + } + + return assetCache_; } +namespace { + +/** + * Run steady revalidates in bounded parallel batches via JobQueue. + * + * Matches project convention (no std::async): each unit is JtPathFindWork so + * concurrency is visible to job accounting. Requested width is + * kPathSteadyUpdateParallelism (== kPathFindWorkLimit); effective width is: + * workers < 3 → serial (no fork-join) + * workers >= 3 → min(requested, workers - 1) per batch + * (1 unit inline + ≤ workers - 2 JtPathFindWork siblings) + * + * workerCount comes from JobQueue::getWorkerCount() (actual pool size), not a + * re-derived Config estimate — the old floor of 2 forced serial revalidate on + * every default multi-thread node (real pools are typically 2+min(hw,4) ≥ 3). + * + * Fork-join from a JobQueue thread: queue siblings, run one unit on this + * thread, then wait on doneCv. That blocks a pool thread, so fan-out needs + * spare workers that can still drain JtPathFindWork. + * + * Deadlock class (workers == 2; or workers == 3 with batch > workers - 1): + * - Thread A: updateAll holds waveMutex_, forks siblings, waits doneCv + * - Thread B: second updateAll blocks on waveMutex_ (closed vs mid-close) + * - No free worker left for JtPathFindWork → both wait forever + * + * Mitigations (thresholds above): serial for workers < 3; batch ≤ workers - 1 + * reserves one pool thread for a concurrent waveMutex_ waiter. Safe with + * mid-close try_lock (skips rather than blocking a third pool thread forever). + * + * Completes the full work vector — do not abort mid-wave for new path_find + * sessions (that stretched mean update gap under load). + */ void -PathRequestManager::updateAll(std::shared_ptr const& inLedger) +runParallel( + JobQueue& jobQueue, + std::vector const& work, + int parallelism, + int workerCount, + std::function const& runOne, + bool pinIndex, + bool revalidateOnly, + std::function const& onDrop, + int& processed) +{ + if (work.empty()) + return; + + auto runSerial = [&](std::size_t begin, std::size_t end) { + for (std::size_t i = begin; i < end; ++i) + { + if (jobQueue.isStopping()) + { + // Release claims for work we never started so inProgress_ cannot + // stick forever across shutdown (needsUpdate would stay false). + for (std::size_t j = i; j < end; ++j) + work[j]->updateComplete(); + break; + } + bool const keep = runOne(work[i], pinIndex, revalidateOnly); + ++processed; + if (!keep) + onDrop(work[i]); + } + }; + + // Need: this parent (doneCv) + ≥1 sibling runner + spare for a concurrent + // waveMutex_ waiter. With only 2 workers the spare is gone as soon as a + // second updateAll blocks on the wave lock — permanent freeze. + if (workerCount < 3 || parallelism <= 1) + { + runSerial(0, work.size()); + return; + } + + // 1 unit inline on this thread; queue ≤ workerCount-2 siblings so that + // even if another JobQueue thread is blocked on waveMutex_, the remaining + // workers can still complete the barrier. + auto const maxBatch = static_cast(workerCount - 1); + auto const par = static_cast( + std::max(std::size_t{1}, std::min(static_cast(parallelism), maxBatch))); + + for (std::size_t batch = 0; batch < work.size(); batch += par) + { + if (jobQueue.isStopping()) + { + // Release claims for batches we never started. + for (std::size_t i = batch; i < work.size(); ++i) + work[i]->updateComplete(); + break; + } + + auto const end = std::min(batch + par, work.size()); + auto const count = end - batch; + + if (count == 1) + { + runSerial(batch, end); + continue; + } + + // results[i] = keep for work[batch + i] + auto results = std::make_shared>(count, 0); + + // Barrier must outlive any helper still finishing notify after the + // coordinator sees remaining==0 and leaves wait (stack mutex/cv UAF): + // last helper does fetch_sub then needs to lock+notify; coordinator may + // already have observed remaining==0 (predicate / spurious wake) and + // destroyed stack sync objects. Shared ownership keeps them alive. + struct BatchBarrier + { + std::mutex mutex; + std::condition_variable cv; + std::atomic remaining{0}; + }; + auto barrier = std::make_shared(); + barrier->remaining.store(count, std::memory_order_relaxed); + + auto finishOne = [barrier]() { + if (barrier->remaining.fetch_sub(1, std::memory_order_acq_rel) == 1) + { + std::lock_guard const lk(barrier->mutex); + barrier->cv.notify_one(); + } + }; + + auto runUnit = + [&runOne, pinIndex, revalidateOnly](PathRequest::pointer const& req) -> bool { + try + { + return runOne(req, pinIndex, revalidateOnly); + } + catch (...) + { + // Never leave the batch barrier hanging. ClaimGuard in runOne + // clears inProgress_ so the next wave can retry. + // + // Return true (keep): a false result is treated as onDrop and + // would permanently remove an open path_find subscription after + // a transient ledger error (e.g. SHAMapMissingNode). Serial + // runOne rethrows to LedgerMaster instead; parallel must not + // convert the same failure into a silent unsubscribe. + return true; + } + }; + + // Queue all but the last unit on JobQueue (JtPathFindWork). + for (std::size_t b = batch; b + 1 < end; ++b) + { + auto const idx = b - batch; + auto req = work[b]; + bool const queued = jobQueue.addJob( + JtPathFindWork, "PthFindSteady", [runUnit, req, results, idx, finishOne]() { + (*results)[idx] = runUnit(req) ? 1 : 0; + finishOne(); + }); + if (!queued) + { + // Shutdown / queue full: run inline so the barrier still completes. + (*results)[idx] = runUnit(req) ? 1 : 0; + finishOne(); + } + } + + // Last unit always runs on this thread so the barrier makes progress + // even when every other worker is busy (siblings drain on the rest). + { + auto const idx = count - 1; + auto const& req = work[batch + idx]; + (*results)[idx] = runUnit(req) ? 1 : 0; + finishOne(); + } + + { + std::unique_lock lk(barrier->mutex); + barrier->cv.wait( + lk, [&] { return barrier->remaining.load(std::memory_order_acquire) == 0; }); + } + + for (std::size_t i = 0; i < count; ++i) + { + ++processed; + if (!(*results)[i]) + onDrop(work[batch + i]); + } + } +} + +} // namespace + +void +PathRequestManager::cancelMidCloseTimerUnlocked() +{ + // Caller holds midCloseBag_->mutex. Bump epoch so a pending async_wait + // (operation_aborted or late fire) cannot clear scheduled after a re-arm + // or call onMidCloseTimer on a cancelled generation. + ++midCloseBag_->epoch; + midCloseBag_->scheduled = false; + midCloseTimer_.cancel(); +} + +void +PathRequestManager::scheduleMidCloseRefresh() +{ + // Only one mid-close timer in flight; re-armed after each tick while live. + // Serialize all timer ops on bag->mutex — boost::asio::steady_timer is not + // safe for concurrent expires_after/async_wait vs cancel from other threads + // (insertPathRequest, last-session release, destructor, timer re-arm). + auto bag = midCloseBag_; + std::uint64_t epoch = 0; + { + std::lock_guard const lk(bag->mutex); + if (!bag->manager || bag->scheduled) + return; + bag->scheduled = true; + epoch = ++bag->epoch; + midCloseTimer_.expires_after(app_.config().pathMidCloseDelay); + // Capture bag (not raw this). Enter inFlight only while using manager so + // ~PathRequestManager can wait without cancel() having to join io threads. + // Never hold bag->mutex across onMidCloseTimer / revalidate (io stall). + midCloseTimer_.async_wait([bag, epoch](boost::system::error_code const& waitEc) { + PathRequestManager* self = nullptr; + { + std::lock_guard const lk(bag->mutex); + // Stale generation (cancelled or superseded) — ignore. + if (epoch != bag->epoch) + return; + bag->scheduled = false; + if (!bag->manager) + return; + self = bag->manager; + ++bag->inFlight; + } + struct InFlightGuard + { + MidCloseBag& bag; + ~InFlightGuard() + { + std::lock_guard const lk(bag.mutex); + --bag.inFlight; + bag.idle.notify_all(); + } + } const inFlightGuard{*bag}; + self->onMidCloseTimer(waitEc); + }); + } +} + +void +PathRequestManager::onMidCloseTimer(boost::system::error_code const& waitEc) +{ + // Caller has entered MidCloseBag::inFlight so *this stays alive. + // bag->scheduled was cleared by the async_wait handler under bag->mutex. + if (waitEc || app_.isStopping() || !requestsPending()) + return; + + // Non-blocking: dispatch revalidate on JtRpc so it never waits behind + // JtUpdatePf (limit 1) closed-ledger / first-update waves. Skip if a + // prior tick is still queued or running (wave overran the period). + auto bag = midCloseBag_; + if (!revalidateJobPending_.exchange(true, std::memory_order_acq_rel)) + { + bool const queued = app_.getJobQueue().addJob(JtRpc, "PthFindReval", [bag]() { + // Brief bag lock only to claim manager + inFlight. Release before + // runPeriodicRevalidate so io_context timer threads are never + // blocked for the whole wave waiting on bag->mutex. + PathRequestManager* self = nullptr; + { + std::lock_guard const lk(bag->mutex); + if (!bag->manager) + return; + self = bag->manager; + ++bag->inFlight; + } + struct InFlightGuard + { + MidCloseBag& bag; + PathRequestManager* self; + ~InFlightGuard() + { + // Always clear pending (including non-std::exception) so a + // stuck flag cannot permanently suppress mid-close ticks. + if (self) + self->revalidateJobPending_.store(false, std::memory_order_release); + std::lock_guard const lk(bag.mutex); + --bag.inFlight; + bag.idle.notify_all(); + } + } const inFlightGuard{*bag, self}; + + try + { + self->runPeriodicRevalidate(); + } + catch (std::exception const& ex) + { + JLOG(self->journal_.info()) << "periodic path revalidate exception: " << ex.what(); + } + }); + if (!queued) + { + revalidateJobPending_.store(false, std::memory_order_release); + JLOG(journal_.debug()) << "periodic path revalidate job not queued"; + } + else + { + JLOG(journal_.debug()) << "periodic path revalidate job queued"; + } + } + + // Keep ticking every pathMidCloseDelay while sessions remain. + if (requestsPending() && !app_.isStopping()) + scheduleMidCloseRefresh(); +} + +void +PathRequestManager::runPeriodicRevalidate() +{ + if (app_.isStopping() || !requestsPending()) + return; + + // Open ledger: fresh offers/balances for rippleCalculate without waiting + // for the next validated close. Does not go through LedgerMaster::updatePaths. + auto const ledger = app_.getOpenLedger().current(); + if (!ledger) + return; + + try + { + JLOG(journal_.debug()) << "runPeriodicRevalidate open seq=" << ledger->seq(); + updateAll(ledger, /*midClose=*/true); + } + catch (SHAMapMissingNode const& mn) + { + // Mirror LedgerMaster::updatePaths: missing nodes are best-effort; + // do not leave sessions stuck. Next tick will retry. + JLOG(journal_.info()) << "During periodic path revalidate: " << mn.what(); + if (ledger->open()) + { + app_.getInboundLedgers().acquire( + ledger->header().parentHash, + ledger->header().seq - 1, + InboundLedger::Reason::GENERIC); + } + else + { + app_.getInboundLedgers().acquire( + ledger->header().hash, ledger->header().seq, InboundLedger::Reason::GENERIC); + } + } +} + +void +PathRequestManager::updateAll(std::shared_ptr const& inLedger, bool midClose) { auto event = app_.getJobQueue().makeLoadEvent(JtPathFind, "PathRequest::updateAll"); + // Mid-close must not block behind a closed wave (that re-created the + // ledger-bound gap). Skip the tick if a closed/create wave holds the lock. + std::unique_lock waveLock(waveMutex_, std::defer_lock); + if (midClose) + { + if (!waveLock.try_lock()) + { + JLOG(journal_.debug()) << "mid-close skipped: closed/create wave in progress"; + return; + } + } + else + { + waveLock.lock(); + } + std::vector requests; std::shared_ptr cache; - // Get the ledger and cache we should be using { std::scoped_lock const sl(lock_); requests = requests_; - cache = getAssetCache(inLedger, true); + // Closed / create: authoritative advance. Mid-close: do not advance the + // shared cache ledger_ to open (avoids races with closed waves); calc + // ledger is passed separately into doUpdate for PaymentSandbox. + bool const authoritative = !(midClose && inLedger->open()); + cache = getAssetCache(inLedger, authoritative); } - bool newRequests = app_.getLedgerMaster().isNewPathRequest(); + // Pure mid-close / periodic: never consume pathFindNewRequest_ (that would + // steal creates from LedgerMaster::updatePaths). Closed / create wakes do. + bool newRequests = false; + if (!midClose) + newRequests = app_.getLedgerMaster().isNewPathRequest(); + bool mustBreak = false; + // Validated/closed ledgers: refresh every session once per seq. + // Open mid-close (periodic JtRpc tick): revalidate established sessions. + // Open create wake (via updatePaths): brand-new path_find only. + bool const closedLedger = !inLedger->open(); + bool const processSteadyOnOpen = midClose && !closedLedger; + + // Pin / claim index must track the wave's ledger, not a lagging cache view. + // Closed waves: inLedger is authoritative. Mid-close does not pin lastIndex_ + // (pinIndex=false); cache seq is fine for logging / claimIndex edge cases. + auto const ledgerSeq = closedLedger ? inLedger->seq() : cache->getLedger()->seq(); + // Open mid-close: use the open view for pricing (issue #3). + std::shared_ptr const calcLedger = + processSteadyOnOpen ? inLedger : std::shared_ptr{}; - JLOG(journal_.trace()) << "updateAll seq=" << cache->getLedger()->seq() << ", " - << requests.size() << " requests"; + JLOG(journal_.trace()) << "updateAll seq=" << ledgerSeq << ", " << requests.size() + << " requests steadyParallel=" + << rpc::tuning::kPathSteadyUpdateParallelism + << " closed=" << closedLedger << " midClose=" << processSteadyOnOpen; - int processed = 0, removed = 0; + int processed = 0; + int removed = 0; auto getSubscriber = [](PathRequest::pointer const& request) -> InfoSub::pointer { if (auto ipSub = request->getSubscriber(); ipSub && ipSub->getRequest() == request) - { return ipSub; - } request->doAborting(); return nullptr; }; + // Returns true if request should be kept. + // pinIndex: pin lastIndex_ only for closed ledgers (open first-update must + // not skip the same-seq closed wave). Mid-close never pins. + // revalidateOnly: mid-close only — never Pathfinder. + auto runOne = + [&](PathRequest::pointer const& request, bool pinIndex, bool revalidateOnly) -> bool { + if (!request) + return false; + + // Always clear inProgress_ even if doUpdate throws. + struct ClaimGuard + { + PathRequest& req; + bool active{true}; + bool completed{false}; + std::optional pin; + ~ClaimGuard() + { + if (active) + req.updateComplete(pin, completed); + } + void + pinTo(LedgerIndex seq) + { + pin = seq; + completed = true; + } + void + markCompleted() + { + completed = true; + } + } guard{.req = *request, .active = true, .completed = false, .pin = std::nullopt}; + + try + { + auto continueCallback = [&getSubscriber, &request]() { + return static_cast(getSubscriber(request)); + }; + + if (auto ipSub = getSubscriber(request)) + { + if (ipSub->getConsumer().warn()) + { + // Dropped for resource pressure — do not mark completed. + return false; + } + + ipSub.reset(); + json::Value update = + request->doUpdate(cache, false, continueCallback, revalidateOnly, calcLedger); + if (pinIndex) + guard.pinTo(ledgerSeq); + else + guard.markCompleted(); + update[jss::type] = "path_find"; + ipSub = getSubscriber(request); + if (ipSub) + { + ipSub->send(update, false); + return true; + } + return false; + } + + if (request->hasCompletion()) + { + request->doUpdate(cache, false, {}, revalidateOnly, calcLedger); + if (pinIndex) + guard.pinTo(ledgerSeq); + else + guard.markCompleted(); + return false; + } + + return false; + } + catch (...) + { + // ClaimGuard clears inProgress_. Propagate after logging. + JLOG(journal_.info()) << "path request update threw"; + throw; + } + }; + + auto dropRequest = [&](PathRequest::pointer const& request) { + std::scoped_lock const sl(lock_); + removed += static_cast(rebuildRequestsUnlocked(request ? request.get() : nullptr)); + + // Always release session pins (resource-pressure / exception drops used + // to skip this and strand PathFindTrustLine vectors under budget). + if (assetCache_ && request) + { + auto const freed = assetCache_->releaseSession(request->id()); + if (freed > 0) + publishCacheStats(*assetCache_); + } + + // Force-drop removes the weak from requests_ but the PathRequest may + // outlive this wave (WS still holds a shared_ptr). Clear owner_ so + // ~PathRequest does not call removePathRequest on a manager that was + // never told to detach this session (manager dtor only detaches + // entries still in requests_ — force-dropped ones would UAF). + if (request) + request->detachFromManager(); + + // Last session gone (or only dead weaks) — free trust-line memory now. + releaseCacheIfIdleUnlocked(); + }; + do { JLOG(journal_.trace()) << "updateAll looping"; + + std::vector firstUpdates; + std::vector steadyUpdates; + firstUpdates.reserve(requests.size()); + steadyUpdates.reserve(requests.size()); + + // Progressive line fill once per closed/create wave (shared cache), not + // per session inside parallel doUpdate (avoids N unique-lock expands). + // + // Must run BEFORE needsUpdate claims inProgress_. getItemsChunk can + // throw SHAMapMissingNode (incomplete ledger data). If expand ran after + // claims and threw, LedgerMaster/runPeriodicRevalidate catch the error + // but never clear inProgress_ — open subscriptions then permanently + // skip every later wave (needsUpdate returns false while inProgress_). + // waveMutex_ already serializes waves, so expand-before-claim is safe. + if (!processSteadyOnOpen && cache) + cache->expandIncompleteLines(); + + // Partition before running work. Capture isNew() before needsUpdate + // (needsUpdate only sets inProgress_; lastIndex_ flips after complete). + // + // Open ledger without midClose: only brand-new sessions (newOnly) so + // ramp stays O(n). Mid-close / closed: include steady revalidates. + bool const newOnly = newRequests && !closedLedger && !processSteadyOnOpen; + + // Periodic mid-close must re-claim every tick even when lastIndex_ + // already equals the open ledger seq (same seq until the next close). + // Use max index so needsUpdate only gates on inProgress_. + auto const claimIndex = + processSteadyOnOpen ? std::numeric_limits::max() : ledgerSeq; + for (auto const& wr : requests) { if (app_.getJobQueue().isStopping()) break; auto request = wr.lock(); - bool remove = true; - JLOG(journal_.trace()) << "updateAll request " << (request ? "" : "not ") << "found"; + if (!request) + { + dropRequest(nullptr); + continue; + } - if (request) + bool const isFirst = request->isNew(); + if (!request->needsUpdate(newOnly, claimIndex)) + continue; + + // Mid-close is revalidate-only for established WS subscriptions. + // Never first-Pathfind brand-new sessions or complete one-shot + // legacy ripple_path_find against the open ledger here — those + // belong to create/closed waves (updatePaths). + if (processSteadyOnOpen) { - auto continueCallback = [&getSubscriber, &request]() { - // This callback is used by doUpdate to determine whether to - // continue working. If getSubscriber returns null, that - // indicates that this request is no longer relevant. - return (bool)getSubscriber(request); - }; - if (!request->needsUpdate(newRequests, cache->getLedger()->seq())) - { - remove = false; - } - else + if (isFirst || request->hasCompletion()) { - if (auto ipSub = getSubscriber(request)) - { - if (!ipSub->getConsumer().warn()) - { - // Release the shared ptr to the subscriber so that - // it can be freed if the client disconnects, and - // thus fail to lock later. - ipSub.reset(); - json::Value update = request->doUpdate(cache, false, continueCallback); - request->updateComplete(); - update[jss::type] = "path_find"; - ipSub = getSubscriber(request); - if (ipSub) - { - ipSub->send(update, false); - remove = false; - ++processed; - } - } - } - else if (request->hasCompletion()) - { - // One-shot request with completion function - request->doUpdate(cache, false); - request->updateComplete(); - ++processed; - } + request->updateComplete(); // release claim only + continue; } + steadyUpdates.push_back(std::move(request)); + continue; } - if (remove) + if (isFirst) + firstUpdates.push_back(std::move(request)); + else if (closedLedger) + steadyUpdates.push_back(std::move(request)); + else { - std::scoped_lock const sl(lock_); - - // Remove any dangling weak pointers or weak - // pointers that refer to this path request. - auto ret = std::ranges::remove_if(requests_, [&removed, &request](auto const& wl) { - auto r = wl.lock(); + // Open non-midClose: claimed a non-new request somehow — release. + request->updateComplete(); + } + } - if (r && r != request) - return false; - ++removed; - return true; - }); + // First full updates: serial — avoids ramp load spikes / gap mountains. + // Pin lastIndex_ only on closed ledgers so an open first-update at seq S + // does not skip the subsequent closed wave at the same S. isNew() clears + // via markCompleted without a pin on open. + // + // needsUpdate already set inProgress_ for every entry in firstUpdates / + // steadyUpdates. If runOne throws (e.g. SHAMapMissingNode), release all + // remaining claims before rethrowing — otherwise those sessions stay + // inProgress forever and never receive another update. + std::size_t firstDone = 0; + try + { + for (; firstDone < firstUpdates.size(); ++firstDone) + { + if (app_.getJobQueue().isStopping()) + break; + if (!newRequests && app_.getLedgerMaster().isNewPathRequest()) + { + mustBreak = true; + break; + } - requests_.erase(ret.begin(), ret.end()); + auto const& req = firstUpdates[firstDone]; + // First update: full Pathfinder (revalidateOnly=false). + bool const keep = runOne(req, /*pinIndex=*/closedLedger, /*revalidateOnly=*/false); + ++processed; + if (!keep) + dropRequest(req); } + // Release claims for first updates we never started. + for (std::size_t i = firstDone; i < firstUpdates.size(); ++i) + firstUpdates[i]->updateComplete(); - mustBreak = !newRequests && app_.getLedgerMaster().isNewPathRequest(); - - // We weren't handling new requests and then - // there was a new request - if (mustBreak) - break; + if (!mustBreak && !app_.getJobQueue().isStopping()) + { + // Established sessions: steady revalidate (main gap win). + // runParallel is serial when JobQueue workers < 3, else batches + // of min(kPathSteadyUpdateParallelism, workers - 1). + // Closed: pin lastIndex_. Mid-close: do not pin. + // revalidateOnly ONLY for mid-close so closed waves can rediscover + // / recover failed searches (staggered / backoff in doUpdate). + // runParallel's runUnit catches per-unit errors (no claim leak). + bool const pinSteady = closedLedger; + bool const revalidateOnly = processSteadyOnOpen; + runParallel( + app_.getJobQueue(), + steadyUpdates, + rpc::tuning::kPathSteadyUpdateParallelism, + app_.getJobQueue().getWorkerCount(), + runOne, + pinSteady, + revalidateOnly, + dropRequest, + processed); + } + else + { + // Release steady claims we never started. + for (auto const& req : steadyUpdates) + req->updateComplete(); + } + } + catch (...) + { + // runOne's ClaimGuard already cleared the throwing request. Clear + // every other claimed session so they are not frozen (updateComplete + // is idempotent when inProgress_ is already false). + for (std::size_t i = firstDone; i < firstUpdates.size(); ++i) + firstUpdates[i]->updateComplete(); + for (auto const& req : steadyUpdates) + req->updateComplete(); + throw; } if (mustBreak) - { // a new request came in while we were working + { + // Interrupted to pick up brand-new sessions; loop with newOnly. newRequests = true; } + else if (processSteadyOnOpen) + { + // One mid-close revalidate pass. Never poll/consume + // pathFindNewRequest_ here — that would steal creates from + // LedgerMaster::updatePaths. updatePaths checks the flag before + // calling updateAll; if mid-close clears it, the create job exits + // with "Nothing to do" and brand-new path_find clients wait until + // the next closed ledger for their first full Pathfinder result. + break; + } else if (newRequests) - { // we only did new requests, so we always need a last pass + { newRequests = app_.getLedgerMaster().isNewPathRequest(); + if (!newRequests) + { + if (closedLedger) + { + // Drain done (newRequests already false). Fall through to + // re-snapshot and run one full pass so everyone is updated + // for this validated ledger (skips those already completed + // via lastIndex_ >= seq). + } + else + { + // Open-ledger create wake: new sessions only. + break; + } + } } else - { // if there are no new requests, we are done + { newRequests = app_.getLedgerMaster().isNewPathRequest(); if (!newRequests) break; + // New sessions arrived during a full pass — handle them next. } - // Hold on to the line cache until after the lock is released, so it can - // be destroyed outside of the lock - std::shared_ptr lastCache; { - // Get the latest requests, cache, and ledger for next pass std::scoped_lock const sl(lock_); - if (requests_.empty()) break; requests = requests_; - lastCache = cache; cache = getAssetCache(cache->getLedger(), false); } + mustBreak = false; } while (!app_.getJobQueue().isStopping()); + // Publish only while this wave's cache is still the manager's live + // instance. If the last subscription ended mid-update, dropRequest / + // releaseCacheIfIdle already published deltas and zeroed lastCache* + // baselines; publishing the local shared_ptr again would re-count the + // entire cache lifetime as a new insight delta (inflated counters). + { + std::scoped_lock const sl(lock_); + if (cache && assetCache_ == cache) + publishCacheStats(*cache); + } + + // Keep the periodic revalidate timer armed while sessions are live. + // (Also started from insertPathRequest; this re-arms after closed waves.) + if (requestsPending() && !app_.isStopping()) + scheduleMidCloseRefresh(); + else if (!requestsPending()) + { + // Drop any residual dead weaks and release cache if fully idle. + std::scoped_lock const sl(lock_); + releaseCacheIfIdleUnlocked(); + } + JLOG(journal_.debug()) << "updateAll complete: " << processed << " processed and " << removed << " removed"; } +bool +PathRequestManager::hasLiveRequestsUnlocked() const +{ + // Use expired() — do not promote weak_ptr to shared_ptr. A temporary + // shared_ptr that is the last owner would run ~PathRequest → removePathRequest + // and re-enter while callers iterate requests_. + for (auto const& w : requests_) + { + if (!w.expired()) + return true; + } + return false; +} + +std::size_t +PathRequestManager::rebuildRequestsUnlocked(PathRequest* request) +{ + // Hold every successfully locked request until AFTER requests_ is replaced. + // Otherwise the temporary shared_ptr from weak_ptr::lock() can be the last + // owner; its destructor calls removePathRequest and erases requests_ while + // the caller is still mid-iteration (recursive_mutex allows re-entry). + std::vector keepAlive; + std::vector survivors; + keepAlive.reserve(requests_.size()); + survivors.reserve(requests_.size()); + + std::size_t removed = 0; + for (auto const& wl : requests_) + { + auto r = wl.lock(); + if (!r) + { + ++removed; + continue; + } + keepAlive.push_back(r); + if (request && r.get() == request) + { + ++removed; + continue; + } + survivors.push_back(wl); + } + requests_ = std::move(survivors); + // keepAlive destructs after requests_ is stable; nested removePathRequest + // (if any) only rebuilds an already-consistent vector. + return removed; +} + +void +PathRequestManager::releaseCacheIfIdleUnlocked() +{ + // Drop expired weak_ptrs first — they previously kept requests_ non-empty + // forever after WS disconnect, so AssetCache was never reclaimed. + // expired() only — never lock() here (see rebuildRequestsUnlocked). + auto dead = std::ranges::remove_if(requests_, [](auto const& wl) { return wl.expired(); }); + requests_.erase(dead.begin(), dead.end()); + + if (hasLiveRequestsUnlocked()) + { + // Per-account pins (releaseSession on each close) already freed + // unreferenced entries. Remaining map content is still held by live + // sessions — do not LRU/proportionally evict shared hubs. + return; + } + + { + // Timer cancel must use the same mutex as scheduleMidCloseRefresh + // (insert / timer / destructor threads). + std::lock_guard const lk(midCloseBag_->mutex); + cancelMidCloseTimerUnlocked(); + } + revalidateJobPending_.store(false, std::memory_order_release); + + if (!assetCache_) + return; + + auto const lines = assetCache_->totalLineCount(); + auto const loaded = assetCache_->linesLoaded(); + publishCacheStats(*assetCache_); + JLOG(journal_.info()) << "releasing AssetCache (no path_find sessions) lines=" << lines + << " lifetime_loaded=" << loaded; + assetCache_.reset(); + // Reset published baselines so the next cache instance does not invent + // huge insight deltas from a fresh 0-based counter set. + lastCacheHits_ = 0; + lastCacheMisses_ = 0; + lastLinesLoaded_ = 0; + lastLedgerAdvances_ = 0; +} + bool PathRequestManager::requestsPending() const { std::scoped_lock const sl(lock_); - return !requests_.empty(); + // Only live sessions — expired weak_ptrs must not keep pathfinding "busy" + // or pin the AssetCache after all websockets have closed. + return hasLiveRequestsUnlocked(); +} + +PathRequestManager::CacheStats +PathRequestManager::getCacheStats() const +{ + std::scoped_lock const sl(lock_); + CacheStats stats; + if (!assetCache_) + return stats; + + stats.available = true; + stats.hits = assetCache_->cacheHits(); + stats.misses = assetCache_->cacheMisses(); + stats.linesLoaded = assetCache_->linesLoaded(); + stats.ledgerAdvances = assetCache_->ledgerAdvances(); + stats.totalLines = assetCache_->totalLineCount(); + return stats; } void -PathRequestManager::insertPathRequest(PathRequest::pointer const& req) +PathRequestManager::removePathRequest(PathRequest* request) { std::scoped_lock const sl(lock_); + rebuildRequestsUnlocked(request); - // Insert after any older unserviced requests but before - // any serviced requests - auto ret = std::ranges::find_if(requests_, [](auto const& wl) { - auto r = wl.lock(); + // Drop this session's account pins. Accounts still pinned by other live + // path_finds are kept; only exclusively held (or last holder) entries free. + if (assetCache_ && request) + { + auto const freed = assetCache_->releaseSession(request->id()); + if (freed > 0) + publishCacheStats(*assetCache_); + } + + // Clear owner_ once unlinked so a later ~PathRequest (or a second close) + // does not re-enter after this manager is destroyed. Manager dtor only + // detaches sessions still listed in requests_; closed/force-dropped ones + // would otherwise keep a dangling owner_. + if (request) + request->detachFromManager(); + + releaseCacheIfIdleUnlocked(); +} - // We come before handled requests - return r && !r->isNew(); - }); +void +PathRequestManager::insertPathRequest(PathRequest::pointer const& req) +{ + bool armTimer = false; + { + std::scoped_lock const sl(lock_); + + // Promote only while scanning; keep alive until after emplace so a + // last-ref temporary cannot re-enter removePathRequest mid-find. + std::vector keepAlive; + auto insertAt = requests_.end(); + for (auto it = requests_.begin(); it != requests_.end(); ++it) + { + auto r = it->lock(); + if (!r) + continue; + keepAlive.push_back(r); + if (!r->isNew()) + { + insertAt = it; + break; + } + } + + armTimer = !hasLiveRequestsUnlocked(); + requests_.emplace(insertAt, req); + } - requests_.emplace(ret, req); + // First live session: start periodic revalidate ticks immediately so gaps + // are not gated on waiting for a full closed-ledger wave to finish. + if (armTimer && !app_.isStopping()) + scheduleMidCloseRefresh(); } -// Make a new-style path_find request json::Value PathRequestManager::makePathRequest( std::shared_ptr const& subscriber, @@ -249,7 +1149,6 @@ PathRequestManager::makePathRequest( return std::move(jvRes); } -// Make an old-style ripple_path_find request json::Value PathRequestManager::makeLegacyPathRequest( PathRequest::pointer& req, @@ -258,8 +1157,6 @@ PathRequestManager::makeLegacyPathRequest( std::shared_ptr const& inLedger, json::Value const& request) { - // This assignment must take place before the - // completion function is called req = std::make_shared( app_, completion, consumer, ++lastIdentifier_, *this, journal_); @@ -274,7 +1171,6 @@ PathRequestManager::makeLegacyPathRequest( insertPathRequest(req); if (!app_.getLedgerMaster().newPathRequest()) { - // The newPathRequest failed. Tell the caller. jvRes = rpcError(RpcTooBusy); req.reset(); } @@ -289,7 +1185,14 @@ PathRequestManager::doLegacyPathRequest( std::shared_ptr const& inLedger, json::Value const& request) { - auto cache = std::make_shared(inLedger, app_.getJournal("AssetCache")); + auto const& cfg = app_.config(); + auto cache = std::make_shared( + inLedger, + app_.getJournal("AssetCache"), + cfg.pathFindMaxTotalLines, + cfg.pathFindMaxLinesPerAccount, + cfg.pathCacheReuseLedgers, + cfg.pathFindLineChunkSize); auto req = std::make_shared(app_, [] {}, consumer, ++lastIdentifier_, *this, journal_); diff --git a/src/xrpld/rpc/detail/PathRequestManager.h b/src/xrpld/rpc/detail/PathRequestManager.h index 29a80e66c04..600ae371e0e 100644 --- a/src/xrpld/rpc/detail/PathRequestManager.h +++ b/src/xrpld/rpc/detail/PathRequestManager.h @@ -5,14 +5,20 @@ #include #include +#include +#include #include #include #include #include #include +#include + #include #include +#include +#include #include #include #include @@ -23,44 +29,49 @@ namespace xrpl { class PathRequestManager { public: - /** - * A collection of all PathRequest instances. - */ PathRequestManager( Application& app, beast::Journal journal, - beast::insight::Collector::ptr const& collector) - : app_(app), journal_(journal), lastIdentifier_(0) - { - fast_ = collector->makeEvent("pathfind_fast"); - full_ = collector->makeEvent("pathfind_full"); - } + beast::insight::Collector::ptr const& collector); /** - * Update all of the contained PathRequest instances. - * - * @param ledger Ledger we are pathfinding in. + * Detach mid-close handlers, wait for any in-flight timer/JtRpc work that + * already holds a manager pointer, then cancel the timer. io_context + * threads may outlive this object; MidCloseBag keeps them from using a + * dangling PathRequestManager without holding bag->mutex across refresh. + */ + ~PathRequestManager(); + + /** + * @param midClose When true and ledger is open, also revalidate established + * sessions (not just brand-new creates). Used for sub-close-interval + * updates; does not pin lastIndex_ for steady sessions so the next + * closed ledger still refreshes everyone. */ void - updateAll(std::shared_ptr const& ledger); + updateAll(std::shared_ptr const& ledger, bool midClose = false); bool requestsPending() const; + /** + * Arm the periodic revalidate timer (Config::pathMidCloseDelay). Safe to + * call repeatedly from any thread; only one timer is in flight. All timer + * ops are serialized on MidCloseBag::mutex (asio steady_timer is not + * thread-safe across expires_after / async_wait / cancel). + */ + void + scheduleMidCloseRefresh(); + std::shared_ptr getAssetCache(std::shared_ptr const& ledger, bool authoritative); - // Create a new-style path request that pushes - // updates to a subscriber json::Value makePathRequest( std::shared_ptr const& subscriber, std::shared_ptr const& ledger, json::Value const& request); - // Create an old-style path request that is - // managed by a coroutine and updated by - // the path engine json::Value makeLegacyPathRequest( PathRequest::pointer& req, @@ -69,8 +80,6 @@ class PathRequestManager std::shared_ptr const& inLedger, json::Value const& request); - // Execute an old-style path request immediately - // with the ledger specified by the caller json::Value doLegacyPathRequest( resource::Consumer& consumer, @@ -89,21 +98,145 @@ class PathRequestManager full_.notify(ms); } + /** + * Snapshot of AssetCache counters for get_counts / monitoring. + */ + struct CacheStats + { + bool available = false; + std::uint64_t hits = 0; + std::uint64_t misses = 0; + std::uint64_t linesLoaded = 0; + std::uint64_t ledgerAdvances = 0; + std::size_t totalLines = 0; + }; + + [[nodiscard]] CacheStats + getCacheStats() const; + + /** + * Drop a finished/closed path_find session. When the last live session is + * gone, releases AssetCache so trust-line memory and get_counts cache + * counters reclaim (pathfind_cache_lines / pathfind_lines_loaded → 0). + */ + void + removePathRequest(PathRequest* request); + private: void insertPathRequest(PathRequest::pointer const&); + /** + * Publish AssetCache counter deltas to insight collectors. + * Takes lock_ (recursive) — serializes lastCache* baselines with close paths. + */ + void + publishCacheStats(AssetCache const& cache); + + /** + * Caller holds lock_. Rebuild requests_ without expired weaks, and drop + * @a request if non-null. Strong refs are held until after the vector is + * replaced so ~PathRequest → removePathRequest cannot re-enter mid-erase + * (recursive_mutex would allow that and invalidate iterators). + * + * @return number of entries removed (expired + matched request). + */ + std::size_t + rebuildRequestsUnlocked(PathRequest* request = nullptr); + + /** + * Caller holds lock_. Erase expired weak_ptrs; if no live sessions remain, + * destroy assetCache_ (reclaims PathFindTrustLine memory). + */ + void + releaseCacheIfIdleUnlocked(); + + [[nodiscard]] bool + hasLiveRequestsUnlocked() const; + + /** + * Open-ledger revalidate-only wave for all established sessions. Runs on + * JtRpc (not JtUpdatePf) so it never waits behind closed-ledger Pathfinder. + */ + void + runPeriodicRevalidate(); + + /** + * Timer completion body. Invoked only after the async_wait handler has + * validated bag epoch/manager and entered inFlight (bag mutex not held + * across this call). + */ + void + onMidCloseTimer(boost::system::error_code const& waitEc); + + /** + * Cancel the mid-close timer and invalidate any pending async_wait. + * Caller must hold midCloseBag_->mutex. + */ + void + cancelMidCloseTimerUnlocked(); + Application& app_; beast::Journal journal_; beast::insight::Event fast_; beast::insight::Event full_; + beast::insight::Counter cacheHits_; + beast::insight::Counter cacheMisses_; + beast::insight::Counter linesLoaded_; + beast::insight::Counter cacheLedgerAdvances_; + + std::uint64_t lastCacheHits_{0}; + std::uint64_t lastCacheMisses_{0}; + std::uint64_t lastLinesLoaded_{0}; + std::uint64_t lastLedgerAdvances_{0}; - // Track all requests std::vector requests_; - // Use a AssetCache - std::weak_ptr assetCache_; + // Strong while any path_find session is live; released when idle so + // trust-line vectors are not pinned forever after WS disconnect. + std::shared_ptr assetCache_; + + /** + * Lifetime + timer token for mid-close async_wait / JtRpc jobs. Handlers + * capture shared_ptr. bag->mutex is held only for: + * - publish/check manager and enter/leave inFlight + * - all midCloseTimer_ ops (expires_after / async_wait / cancel) and the + * scheduled/epoch single-flight fields (asio timers are not thread-safe) + * Never hold bag->mutex across runPeriodicRevalidate so network io_context + * threads are not stalled on a long refresh. + * + * Protocol: under mutex, if manager is non-null, ++inFlight and copy the + * pointer, unlock, use the pointer, then --inFlight and notify. Destructor + * nulls manager, invalidates the timer epoch, cancels the timer, then waits + * until inFlight == 0 so no handler uses a destroyed PathRequestManager + * (io threads outlive this object). + * + * epoch is bumped on every cancel so a stale operation_aborted handler + * cannot clear scheduled after a newer arm, or re-enter onMidCloseTimer. + */ + struct MidCloseBag + { + std::mutex mutex; + std::condition_variable idle; + PathRequestManager* manager{nullptr}; + int inFlight{0}; + bool scheduled{false}; + std::uint64_t epoch{0}; + }; + std::shared_ptr midCloseBag_; + + boost::asio::steady_timer midCloseTimer_; + // True while a JtRpc periodic revalidate job is queued or running. + // Cleared only after runPeriodicRevalidate returns (not before). + std::atomic revalidateJobPending_{false}; + + // Serializes closed/create updateAll vs mid-close. Mid-close uses try_lock + // so it never blocks behind a long closed wave (skips the tick instead). + // Closed/create uses lock() and can occupy a JobQueue worker while waiting; + // runParallel therefore only fans out when workers >= 3 and caps each batch + // at workers - 1 so that waiter cannot starve JtPathFindWork siblings. + std::mutex waveMutex_; std::atomic lastIdentifier_; diff --git a/src/xrpld/rpc/detail/Pathfinder.cpp b/src/xrpld/rpc/detail/Pathfinder.cpp index 642b5c42533..9e988d715a4 100644 --- a/src/xrpld/rpc/detail/Pathfinder.cpp +++ b/src/xrpld/rpc/detail/Pathfinder.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -28,6 +29,7 @@ #include #include #include +#include #include #include @@ -37,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -68,16 +71,12 @@ Each complete path is then rated and sorted. Paths with no or trivial liquidity are dropped. Otherwise, paths are sorted based on quality, liquidity, and path length. -Path slots are filled in quality (ratio of out to in) order, with the -exception that the last path must have enough liquidity to complete the -payment (assuming no liquidity overlap). In addition, if no selected path -is capable of providing enough liquidity to complete the payment by itself, -an extra "covering" path is returned. +Path slots are filled in quality (ratio of out to in) order up to maxPaths. +Each selected alternative is returned for the client to choose among; there +are no reserved full-liquidity spare slots and no extra covering path. The selected paths are then tested to determine if they can complete the -payment and, if so, at what cost. If they fail and a covering path was -found, the test is repeated with the covering path. If this succeeds, the -final paths and the estimated cost are returned. +payment and, if so, at what cost. The engine permits the search depth to be selected and the paths table includes the depth at which each path type is found. A search depth of zero @@ -176,12 +175,19 @@ pathTypeToString(Pathfinder::PathType const& type) return ret; } -// Return the smallest amount of useful liquidity for a given amount, and the -// total number of paths we have to evaluate. +// Smallest liquidity worth ranking for a destination amount. +// +// Divisor is maxPaths+2 (historical covering/spare slots). That must stay: +// transactionSign build_path still ranks with maxPaths=4 and needs amount/6. +// Dividing by maxPaths alone made signed-payment path building use amount/4, +// which discarded usable routes that previously ranked. +// path_find (maxPaths=6) becomes amount/8 — slightly more permissive, never +// stricter than the old floor. STAmount smallestUsefulAmount(STAmount const& amount, int maxPaths) { - return divide(amount, STAmount(maxPaths + 2), amount.asset()); + auto const slots = std::max(1, maxPaths + 2); + return divide(amount, STAmount(slots), amount.asset()); } STAmount @@ -529,10 +535,30 @@ Pathfinder::rankPaths( std::vector& rankedPaths, std::function const& continueCallback) { - JLOG(j_.trace()) << "rankPaths with " << paths.size() << " candidates, and " << maxPaths - << " maximum"; + // Cap expensive getPathLiquidity work. completePaths_ may hold up to + // kPathfinderMaxCompletePaths; each candidate costs 1–2 RippleCalc. + auto const rankCap = static_cast( + app_.getFeeTrack().isLoadedLocal() ? rpc::tuning::kPathRankMaxCandidatesLoaded + : rpc::tuning::kPathRankMaxCandidates); + auto toRank = std::min(paths.size(), rankCap); + + // When truncating, do not rank pure insertion order (later gPathTable + // expansions would never be liquidity-tested). Pre-order by length — free + // and correlated with final rank — then take the first toRank indices. + // Equal lengths keep relative insertion order (stable_sort). + std::vector order(paths.size()); + std::iota(order.begin(), order.end(), 0); + if (paths.size() > toRank) + { + std::stable_sort(order.begin(), order.end(), [&](std::size_t a, std::size_t b) { + return paths[a].size() < paths[b].size(); + }); + } + + JLOG(j_.trace()) << "rankPaths with " << paths.size() << " candidates (ranking " << toRank + << "), and " << maxPaths << " maximum"; rankedPaths.clear(); - rankedPaths.reserve(paths.size()); + rankedPaths.reserve(toRank); auto const saMinDstAmount = [&]() -> STAmount { if (!convertAll_) @@ -546,33 +572,48 @@ Pathfinder::rankPaths( return largestAmount(dstAmount_); }(); - for (int i = 0; i < paths.size(); ++i) + auto rankOne = [&](std::size_t pathIndex) { + auto const& currentPath = paths[pathIndex]; + if (currentPath.empty()) + return; + STAmount liquidity; + uint64_t uQuality = 0; + auto const resultCode = getPathLiquidity(currentPath, saMinDstAmount, liquidity, uQuality); + if (!isTesSuccess(resultCode)) + { + JLOG(j_.debug()) << "findPaths: dropping : " << transToken(resultCode) << ": " + << currentPath.getJson(JsonOptions::Values::None); + return; + } + JLOG(j_.debug()) << "findPaths: quality: " << uQuality << ": " + << currentPath.getJson(JsonOptions::Values::None); + rankedPaths.push_back( + {.quality = uQuality, + .length = currentPath.size(), + .liquidity = liquidity, + .index = static_cast(pathIndex)}); + }; + + for (std::size_t r = 0; r < toRank; ++r) { if (continueCallback && !continueCallback()) return; - auto const& currentPath = paths[i]; - if (!currentPath.empty()) - { - STAmount liquidity; - uint64_t uQuality = 0; - auto const resultCode = - getPathLiquidity(currentPath, saMinDstAmount, liquidity, uQuality); - if (!isTesSuccess(resultCode)) - { - JLOG(j_.debug()) << "findPaths: dropping : " << transToken(resultCode) << ": " - << currentPath.getJson(JsonOptions::Values::None); - } - else - { - JLOG(j_.debug()) << "findPaths: quality: " << uQuality << ": " - << currentPath.getJson(JsonOptions::Values::None); + rankOne(order[r]); + } - rankedPaths.push_back( - {.quality = uQuality, - .length = currentPath.size(), - .liquidity = liquidity, - .index = i}); - } + // Length-first truncation can skip the only liquid route (often longer). + // If the first pass found nothing and we truncated, rank the remainder so + // payment build_path / path_find do not silently return empty alternatives. + if (rankedPaths.empty() && paths.size() > toRank) + { + JLOG(j_.debug()) << "rankPaths empty after truncating to " << toRank + << "; ranking remaining " << (paths.size() - toRank) << " candidates"; + rankedPaths.reserve(paths.size()); + for (std::size_t r = toRank; r < paths.size(); ++r) + { + if (continueCallback && !continueCallback()) + return; + rankOne(order[r]); } } @@ -603,7 +644,6 @@ Pathfinder::rankPaths( STPathSet Pathfinder::getBestPaths( int maxPaths, - STPath& fullLiquidityPath, STPathSet const& extraPaths, AccountID const& srcIssuer, std::function const& continueCallback) @@ -614,8 +654,6 @@ Pathfinder::getBestPaths( if (completePaths_.empty() && extraPaths.empty()) return completePaths_; - XRPL_ASSERT( - fullLiquidityPath.empty(), "xrpl::Pathfinder::getBestPaths : first empty path result"); bool const issuerIsSender = isXRP(srcPathAsset_) || (srcIssuer == srcAccount_); std::vector extraPathRanks; @@ -623,9 +661,9 @@ Pathfinder::getBestPaths( STPathSet bestPaths; - // The best PathRanks are now at the start. Pull off enough of them to - // fill bestPaths, then look through the rest for the best individual - // path that can satisfy the entire liquidity - if one exists. + // The best PathRanks are now at the start. Fill up to maxPaths alternatives + // by quality/liquidity. Do not reserve spare slots for a single fully + // liquid covering path — clients want the full set of alternatives. STAmount remaining = remainingAmount_; auto pathsIterator = pathRanks_.begin(); @@ -635,6 +673,10 @@ Pathfinder::getBestPaths( { if (continueCallback && !continueCallback()) break; + + if (static_cast(bestPaths.size()) >= maxPaths) + break; + bool usePath = false; bool useExtraPath = false; @@ -675,10 +717,6 @@ Pathfinder::getBestPaths( if (usePath) ++pathsIterator; - auto iPathsLeft = maxPaths - bestPaths.size(); - if (iPathsLeft <= 0 && !fullLiquidityPath.empty()) - break; - if (path.empty()) { // LCOV_EXCL_START @@ -700,31 +738,12 @@ Pathfinder::getBestPaths( startsWithIssuer = true; } - if (iPathsLeft > 1 || (iPathsLeft > 0 && pathRank.liquidity >= remaining)) - // last path must fill - { - --iPathsLeft; - remaining -= pathRank.liquidity; - bestPaths.pushBack(startsWithIssuer ? removeIssuer(path) : path); - } - else if (iPathsLeft == 0 && pathRank.liquidity >= dstAmount_ && fullLiquidityPath.empty()) - { - // We found an extra path that can move the whole amount. - fullLiquidityPath = (startsWithIssuer ? removeIssuer(path) : path); - JLOG(j_.debug()) << "Found extra full path: " - << fullLiquidityPath.getJson(JsonOptions::Values::None); - } - else - { - JLOG(j_.debug()) << "Skipping a non-filling path: " - << path.getJson(JsonOptions::Values::None); - } + remaining -= pathRank.liquidity; + bestPaths.pushBack(startsWithIssuer ? removeIssuer(path) : path); } if (remaining > beast::kZero) { - XRPL_ASSERT( - fullLiquidityPath.empty(), "xrpl::Pathfinder::getBestPaths : second empty path result"); JLOG(j_.info()) << "Paths could not send " << remaining << " of " << dstAmount_; } else @@ -786,11 +805,15 @@ Pathfinder::getPathsOut( asset.visit( [&](Issue const&) { - if (auto const lines = rLCache_->getRippleLines(account, direction)) + // Shared full line vector + inline filters (no per-hop allocation). + if (auto const lines = rLCache_->getRippleLines(account)) { + auto const wantCurrency = pathAsset.get(); for (auto const& rspEntry : *lines) { - if (pathAsset.get() != rspEntry.getLimit().get().currency) + if (direction == LineDirection::Incoming && rspEntry.getNoRipple()) + continue; + if (wantCurrency != rspEntry.getCurrency()) continue; if (rspEntry.getBalance() <= beast::kZero && (!rspEntry.getLimitPeer() || @@ -1059,8 +1082,8 @@ Pathfinder::addLink( auto const correctAsset = [&]() { if constexpr (kIsLine) { - return uEndPathAsset.get() == - asset.getLimit().template get().currency; + // Inline currency match against the shared full vector. + return uEndPathAsset.get() == asset.getCurrency(); } if constexpr (kIsMpt) { @@ -1136,18 +1159,14 @@ Pathfinder::addLink( uEndPathAsset.visit( [&](Currency const&) { - if (auto const lines = rLCache_->getRippleLines( - uEndAccount, - bIsNoRippleOut ? LineDirection::Incoming : LineDirection::Outgoing)) - { + // Shared full Outgoing vector; currency + Incoming (noRipple) + // filters run inline in forAssets/checkAsset — no hop allocs. + if (auto const lines = rLCache_->getRippleLines(uEndAccount)) forAssets(*lines); - } }, [&](MPTID const&) { if (auto const mpts = rLCache_->getMPTs(uEndAccount)) - { forAssets(*mpts); - } }); if (!candidates.empty()) @@ -1355,12 +1374,17 @@ fillPaths(Pathfinder::PaymentType type, PathCostList const& costs) } // namespace -// Costs: +// Search costs (searchLevel): lower is tried first / at lower path_search levels. // 0 = minimum to make some payments possible // 1 = include trivial paths to make common cases work // 4 = normal fast search level // 7 = normal slow search level // 10 = most aggressive +// +// NonXrpToXrp: moderate-cost tiers include account→book→XRP and two-account +// hops; an extra book hop sits at a higher search level. Costs are search +// tiers, not liquidity ranks — final ordering still uses RippleCalc +// quality/liquidity. void Pathfinder::initPathTable() @@ -1383,11 +1407,12 @@ Pathfinder::initPathTable() fillPaths( PaymentType::NonXrpToXrp, - {{.cost = 1, .path = "sxd"}, // gateway buys XRP - {.cost = 2, .path = "saxd"}, // source -> gateway -> book(XRP) -> dest - {.cost = 6, .path = "saaxd"}, - {.cost = 7, .path = "sbxd"}, - {.cost = 8, .path = "sabxd"}, + {{.cost = 1, .path = "sxd"}, // gateway buys XRP + {.cost = 2, .path = "saxd"}, // source -> gateway -> book(XRP) -> dest + {.cost = 6, .path = "sabxd"}, // source -> account -> book(XRP) -> dest + {.cost = 6, .path = "saaxd"}, // source -> account -> account -> book(XRP) + {.cost = 7, .path = "sbxd"}, // source -> book -> XRP dest + {.cost = 8, .path = "sabbxd"}, // source -> account -> book -> book -> book(XRP) {.cost = 9, .path = "sabaxd"}}); // non-XRP to non-XRP (same currency) diff --git a/src/xrpld/rpc/detail/Pathfinder.h b/src/xrpld/rpc/detail/Pathfinder.h index aeacd218d29..9eaa879f311 100644 --- a/src/xrpld/rpc/detail/Pathfinder.h +++ b/src/xrpld/rpc/detail/Pathfinder.h @@ -66,15 +66,16 @@ class Pathfinder : public CountedObject void computePathRanks(int maxPaths, std::function const& continueCallback = {}); - /* Get the best paths, up to maxPaths in number, from completePaths_. - - On return, if fullLiquidityPath is not empty, then it contains the best - additional single path which can consume all the liquidity. - */ + /** + * Get the best paths, up to maxPaths in number, from completePaths_. + * + * Paths are filled by quality/liquidity only. There is no reserved + * full-liquidity covering/spare path (see API-CHANGELOG covering-path + * note). Callers may inject previously found paths via extraPaths. + */ STPathSet getBestPaths( int maxPaths, - STPath& fullLiquidityPath, STPathSet const& extraPaths, AccountID const& srcIssuer, std::function const& continueCallback = {}); diff --git a/src/xrpld/rpc/detail/TransactionSign.cpp b/src/xrpld/rpc/detail/TransactionSign.cpp index 9c97577b27f..7acc8734df6 100644 --- a/src/xrpld/rpc/detail/TransactionSign.cpp +++ b/src/xrpld/rpc/detail/TransactionSign.cpp @@ -302,8 +302,24 @@ checkPayment( if (auto ledger = app.getOpenLedger().current()) { + // One-shot build_path must not use WS progressive defaults: a + // bare AssetCache only loads kPathFindLineChunkSize (64) lines + // per account and never expands, so payments involving larger + // accounts silently miss routes. Mirror doLegacyPathRequest / + // PathRequest::doUpdate (hasCompletion): full config limits + + // LoadScope up to max lines per account for the first load. + auto const& cfg = app.config(); + auto cache = std::make_shared( + ledger, + app.getJournal("AssetCache"), + cfg.pathFindMaxTotalLines, + cfg.pathFindMaxLinesPerAccount, + cfg.pathCacheReuseLedgers, + cfg.pathFindLineChunkSize); + AssetCache::LoadScope const fullAccountLines{cfg.pathFindMaxLinesPerAccount}; + Pathfinder pf( - std::make_shared(ledger, app.getJournal("AssetCache")), + cache, srcAddressID, *dstAccountID, sendMax.asset(), @@ -312,13 +328,13 @@ checkPayment( std::nullopt, domain, app); - if (pf.findPaths(app.config().pathSearchOld)) + if (pf.findPaths(cfg.pathSearchOld)) { - // 4 is the maximum paths + // submit build_path intentionally keeps the historical cap + // of 4 paths (not kPathFindMaxPaths / path_find's six). pf.computePathRanks(4); - STPath fullLiquidityPath; STPathSet const paths; - result = pf.getBestPaths(4, fullLiquidityPath, paths, sendMax.getIssuer()); + result = pf.getBestPaths(4, paths, sendMax.getIssuer()); } } diff --git a/src/xrpld/rpc/detail/TrustLine.cpp b/src/xrpld/rpc/detail/TrustLine.cpp index 77a2b36d566..0dd7cd02cf0 100644 --- a/src/xrpld/rpc/detail/TrustLine.cpp +++ b/src/xrpld/rpc/detail/TrustLine.cpp @@ -4,11 +4,13 @@ #include #include #include +#include #include #include #include #include +#include #include #include @@ -43,6 +45,109 @@ PathFindTrustLine::makeItem(AccountID const& accountID, SLE::const_ref sle) return std::optional{PathFindTrustLine{sle, accountID}}; } +PathFindTrustLine::ChunkResult +PathFindTrustLine::getItemsChunk( + AccountID const& accountID, + ReadView const& view, + LineDirection direction, + DirCursor const& start, + std::size_t maxLines) +{ + ChunkResult result; + result.cursor = start; + + if (start.complete || maxLines == 0) + return result; + + // Walk the owner directory so we can abort once maxLines is hit and resume + // from DirCursor on a later expand. forEachItem cannot early-exit. + auto const root = keylet::ownerDir(accountID); + if (root.type != ltDIR_NODE) + { + result.cursor = {}; + result.cursor.complete = true; + return result; + } + + // page 0 = root owner-dir page; otherwise keylet::page(root, page). + std::uint64_t currentPage = start.page; + std::size_t indexInPage = start.indexInPage; + + while (result.lines.size() < maxLines) + { + auto const pos = currentPage == 0 ? root : keylet::page(root, currentPage); + auto sle = view.read(pos); + if (!sle) + { + result.cursor = {}; + result.cursor.complete = true; + break; + } + + auto const& indexes = sle->getFieldV256(sfIndexes); + bool hitCap = false; + while (indexInPage < indexes.size()) + { + if (result.lines.size() >= maxLines) + { + hitCap = true; + break; + } + + auto const& key = indexes[indexInPage]; + ++indexInPage; + + auto const sleCur = view.read(keylet::child(key)); + if (!sleCur || sleCur->getType() != ltRIPPLE_STATE) + continue; + + auto ret = makeItem(accountID, sleCur); + if (!ret) + continue; + if (direction == LineDirection::Incoming && ret->getNoRipple()) + continue; + + result.lines.push_back(std::move(*ret)); + } + + if (hitCap || result.lines.size() >= maxLines) + { + result.cursor.page = currentPage; + result.cursor.indexInPage = indexInPage; + result.cursor.complete = false; + break; + } + + auto const next = sle->getFieldU64(sfIndexNext); + if (next == 0u) + { + result.cursor = {}; + result.cursor.complete = true; + break; + } + + currentPage = next; + indexInPage = 0; + } + + result.lines.shrink_to_fit(); + return result; +} + +std::vector +PathFindTrustLine::getItems( + AccountID const& accountID, + ReadView const& view, + LineDirection direction, + std::size_t maxLines) +{ + // Compatibility wrapper: single walk (optionally capped). + DirCursor cursor; + auto const want = maxLines == 0 ? std::numeric_limits::max() : maxLines; + auto chunk = getItemsChunk(accountID, view, direction, cursor, want); + return std::move(chunk.lines); +} + namespace detail { template std::vector @@ -57,23 +162,13 @@ getTrustLineItems( if (ret && (direction == LineDirection::Outgoing || !ret->getNoRipple())) items.push_back(std::move(*ret)); }); - // This list may be around for a while, so free up any unneeded - // capacity + // This list may be around for a while, so free up any unneeded capacity items.shrink_to_fit(); return items; } } // namespace detail -std::vector -PathFindTrustLine::getItems( - AccountID const& accountID, - ReadView const& view, - LineDirection direction) -{ - return detail::getTrustLineItems(accountID, view, direction); -} - RPCTrustLine::RPCTrustLine(SLE::const_ref sle, AccountID const& viewAccount) : TrustLineBase(sle, viewAccount) , lowQualityIn_(sle->getFieldU32(sfLowQualityIn)) diff --git a/src/xrpld/rpc/detail/TrustLine.h b/src/xrpld/rpc/detail/TrustLine.h index c3f76784baa..19a71b7500d 100644 --- a/src/xrpld/rpc/detail/TrustLine.h +++ b/src/xrpld/rpc/detail/TrustLine.h @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -167,6 +168,15 @@ class TrustLineBase return !viewLowest_ ? lowLimit_ : highLimit_; } + /** + * Currency of this trust line (from our limit amount). + */ + [[nodiscard]] Currency + getCurrency() const + { + return getLimit().get().currency; + } + json::Value getJson(int); @@ -194,8 +204,54 @@ class PathFindTrustLine final : public TrustLineBase, public CountedObject makeItem(AccountID const& accountID, SLE::const_ref sle); + /** + * Resume point for a chunked owner-directory trust-line scan. + * page == 0 means the root owner-dir page; otherwise keylet::page(root, page). + * indexInPage is the next sfIndexes entry to consider on that page. + */ + struct DirCursor + { + std::uint64_t page{0}; + std::size_t indexInPage{0}; + bool complete{false}; + }; + + /** + * Result of one chunked owner-dir scan step. + */ + struct ChunkResult + { + std::vector lines; + DirCursor cursor; + }; + + /** + * Load up to maxLines outgoing/incoming trust lines, resuming from cursor. + * When maxLines is 0, returns immediately with the same cursor (no work). + * On completion of the owner directory, cursor.complete is true. + * + * Callers (Pathfinder) apply currency filters inline on the shared vector; + * this loader does not filter by currency. + */ + static ChunkResult + getItemsChunk( + AccountID const& accountID, + ReadView const& view, + LineDirection direction, + DirCursor const& cursor, + std::size_t maxLines); + + /** + * Load trust lines for an account (optionally capped). + * + * @param maxLines If non-zero, stop after this many matching lines (budget). + */ static std::vector - getItems(AccountID const& accountID, ReadView const& view, LineDirection direction); + getItems( + AccountID const& accountID, + ReadView const& view, + LineDirection direction, + std::size_t maxLines = 0); }; // This wrapper is used for the `AccountLines` command and includes the quality diff --git a/src/xrpld/rpc/detail/Tuning.h b/src/xrpld/rpc/detail/Tuning.h index 5c47ab43655..8bf2036c2f4 100644 --- a/src/xrpld/rpc/detail/Tuning.h +++ b/src/xrpld/rpc/detail/Tuning.h @@ -1,6 +1,9 @@ #pragma once +#include + #include +#include /** * Tuned constants. @@ -88,15 +91,153 @@ pageLength(bool isBinary) return isBinary ? kBinaryPageLength : kJsonPageLength; } +/** + * Max paths returned in a path_find / Pathfinder best-path set. + * Clients receive up to this many alternatives per source asset (no reserved + * full-liquidity spare slots). + */ +static constexpr int kPathFindMaxPaths = 6; + /** * Maximum number of source currencies allowed in a path find request. */ static constexpr int kMaxSrcCur = 18; /** - * Maximum number of auto source currencies in a path find request. + * Maximum number of auto source currencies in a path find request + * (ripple_path_find / explicit legacy). Large for one-shot API completeness. */ static constexpr int kMaxAutoSrcCur = 88; +/** + * Auto source-currency cap for WS path_find subscriptions when the client + * omits source_currencies. Keeps first/full Pathfinder waves bounded under + * concurrent sessions (each currency = full graph search + ranking). + */ +static constexpr int kMaxAutoSrcCurSub = 16; + +/** + * Auto source-currency cap for WS path_find while the server is locally + * loaded. Does not apply to one-shot ripple_path_find (hard cap only). + */ +static constexpr int kMaxAutoSrcCurLoaded = 12; + +/** + * Soft cap on total PathFindTrustLine objects retained in AssetCache. + * Bounds memory under concurrent path_find sessions. When the budget is + * exhausted, new chunks are not admitted (no silent floor). Incomplete + * accounts can grow later as budget frees or on subsequent expand passes. + * + * Default for Config::pathFindMaxTotalLines ([path_find] max_total_lines). + */ +static constexpr std::size_t kPathFindMaxTotalLines = 1'000'000; + +/** + * Soft cap on trust lines loaded for a single account (full outgoing set). + * Default for Config::pathFindMaxLinesPerAccount ([path_find] max_lines_per_account). + */ +static constexpr std::size_t kPathFindMaxLinesPerAccount = 50'000; + +/** + * Trust lines loaded per account per load/expand step for WebSocket path_find. + * Owner-dir walks are resumable so large accounts fill over successive updates + * instead of one spike. One-shot callers (ripple_path_find, transactionSign + * build_path) use AssetCache::LoadScope with kPathFindMaxLinesPerAccount to + * load the full set in a single request. + * + * Default for Config::pathFindLineChunkSize ([path_find] line_chunk_size). + * Config range: 1–1024. + */ +static constexpr std::size_t kPathFindLineChunkSize = 64; + +/** + * Reuse cached account trust-line vectors across this many ledger advances + * without reloading. Pathfinding is best-effort; slightly stale lines are OK. + * Large ledger jumps still force a full clear. + * + * Default for Config::pathCacheReuseLedgers ([path_find] cache_reuse_ledgers). + * Config range: 0–64. + */ +static constexpr std::uint32_t kPathCacheReuseLedgers = 6; + +/** + * Base interval (in ledger closes) between full Pathfinder rediscoveries for an + * open path_find subscription. Between rediscoveries, updates only re-run + * rippleCalculate on the previously discovered path set (much cheaper). + * + * Actual due ledger is staggered per session: + * lastFull + interval + (requestId % interval) + * so concurrent sessions do not all Pathfinder on the same close. + * Timed rediscovery is also skipped while the server is locally loaded. + * + * Default for Config::pathFullSearchInterval ([path_find] full_search_interval). + * Config range: 1–100. + */ +static constexpr std::uint32_t kPathFullSearchInterval = 3; + +/** + * Requested concurrent revalidates for established path_find sessions (not + * first update). Dispatched as JtPathFindWork jobs (JobTypes limit = + * kPathFindWorkLimit; must stay equal to that constant). + * + * Effective fan-out in PathRequestManager::runParallel is lower: + * - serial when JobQueue::getWorkerCount() < 3 (stand-alone / workers=1–2) + * - otherwise min(this, workers - 1) units per batch (1 inline + ≤workers-2 + * siblings), so a concurrent waveMutex_ waiter cannot starve the barrier + * - uses the live pool size, not a Config re-estimate (defaults are often ≥3) + * + * Revalidate is mostly independent per request; AssetCache uses a shared_mutex + * so hits/filters do not fully serialize workers. + * + * Sized for ~100 sessions finishing a pure-revalidate wave in well under 1s + * (target mean path_find update gap ≤2s with periodic mid-close ticks). + */ +static constexpr int kPathSteadyUpdateParallelism = xrpl::kPathFindWorkLimit; + +/** + * Period for open-ledger revalidate-only waves while any path_find session is + * live. Armed on first session and re-armed after each tick so mean update gap + * is not hard-bound to the ~4–5s ledger close interval. + * + * Each tick is revalidate-only (rippleCalculate on known paths) — never + * Pathfinder — and is dispatched on JtRpc (not JtUpdatePf) so closed-ledger + * Pathfinder / first-update waves cannot block the cadence. + * + * 500ms targets mean gap ≤1s at 100 sessions when each pure-revalidate wave + * finishes in well under the period. + * + * Default for Config::pathMidCloseDelay ([path_find] mid_close_ms). + */ +static constexpr std::chrono::milliseconds kPathMidCloseDelay{500}; + +/** + * After a failed full Pathfinder search, wait this many ledger closes before + * trying another full search for that session. Prevents unroutable sessions + * from full-searching every close (the expensive case the cache/revalidate + * work targets). Revalidate still runs each update; only Pathfinder is gated. + */ +static constexpr std::uint32_t kPathFailedSearchInterval = 5; + +/** + * Max complete paths to liquidity-rank per Pathfinder invocation. + * Ranking is 1–2 RippleCalc per path; completePaths_ can hold up to 1000. + * When more candidates exist, rankPaths pre-orders by path length (cheap) so + * truncation is not pure insertion order from completePaths_. + */ +static constexpr int kPathRankMaxCandidates = 200; + +/** + * Tighter ranking cap when the server is locally loaded. + */ +static constexpr int kPathRankMaxCandidatesLoaded = 80; + +/** + * Max new trust-line rows admitted in one expandIncompleteLines() pass + * (closed/create waves). Multiple 64-line chunks may run per account while + * this budget remains so multi-session hubs fill within a few closes without + * a one-shot full-account drain under unique_lock. + */ +static constexpr std::size_t kPathExpandLinesPerWave = 2048; + } // namespace xrpl::rpc::tuning /** @} */ diff --git a/src/xrpld/rpc/handlers/admin/status/GetCounts.cpp b/src/xrpld/rpc/handlers/admin/status/GetCounts.cpp index 421f23d2374..e6714e9262c 100644 --- a/src/xrpld/rpc/handlers/admin/status/GetCounts.cpp +++ b/src/xrpld/rpc/handlers/admin/status/GetCounts.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -13,6 +14,7 @@ #include #include +#include #include namespace xrpl { @@ -104,6 +106,29 @@ getCountsJson(Application& app, int minObjectCount) app.getNodeStore().getCountsJson(ret); + // Pathfinding AssetCache stats (shared across continuous path_find). + // Use double so u64 counters are not truncated by json::UInt (32-bit). + // When idle the cache is released: report zeros so charts reclaim and + // operators can see memory drop after the last WS path_find closes. + auto setU64 = [](json::Value& obj, char const* key, std::uint64_t v) { + // double preserves integers exactly through 2^53; pathfind counters stay well below that. + obj[key] = static_cast(v); + }; + auto const cacheStats = app.getPathRequestManager().getCacheStats(); + auto const hits = cacheStats.available ? cacheStats.hits : 0; + auto const misses = cacheStats.available ? cacheStats.misses : 0; + auto const loaded = cacheStats.available ? cacheStats.linesLoaded : 0; + auto const advances = cacheStats.available ? cacheStats.ledgerAdvances : 0; + auto const lines = cacheStats.available ? static_cast(cacheStats.totalLines) : 0; + setU64(ret, "pathfind_cache_hits", hits); + setU64(ret, "pathfind_cache_misses", misses); + setU64(ret, "pathfind_lines_loaded", loaded); + // Correct name: counts advanceLedger calls (soft or force), not only rebuilds. + setU64(ret, "pathfind_cache_advances", advances); + // Alias for existing load-test / chart keys. + setU64(ret, "pathfind_cache_rebuilds", advances); + setU64(ret, "pathfind_cache_lines", lines); + return ret; } diff --git a/tools/pathfind-loadtest/README.md b/tools/pathfind-loadtest/README.md new file mode 100644 index 00000000000..5c173f99e0f --- /dev/null +++ b/tools/pathfind-loadtest/README.md @@ -0,0 +1,58 @@ +# path_find load harness + +Reproduces the concurrent `path_find` load numbers cited in +[PR #7962](https://github.com/XRPLF/rippled/pull/7962): mean update gap near one +ledger close (~4s) with ~100 WebSocket sessions while consensus stays +`FULL` / `load_factor` ≈ 1. + +This tree keeps **in-process unit coverage** for the new machinery +(`xrpl.rpc.AssetCache`, `xrpl.rpc.PathFindSub`). Full multi-connection load +testing needs a live node and a client pool, so the harness lives in a small +companion repo and is linked here for review / CI reproducibility. + +## Companion harness + +| | | +| -------------- | ----------------------------------------------------------------------------------------------------------------- | +| **Repository** | https://github.com/shortthefomo/test-pathfind | +| **Modes** | CLI + Vue dashboard (burst or ramp) | +| **Metrics** | create latency, update-gap time series, `server_info` / `get_counts` (pathfind cache counters), consensus verdict | + +### Quick start + +```bash +git clone https://github.com/shortthefomo/test-pathfind.git +cd test-pathfind +npm install +npm run discover # cache wallets with funded trust lines → data/wallets.json +npm run cli -- --skipDiscover --mode=ramp --max=100 --observeSec=60 \ + --endpoint=ws://127.0.0.1:6006 +``` + +Dashboard: + +```bash +npm run dev # http://localhost:5173 +``` + +Point `--endpoint` at a standalone / pathfinding-capable `xrpld` with admin +RPC if you want `pathfind_cache_*` series from `get_counts`. + +### What to look for + +- **Consensus**: `server_state` stays `FULL` / `PROPOSING` for the whole hold +- **Update gap**: mean session update interval ≈ ledger close (≤ ~4–5s) at max concurrency +- **Cache**: `pathfind_cache_hits` grows; `pathfind_cache_lines` stable (not thrashing each close) +- **Ramp-down**: lines reclaim toward 0 after the last session closes + +### CI note + +Unit tests in this PR (`AssetCache`, `PathFindSub`) run in the normal +`xrpld --unittest=…` matrix, including **thread-sanitizer** builds for the +multi-threaded `AssetCache` stress case. + +The external harness is intentionally **not** a required GitHub Actions job: +it needs a long-lived funded network (or a heavy local stand-alone with a +wallet cache) and is meant for pre-merge perf validation and operator +benchmarks. Treat a green local CLI run as the evidence pack for the PR +numbers; attach result JSON from `data/results/` when updating the PR.