#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace xrpl::test { /** * An acquisition that exposes the entry points its bases keep protected, so a * case can reach them without the daemon's API growing. */ struct TestableInboundLedger final : InboundLedger { using InboundLedger::InboundLedger; /** * Look for the ledger locally, ask the peers being tracked for the * rest, and arm the timer, as InboundLedgers::acquire() does. * * That caller holds its collection lock across init(), which releases * it, so this stands in with a lock of its own. Declared before the * lock, so it outlives it. */ void startAcquire() { std::recursive_mutex collectionMutex; ScopedLockType collectionLock(collectionMutex); init(collectionLock); } /** * Ask for more nodes, or judge what has been collected, as a fresh * acquisition does. */ void triggerAdded() { trigger(nullptr, TriggerReason::Added); } /** * The same, as the timer chain does. */ void triggerTimeout() { trigger(nullptr, TriggerReason::Timeout); } /** * Record how many timeouts have elapsed. * * @param timeouts The count to record. */ void setTimeouts(int timeouts) { ScopedLockType const sl(mtx_); timeouts_ = timeouts; } /** * Forget any recorded progress. */ void clearProgress() { ScopedLockType const sl(mtx_); progress_ = false; } /** * Record that nothing is left to fetch. */ void markComplete() { ScopedLockType const sl(mtx_); complete_ = true; } /** * Settle the acquisition and signal whatever is waiting on it. */ void signalDone() { ScopedLockType const sl(mtx_); done(); } }; /** * The ledger an acquisition is assembling, as a pointer that can modify it. * * @param acquire The acquisition to read from. * @return The ledger, or nullptr if there is none to report. */ [[nodiscard]] static std::shared_ptr mutableLedger(InboundLedger const& acquire) { // Sound because the acquisition holds a non-const ledger and only hands out a const view. return std::const_pointer_cast(acquire.getLedger()); } struct InboundLedger_test : public beast::unit_test::Suite { /** * A retry interval short enough that a whole timeout chain costs a fraction * of a second. TimeoutCounter refuses anything at or below 10ms. */ static constexpr auto kFastRetry = std::chrono::milliseconds{20}; /** * A seed no other chain in this suite has used. * * The Env below is shared, and its node store, fetch packs and remembered * failures are all keyed by hash, so two cases building identically seeded * chains would let one resolve or judge the other's. Handing out a fresh * seed per chain makes that impossible rather than merely unlikely. * * @return The seed. */ [[nodiscard]] unsigned int nextSeed() { return ++seed_; } /** * A ledger header naming the given map roots. * * The hash is derived from the fields, so an acquisition accepts the * header as its own however the roots are chosen. * * @param txHash The transaction map root; zero means no transactions. * @param accountHash The state map root; zero is a ledger no * acquisition can finish. * @return The header, with its hash filled in. */ static LedgerHeader makeHeader(uint256 const& txHash, uint256 const& accountHash) { LedgerHeader header; header.seq = 2; header.parentCloseTime = NetClock::time_point{}; header.closeTime = NetClock::time_point{}; header.closeTimeResolution = NetClock::duration{10}; header.closeFlags = 0; header.txHash = txHash; header.accountHash = accountHash; header.hash = calculateLedgerHash(header); return header; } /** * The common shape: no transactions, so only the state map is in play. * * @param chain The chain whose root to name as the state hash. * @return The header, with its hash filled in. */ static LedgerHeader makeHeader(DeepChain const& chain) { return makeHeader(uint256{}, chain.rootHash.asUInt256()); } /** * Put the header in the local store, which is the first place tryDB() * looks. * * Unlike a fetch pack, which hands each entry out once, the store * keeps it, so more than one acquisition of the same ledger can find * it. * * @param env The environment whose node store to seed. * @param header The header to store, keyed by its own hash. */ static void storeHeader(jtx::Env& env, LedgerHeader const& header) { Serializer s; s.add32(HashPrefix::LedgerMaster); addRaw(header, s); env.app().getNodeFamily().db().store( NodeObjectType::Ledger, std::move(s.modData()), header.hash, header.seq); } /** * Put every node of a chain in the local store, so a state-map walk * resolves the whole map without a peer. * * @param env The environment whose node store to seed. * @param header The header whose sequence the nodes are stored under. * @param chain The chain supplying the nodes. * @param maxDepth The deepest node to store, so a caller can leave a walk * something to ask for. */ static void storeStateNodes( jtx::Env& env, LedgerHeader const& header, DeepChain const& chain, unsigned int maxDepth) { auto& db = env.app().getNodeFamily().db(); for (auto depth = 0u; depth <= maxDepth; ++depth) { db.store( NodeObjectType::AccountNode, chain.prefixedNodeAt(depth), chain.nodeAt(depth)->getHash().asUInt256(), header.seq); } } /** * A ledger whose maps all resolve locally finishes on the spot, and the * finished ledger is immutable and handed on. * * The case where tryDB() alone completes the acquisition, so it covers * tryDB() reporting a ledger it found and done() taking its success arm * on that path. Both entry points are driven: InboundLedgers::acquire() * is the only caller of init(), and hands back the finished ledger * itself, while checkLocal() is the route that reaches done(). * * @param env The environment to run in. */ void testLocalLedgerCompletesAcquire(jtx::Env& env) { testcase("A ledger found locally completes the acquire"); // A chain ending in a real leaf, so the state map is genuinely complete rather than merely // rooted. No transactions, so only the state map is in play. auto const chain = DeepChain::toLeaf(2, nextSeed()); auto const header = makeHeader(chain); storeHeader(env, header); storeStateNodes(env, header, chain, chain.deepestDepth); // acquire() runs init() under its own collection lock and returns the ledger only once the // acquisition is complete and unfailed, so a non-null result is what shows tryDB() found it // without a peer ever being asked. auto const acquired = env.app().getInboundLedgers().acquire( header.hash, header.seq, InboundLedger::Reason::GENERIC); BEAST_EXPECT(acquired != nullptr); if (acquired) { BEAST_EXPECT(acquired->isImmutable()); BEAST_EXPECT(acquired->header().hash == header.hash); } // init() hands a ledger it completed to LedgerMaster itself, which is what makes it // available to everything else. BEAST_EXPECT(env.app().getLedgerMaster().getLedgerByHash(header.hash) != nullptr); // Nothing was logged as a failure, which is the other arm of done(). BEAST_EXPECT(!env.app().getInboundLedgers().isFailure(header.hash)); // The same ledger through checkLocal(), which unlike init() reaches done(). Everything it // needs is still in the store, since the first acquisition read rather than consumed it. auto again = std::make_shared( env.app(), header.hash, header.seq, InboundLedger::Reason::GENERIC, stopwatch(), std::make_unique()); // True because the acquisition ended, which here means it succeeded, and it reports that // only after done() has run. BEAST_EXPECT(again->checkLocal()); BEAST_EXPECT(again->isComplete()); BEAST_EXPECT(!again->isFailed()); auto const settled = again->getLedger(); BEAST_EXPECT(settled != nullptr); if (settled) BEAST_EXPECT(settled->isImmutable()); BEAST_EXPECT(!env.app().getInboundLedgers().isFailure(header.hash)); } /** * A ledger completed by a walk rather than by tryDB() is settled * before it is reported complete. * * The other order is what lets an unsettled ledger escape: isComplete() * is read without mtx_, so a second thread can act on it while done() * is still settling, and a mutable ledger reaching * LedgerHistory::insert() calls logicError(). The order itself is only * visible to a concurrent reader, so what this case pins is the * consequence - the acquisition never reports a ledger that is not * immutable - and that trigger() completes an acquisition without * itself setting the completion flag. * * @param env The environment to run in. */ void testWalkSettlesBeforeReportingComplete(jtx::Env& env) { testcase("A ledger completed by a walk is settled before it is reported"); auto const chain = DeepChain::toLeaf(2, nextSeed()); auto const header = makeHeader(chain); // Everything except the leaf, so tryDB() can root the state map but its walk still has // something to ask for. That is what leaves the completion to trigger(). storeHeader(env, header); storeStateNodes(env, header, chain, chain.deepestDepth - 1); auto acquire = std::make_shared( env.app(), header.hash, header.seq, InboundLedger::Reason::GENERIC, stopwatch(), std::make_unique()); BEAST_EXPECT(!acquire->checkLocal()); BEAST_EXPECT(!acquire->isComplete()); BEAST_EXPECT(!acquire->isFailed()); // Only now can the walk finish, so nothing but the walk can have completed this. storeStateNodes(env, header, chain, chain.deepestDepth); acquire->triggerAdded(); BEAST_EXPECT(acquire->isComplete()); BEAST_EXPECT(!acquire->isFailed()); auto const settled = acquire->getLedger(); BEAST_EXPECT(settled != nullptr); if (settled) BEAST_EXPECT(settled->isImmutable()); // done()'s success arm ran, so the ledger reached LedgerMaster and nothing was recorded as // a failure. BEAST_EXPECT(env.app().getLedgerMaster().getLedgerByHash(header.hash) != nullptr); BEAST_EXPECT(!env.app().getInboundLedgers().isFailure(header.hash)); } /** * A ledger whose map goes invalid on the way to being settled must be * discarded rather than delivered. * * done() settles the ledger before it logs or acts on the outcome, and a * map abandoned by then makes settling refuse, so the acquisition has to * record a failure instead. Reproduced by setting the flag and then * invalidating the map, which is the order a walk on another thread * produces without the second thread. * * @param env The environment to run in. */ void testInvalidatedLedgerFailsInDone(jtx::Env& env) { testcase("A ledger invalidated on its way to being settled fails"); // The fabricated chain, so feeding it to the state map invalidates the map. DeepChain const chain{nextSeed()}; // Only the header is local, so the acquisition holds a ledger with an empty state map. auto const header = makeHeader(chain); storeHeader(env, header); auto acquire = std::make_shared( env.app(), header.hash, header.seq, InboundLedger::Reason::GENERIC, stopwatch(), std::make_unique()); BEAST_EXPECT(!acquire->checkLocal()); BEAST_EXPECT(!acquire->isFailed()); BEAST_EXPECT(!acquire->isComplete()); auto const ledger = mutableLedger(*acquire); BEAST_EXPECT(ledger != nullptr); if (!ledger) return; // The state of affairs done() is handed: nothing left to fetch as far as the caller could // tell. acquire->markComplete(); // And the walk that has since reached the verdict. auto& stateMap = ledger->stateMap(); BEAST_EXPECT(stateMap.addRootNode(chain.rootHash, chain.nodeAt(0), nullptr).isGood()); for (auto const& [nodeID, node] : chain.nodesBelowRoot()) stateMap.addKnownNode(nodeID, node, nullptr); BEAST_EXPECT(!stateMap.isValid()); acquire->signalDone(); // complete_ is withdrawn alongside the failure, or every guard that checks it before // failed_ keeps treating this ledger as delivered. BEAST_EXPECT(!acquire->isComplete()); BEAST_EXPECT(acquire->isFailed()); // Nothing was handed to LedgerMaster, and the hash is remembered as a failure so it is not // immediately re-acquired. BEAST_EXPECT(env.app().getLedgerMaster().getLedgerByHash(header.hash) == nullptr); BEAST_EXPECT(waitFor([&] { return env.app().getInboundLedgers().isFailure(header.hash); })); } /** * An acquisition that fails on local data must still signal. * * Both entry points that reach tryDB() are covered, since without * done() the object never signals, logFailure() never runs, and the * hash never lands in recentFailures_ - so the same doomed ledger is * asked for again on the next round. recentFailures_ is what the * assertions watch, since it is the caller-visible consequence of * having signalled. * * @param env The environment to run in. */ void testLocalFailureSignalsDone(jtx::Env& env) { testcase("An acquisition that fails locally still signals"); // A zero account hash is a ledger no acquisition can ever finish, and tryDB() says so as // soon as it has the header. auto const header = makeHeader(uint256{}, uint256{}); storeHeader(env, header); BEAST_EXPECT(!env.app().getInboundLedgers().isFailure(header.hash)); // acquire() is the only caller of init(), and hands back nothing for a failed acquisition. BEAST_EXPECT( env.app().getInboundLedgers().acquire( header.hash, header.seq, InboundLedger::Reason::GENERIC) == nullptr); // The failure reached recentFailures_, which is what stops the next round asking again. BEAST_EXPECT(waitFor([&] { return env.app().getInboundLedgers().isFailure(header.hash); })); // The other route into tryDB(): a trigger() on an acquisition that has no header yet. A // hash of its own, so the entry above cannot answer for it. auto const otherHeader = makeHeader(uint256{1}, uint256{}); storeHeader(env, otherHeader); auto viaTrigger = std::make_shared( env.app(), otherHeader.hash, otherHeader.seq, InboundLedger::Reason::GENERIC, stopwatch(), std::make_unique()); BEAST_EXPECT(!env.app().getInboundLedgers().isFailure(otherHeader.hash)); viaTrigger->triggerAdded(); BEAST_EXPECT(viaTrigger->isFailed()); BEAST_EXPECT(!viaTrigger->isComplete()); BEAST_EXPECT( waitFor([&] { return env.app().getInboundLedgers().isFailure(otherHeader.hash); })); } /** * A ledger assembled from local data must be judged even when only * one map is settled. * * tryDB() walks both maps to see what is on hand, and a fetch pack is * checked against each node's own hash rather than the shape it * implies, so a whole chain can resolve locally without passing * through addKnownNode(). * * The asymmetry is the point: the transaction map is the chain, so * its walk abandons it, while the state root is a hash no fetch pack * supplies, leaving that map merely incomplete. tryDB() therefore * sets neither flag and has to reach the verdict itself, since the * setImmutable() call further down needs both. * * @param env The environment to run in. */ void testLocalChainFailsAcquire(jtx::Env& env) { testcase("A chain found locally fails the acquire"); DeepChain const chain{nextSeed()}; // The chain as the transaction root; an arbitrary hash, seeded nowhere, as the state root. auto const header = makeHeader(chain.rootHash.asUInt256(), uint256{99}); auto& ledgerMaster = env.app().getLedgerMaster(); // The header, prefixed the way tryDB() expects to find it in a fetch pack. Serializer hs; hs.add32(HashPrefix::LedgerMaster); addRaw(header, hs); ledgerMaster.addFetchPack(header.hash, std::make_shared(hs.modData())); // Every node of the chain, keyed by its own hash. TransactionStateSF::getNode() reads // these, so the transaction-map walk resolves the whole chain with no peer involved. for (auto depth = 0u; depth <= SHAMap::kLeafDepth; ++depth) { ledgerMaster.addFetchPack( chain.nodeAt(depth)->getHash().asUInt256(), std::make_shared(chain.prefixedNodeAt(depth))); } auto acquire = std::make_shared( env.app(), header.hash, header.seq, InboundLedger::Reason::GENERIC, stopwatch(), std::make_unique()); // checkLocal() routes into tryDB() without any peer data having arrived. It reports true // only because the acquisition ended, which is what this case is about. BEAST_EXPECT(acquire->checkLocal()); BEAST_EXPECT(acquire->isFailed()); BEAST_EXPECT(!acquire->isComplete()); } /** * The aggressive-retry branch of trigger() must judge a map the walk * abandoned. * * That branch reads an empty getNeededHashes() result as "nothing * left to fetch", and the walk it runs can reach the invalid verdict * itself once nodes resolve from local storage rather than from a * peer. * * The staging matters: tryDB() runs first and would shadow this guard * if it could resolve the whole chain, so only the root is local to * begin with - enough for the state map to hold a root, without which * neededHashes() reports the root as missing and never walks, but not * enough to reach the offending depth. Reaching the branch also needs * a timeout count above kLedgerBecomeAggressiveThreshold, which the * case records directly rather than waiting fifteen seconds for the * timer chain to raise it. * * @param env The environment to run in. */ void testAggressiveRetryJudgesLocalMap(jtx::Env& env) { testcase("An aggressive retry judges a map the walk abandoned"); DeepChain const chain{nextSeed()}; // The chain as the state root, and no transactions, so only the state map is in play. auto const header = makeHeader(chain); auto& ledgerMaster = env.app().getLedgerMaster(); Serializer hs; hs.add32(HashPrefix::LedgerMaster); addRaw(header, hs); ledgerMaster.addFetchPack(header.hash, std::make_shared(hs.modData())); // Only the root, so the state map gets a root but the walk stops one level down. ledgerMaster.addFetchPack( chain.nodeAt(0)->getHash().asUInt256(), std::make_shared(chain.prefixedNodeAt(0))); auto acquire = std::make_shared( env.app(), header.hash, header.seq, InboundLedger::Reason::GENERIC, stopwatch(), std::make_unique()); // The acquisition is alive: it has the header and a state root, and still wants the rest. BEAST_EXPECT(!acquire->checkLocal()); BEAST_EXPECT(!acquire->isFailed()); BEAST_EXPECT(acquire->getJson(0)[jss::have_header].asBool()); BEAST_EXPECT(!acquire->getJson(0)[jss::have_state].asBool()); auto const ledger = mutableLedger(*acquire); BEAST_EXPECT(ledger != nullptr); if (!ledger) return; BEAST_EXPECT(ledger->stateMap().isValid()); // Only now does the rest of the chain become resolvable, so tryDB() cannot have judged it. for (auto depth = 1u; depth <= SHAMap::kLeafDepth; ++depth) { ledgerMaster.addFetchPack( chain.nodeAt(depth)->getHash().asUInt256(), std::make_shared(chain.prefixedNodeAt(depth))); } // kLedgerBecomeAggressiveThreshold is 4 and file-local, so name the requirement here. acquire->setTimeouts(5); acquire->clearProgress(); acquire->triggerTimeout(); // The walk resolved the chain locally and abandoned the map, and trigger() recorded that // rather than reading the empty result as a finished acquisition. BEAST_EXPECT(!ledger->stateMap().isValid()); BEAST_EXPECT(acquire->isFailed()); BEAST_EXPECT(!acquire->isComplete()); // haveState_ is what pins this guard rather than the setImmutable() backstop in done(), // which also fails the acquire: without the guard the empty result reads as success, and // every have-flag is set on the way to that backstop. BEAST_EXPECT(!acquire->getJson(0)[jss::have_state].asBool()); // The same branch with no header yet, which is the other arm of hasInvalidMap(): there is // no map to judge, and reading that as a verdict would fail an acquisition that has only // just started. getNeededHashes() has asked for the header, so the non-empty branch is the // right one and the acquisition stays alive. auto headerless = std::make_shared( env.app(), uint256{7}, 0, InboundLedger::Reason::GENERIC, stopwatch(), std::make_unique()); headerless->setTimeouts(5); headerless->clearProgress(); headerless->triggerTimeout(); BEAST_EXPECT(mutableLedger(*headerless) == nullptr); BEAST_EXPECT(!headerless->isFailed()); BEAST_EXPECT(!headerless->isComplete()); } /** * The retry timer re-asks, then gives up and signals. * * The only case that drives onTimer() rather than trigger() directly, * which is what covers the give-up: past kLedgerTimeoutRetriesMax the * acquisition fails itself and done() records that, so the same * doomed ledger is not asked for again on the next round. It is also * what the retry interval is a constructor parameter for, since the * chain runs past kLedgerTimeoutRetriesMax ticks of three seconds * apiece in production. * * Nothing is local and no data ever arrives, so no tick can record progress * and the count only climbs. A hash of its own, so no other case can have * remembered it as a failure already. * * @param env The environment to run in. */ void testTimerRetriesThenGivesUp(jtx::Env& env) { testcase("The retry timer re-asks, then gives up"); uint256 const kUnknownLedger{8}; // One candidate, which onTimer() re-offers on every tick. auto const candidate = std::make_shared(); auto peerSet = std::make_unique(std::vector>{candidate}); auto* const peerSetPtr = peerSet.get(); auto acquire = std::make_shared( env.app(), kUnknownLedger, 0, InboundLedger::Reason::GENERIC, stopwatch(), std::move(peerSet), kFastRetry); BEAST_EXPECT(!env.app().getInboundLedgers().isFailure(kUnknownLedger)); // init() finds nothing locally, so it asks the candidate and queues the first check-in, // which is what arms the retry timer for every cycle after. Those first requests are not // the ones under test, so count from here. acquire->startAcquire(); BEAST_EXPECT(!acquire->isFailed()); int const requestsFromInit = peerSetPtr->requests(); BEAST_EXPECT(requestsFromInit > 0); BEAST_EXPECT(peerSetPtr->addedPeers() == std::set{candidate->id()}); // Every tick asks again, and past kLedgerTimeoutRetriesMax (6) the chain gives up. BEAST_EXPECT(waitFor([&] { return acquire->isFailed(); })); BEAST_EXPECT(!acquire->isComplete()); BEAST_EXPECT(peerSetPtr->requests() > requestsFromInit); // done() remembered the hash, which is what stops the next round asking again. BEAST_EXPECT( waitFor([&] { return env.app().getInboundLedgers().isFailure(kUnknownLedger); })); } void run() override { // One Env for the suite, since building one costs far more than any case here. Safe // because every chain is seeded through nextSeed(): the node store, the fetch packs and // the remembered failures are all shared, and all three are keyed by hash. jtx::Env env{*this}; testLocalLedgerCompletesAcquire(env); testWalkSettlesBeforeReportingComplete(env); testInvalidatedLedgerFailsInDone(env); testLocalFailureSignalsDone(env); testLocalChainFailsAcquire(env); testAggressiveRetryJudgesLocalMap(env); // Last: the only case that waits out a whole timeout chain. testTimerRetriesThenGivesUp(env); } private: unsigned int seed_{0}; }; BEAST_DEFINE_TESTSUITE(InboundLedger, app, xrpl); } // namespace xrpl::test