From 5bf1c32008173db2080286b2690f4951299d1619 Mon Sep 17 00:00:00 2001 From: Andrew Toth Date: Tue, 14 Apr 2026 13:45:53 -0400 Subject: [PATCH 01/10] validation: add -prevoutfetchthreads configuration option Add a configuration option for the number of worker threads used for parallel UTXO prevout prefetching during block connection. Default is 8 threads, max is 16, 0 disables parallel fetching. --- doc/reduce-memory.md | 1 + src/init.cpp | 1 + src/kernel/chainstatemanager_opts.h | 3 +++ src/node/chainstatemanager_args.cpp | 7 +++++++ src/test/validation_chainstatemanager_tests.cpp | 6 ++++++ src/validation.h | 3 +++ 6 files changed, 21 insertions(+) diff --git a/doc/reduce-memory.md b/doc/reduce-memory.md index 44d90bcf500..348d98bd6b4 100644 --- a/doc/reduce-memory.md +++ b/doc/reduce-memory.md @@ -49,6 +49,7 @@ threads take up 8MiB for the thread stack on a 64-bit system, and 4MiB in a - `-par=` - the number of script verification threads, defaults to the number of cores in the system minus one. - `-rpcthreads=` - the number of threads used for processing RPC requests, defaults to `16`. +- `-prevoutfetchthreads=` - the number of threads used to fetch block input prevouts, defaults to `8`. ## Linux specific diff --git a/src/init.cpp b/src/init.cpp index 0e72443cc33..f2346fde58e 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -538,6 +538,7 @@ void SetupServerArgs(ArgsManager& argsman, bool can_listen_ipc) argsman.AddArg("-minimumchainwork=", strprintf("Minimum work assumed to exist on a valid chain in hex (default: %s, testnet3: %s, testnet4: %s, signet: %s)", defaultChainParams->GetConsensus().nMinimumChainWork.GetHex(), testnetChainParams->GetConsensus().nMinimumChainWork.GetHex(), testnet4ChainParams->GetConsensus().nMinimumChainWork.GetHex(), signetChainParams->GetConsensus().nMinimumChainWork.GetHex()), ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::OPTIONS); argsman.AddArg("-par=", strprintf("Set the number of script verification threads (0 = auto, up to %d, <0 = leave that many cores free, default: %d)", MAX_SCRIPTCHECK_THREADS, DEFAULT_SCRIPTCHECK_THREADS), ArgsManager::ALLOW_ANY, OptionsCategory::OPTIONS); + argsman.AddArg("-prevoutfetchthreads=", strprintf("Set the number of threads used to prefetch block input prevouts from the chainstate database (0 disables, up to %d, default: %d). Negative values are rejected.", MAX_PREVOUTFETCH_THREADS, DEFAULT_PREVOUTFETCH_THREADS), ArgsManager::ALLOW_ANY, OptionsCategory::OPTIONS); argsman.AddArg("-persistmempool", strprintf("Whether to save the mempool on shutdown and load on restart (default: %u)", DEFAULT_PERSIST_MEMPOOL), ArgsManager::ALLOW_ANY, OptionsCategory::OPTIONS); argsman.AddArg("-persistmempoolv1", strprintf("Whether a mempool.dat file created by -persistmempool or the savemempool RPC will be written in the legacy format " diff --git a/src/kernel/chainstatemanager_opts.h b/src/kernel/chainstatemanager_opts.h index 134b93194bf..554d032eddc 100644 --- a/src/kernel/chainstatemanager_opts.h +++ b/src/kernel/chainstatemanager_opts.h @@ -22,6 +22,7 @@ class CChainParams; class ValidationSignals; static constexpr auto DEFAULT_MAX_TIP_AGE{24h}; +static constexpr int32_t DEFAULT_PREVOUTFETCH_THREADS{8}; namespace kernel { @@ -46,6 +47,8 @@ struct ChainstateManagerOpts { ValidationSignals* signals{nullptr}; //! Number of script check worker threads. Zero means no parallel verification. int worker_threads_num{0}; + //! Number of worker threads used for prefetching block input prevouts. Zero means no parallel fetching. + int32_t prevoutfetch_threads_num{DEFAULT_PREVOUTFETCH_THREADS}; size_t script_execution_cache_bytes{DEFAULT_SCRIPT_EXECUTION_CACHE_BYTES}; size_t signature_cache_bytes{DEFAULT_SIGNATURE_CACHE_BYTES}; }; diff --git a/src/node/chainstatemanager_args.cpp b/src/node/chainstatemanager_args.cpp index 46a12bf21ed..b44314908d8 100644 --- a/src/node/chainstatemanager_args.cpp +++ b/src/node/chainstatemanager_args.cpp @@ -60,6 +60,13 @@ util::Result ApplyArgsManOptions(const ArgsManager& args, ChainstateManage // Subtract 1 because the main thread counts towards the par threads. opts.worker_threads_num = script_threads - 1; + if (auto value{args.GetArg("-prevoutfetchthreads")}) { + if (*value < 0) { + return util::Error{Untranslated(strprintf("-prevoutfetchthreads must be non-negative (got %d). Use 0 to disable parallel input fetching.", *value))}; + } + opts.prevoutfetch_threads_num = std::min(*value, MAX_PREVOUTFETCH_THREADS); + } + if (auto max_size = args.GetIntArg("-maxsigcachesize")) { // 1. When supplied with a max_size of 0, both the signature cache and // script execution cache create the minimum possible cache (2 diff --git a/src/test/validation_chainstatemanager_tests.cpp b/src/test/validation_chainstatemanager_tests.cpp index 9818b51e51b..eae3fcdcbfb 100644 --- a/src/test/validation_chainstatemanager_tests.cpp +++ b/src/test/validation_chainstatemanager_tests.cpp @@ -991,6 +991,12 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager_args, BasicTestingSetup) BOOST_CHECK(!get_opts({"-minimumchainwork=xyz"})); // invalid hex characters BOOST_CHECK(!get_opts({"-minimumchainwork=01234567890123456789012345678901234567890123456789012345678901234"})); // > 64 hex chars + + BOOST_CHECK_EQUAL(get_valid_opts({}).prevoutfetch_threads_num, DEFAULT_PREVOUTFETCH_THREADS); + BOOST_CHECK_EQUAL(get_valid_opts({"-prevoutfetchthreads=0"}).prevoutfetch_threads_num, 0); + BOOST_CHECK_EQUAL(get_valid_opts({"-prevoutfetchthreads=3"}).prevoutfetch_threads_num, 3); + BOOST_CHECK_EQUAL(get_valid_opts({"-prevoutfetchthreads=100"}).prevoutfetch_threads_num, MAX_PREVOUTFETCH_THREADS); + BOOST_CHECK(!get_opts({"-prevoutfetchthreads=-1"})); } BOOST_AUTO_TEST_SUITE_END() diff --git a/src/validation.h b/src/validation.h index 4cb5dc631e9..2472dd60c34 100644 --- a/src/validation.h +++ b/src/validation.h @@ -89,6 +89,9 @@ static const uint64_t MIN_DISK_SPACE_FOR_BLOCK_FILES{550_MiB}; /** Maximum number of dedicated script-checking threads allowed */ static constexpr int MAX_SCRIPTCHECK_THREADS{15}; +/** Maximum number of dedicated threads allowed for prefetching block input prevouts */ +static constexpr int32_t MAX_PREVOUTFETCH_THREADS{16}; + /** Current sync state passed to tip changed callbacks. */ enum class SynchronizationState { INIT_REINDEX, From f82043af507a2f2caacdae1af6bcacddc8c4876b Mon Sep 17 00:00:00 2001 From: Andrew Toth Date: Sat, 7 Mar 2026 19:29:20 -0500 Subject: [PATCH 02/10] coins: introduce thread pool in CoinsViewOverlay Introduce a ThreadPool shared pointer to CoinsViewOverlay. A pool managed externally can be passed in the constructor. A global thread pool is used in fuzz harnesses since iterations can happen faster than the OS can create and tear down thread pools. This can cause a memory leak when fuzzing. Co-authored-by: l0rinc --- src/coins.h | 13 ++++++++++++- src/kernel/CMakeLists.txt | 2 ++ src/test/coinsviewoverlay_tests.cpp | 22 ++++++++++++++++------ src/test/fuzz/coins_view.cpp | 12 ++++++++++-- src/test/fuzz/coinscache_sim.cpp | 19 +++++++++++++++++-- src/validation.cpp | 12 +++++++++--- src/validation.h | 2 +- test/functional/feature_block.py | 3 +++ test/functional/feature_proxy.py | 3 +++ test/functional/test_framework/util.py | 2 ++ 10 files changed, 75 insertions(+), 15 deletions(-) diff --git a/src/coins.h b/src/coins.h index ae7f34f4658..69eee034b2e 100644 --- a/src/coins.h +++ b/src/coins.h @@ -22,8 +22,11 @@ #include #include +#include #include +class ThreadPool; + /** * A UTXO entry. * @@ -569,8 +572,16 @@ private: return base->PeekCoin(outpoint); } + //! Non-null. + std::shared_ptr m_thread_pool; + public: - using CCoinsViewCache::CCoinsViewCache; + explicit CoinsViewOverlay(CCoinsView* in_base, std::shared_ptr thread_pool, + bool deterministic = false) noexcept + : CCoinsViewCache{in_base, deterministic}, m_thread_pool{std::move(thread_pool)} + { + Assert(m_thread_pool); + } }; //! Utility function to add all of a transaction's outputs to a cache. diff --git a/src/kernel/CMakeLists.txt b/src/kernel/CMakeLists.txt index 541f10b3adc..d2a467f6955 100644 --- a/src/kernel/CMakeLists.txt +++ b/src/kernel/CMakeLists.txt @@ -61,6 +61,7 @@ add_library(bitcoinkernel ../uint256.cpp ../util/chaintype.cpp ../util/check.cpp + ../util/exception.cpp ../util/expected.cpp ../util/feefrac.cpp ../util/fs.cpp @@ -70,6 +71,7 @@ add_library(bitcoinkernel ../util/rbf.cpp ../util/signalinterrupt.cpp ../util/syserror.cpp + ../util/thread.cpp ../util/threadnames.cpp ../util/time.cpp ../util/tokenpipe.cpp diff --git a/src/test/coinsviewoverlay_tests.cpp b/src/test/coinsviewoverlay_tests.cpp index 6b20b31211a..fc7edfe97cd 100644 --- a/src/test/coinsviewoverlay_tests.cpp +++ b/src/test/coinsviewoverlay_tests.cpp @@ -3,6 +3,7 @@ // file COPYING or http://www.opensource.org/licenses/mit-license.php. #include +#include #include #include #include @@ -10,17 +11,24 @@ #include #include #include +#include #include #include #include +#include #include -BOOST_AUTO_TEST_SUITE(coinsviewoverlay_tests) - namespace { +std::shared_ptr MakeStartedThreadPool() +{ + auto pool{std::make_shared("fetch_test")}; + pool->Start(DEFAULT_PREVOUTFETCH_THREADS); + return pool; +} + CBlock CreateBlock() noexcept { static constexpr auto NUM_TXS{100}; @@ -78,13 +86,15 @@ void CheckCache(const CBlock& block, const CCoinsViewCache& cache) } // namespace +BOOST_AUTO_TEST_SUITE(coinsviewoverlay_tests) + BOOST_AUTO_TEST_CASE(fetch_inputs_from_db) { const auto block{CreateBlock()}; CCoinsViewDB db{{.path = "", .cache_bytes = 1_MiB, .memory_only = true}, {}}; PopulateView(block, db); CCoinsViewCache main_cache{&db}; - CoinsViewOverlay view{&main_cache}; + CoinsViewOverlay view{&main_cache, MakeStartedThreadPool()}; const auto& outpoint{block.vtx[1]->vin[0].prevout}; BOOST_CHECK(view.HaveCoin(outpoint)); @@ -111,7 +121,7 @@ BOOST_AUTO_TEST_CASE(fetch_inputs_from_cache) CCoinsViewDB db{{.path = "", .cache_bytes = 1_MiB, .memory_only = true}, {}}; CCoinsViewCache main_cache{&db}; PopulateView(block, main_cache); - CoinsViewOverlay view{&main_cache}; + CoinsViewOverlay view{&main_cache, MakeStartedThreadPool()}; CheckCache(block, view); const auto& outpoint{block.vtx[1]->vin[0].prevout}; @@ -131,7 +141,7 @@ BOOST_AUTO_TEST_CASE(fetch_no_double_spend) CCoinsViewCache main_cache{&db}; // Add all inputs as spent already in cache PopulateView(block, main_cache, /*spent=*/true); - CoinsViewOverlay view{&main_cache}; + CoinsViewOverlay view{&main_cache, MakeStartedThreadPool()}; for (const auto& tx : block.vtx) { for (const auto& in : tx->vin) { const auto& c{view.AccessCoin(in.prevout)}; @@ -149,7 +159,7 @@ BOOST_AUTO_TEST_CASE(fetch_no_inputs) const auto block{CreateBlock()}; CCoinsViewDB db{{.path = "", .cache_bytes = 1_MiB, .memory_only = true}, {}}; CCoinsViewCache main_cache{&db}; - CoinsViewOverlay view{&main_cache}; + CoinsViewOverlay view{&main_cache, MakeStartedThreadPool()}; for (const auto& tx : block.vtx) { for (const auto& in : tx->vin) { const auto& c{view.AccessCoin(in.prevout)}; diff --git a/src/test/fuzz/coins_view.cpp b/src/test/fuzz/coins_view.cpp index db81e190b26..793ea9e285e 100644 --- a/src/test/fuzz/coins_view.cpp +++ b/src/test/fuzz/coins_view.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -17,6 +18,7 @@ #include #include #include +#include #include #include @@ -84,6 +86,12 @@ public: using CCoinsViewCache::CCoinsViewCache; }; + +// Reuse a single global thread pool across fuzz iterations. Creating and destroying a pool every +// iteration leaks memory, since iterations can run faster than the OS can tear down the threads. +std::shared_ptr g_thread_pool{std::make_shared("view_fuzz")}; +Mutex g_thread_pool_mutex; + } // namespace void initialize_coins_view() @@ -376,10 +384,10 @@ FUZZ_TARGET(coins_view_db, .init = initialize_coins_view) // This allows us to exercise all methods on a CoinsViewOverlay, while also // ensuring that nothing can mutate the underlying cache until Flush or Sync is // called. -FUZZ_TARGET(coins_view_overlay, .init = initialize_coins_view) +FUZZ_TARGET(coins_view_overlay, .init = initialize_coins_view) EXCLUSIVE_LOCKS_REQUIRED(!g_thread_pool_mutex) { FuzzedDataProvider fuzzed_data_provider{buffer.data(), buffer.size()}; MutationGuardCoinsViewCache backend_cache{&CoinsViewEmpty::Get(), /*deterministic=*/true}; - CoinsViewOverlay coins_view_cache{&backend_cache, /*deterministic=*/true}; + CoinsViewOverlay coins_view_cache{&backend_cache, g_thread_pool, /*deterministic=*/true}; TestCoinsView(fuzzed_data_provider, coins_view_cache, &backend_cache); } diff --git a/src/test/fuzz/coinscache_sim.cpp b/src/test/fuzz/coinscache_sim.cpp index 15ece2e4af0..24a0327d7d1 100644 --- a/src/test/fuzz/coinscache_sim.cpp +++ b/src/test/fuzz/coinscache_sim.cpp @@ -4,10 +4,13 @@ #include #include +#include #include #include #include #include +#include +#include #include #include @@ -182,10 +185,22 @@ public: } }; +// Reuse a single global thread pool across fuzz iterations. Creating and destroying a pool every +// iteration leaks memory, since iterations can run faster than the OS can tear down the threads. +std::shared_ptr g_thread_pool{std::make_shared("cache_fuzz")}; +Mutex g_thread_pool_mutex; + +void StartPoolIfNeeded() EXCLUSIVE_LOCKS_REQUIRED(!g_thread_pool_mutex) +{ + LOCK(g_thread_pool_mutex); + if (!g_thread_pool->WorkersCount()) g_thread_pool->Start(DEFAULT_PREVOUTFETCH_THREADS); +} + } // namespace -FUZZ_TARGET(coinscache_sim) +FUZZ_TARGET(coinscache_sim, .init = [] { static auto setup{MakeNoLogFileContext<>()}; }) EXCLUSIVE_LOCKS_REQUIRED(!g_thread_pool_mutex) { + StartPoolIfNeeded(); /** Precomputed COutPoint and CCoins values. */ static const PrecomputedData data; @@ -372,7 +387,7 @@ FUZZ_TARGET(coinscache_sim) if (provider.ConsumeBool()) { caches.emplace_back(new CCoinsViewCache(&*caches.back(), /*deterministic=*/true)); } else { - caches.emplace_back(new CoinsViewOverlay(&*caches.back(), /*deterministic=*/true)); + caches.emplace_back(new CoinsViewOverlay(&*caches.back(), g_thread_pool, /*deterministic=*/true)); } // Apply to simulation data. sim_caches[caches.size()].Wipe(); diff --git a/src/validation.cpp b/src/validation.cpp index 87cf646b8b9..fd1f440471b 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -60,6 +60,7 @@ #include #include #include +#include #include #include #include @@ -1859,11 +1860,16 @@ CoinsViews::CoinsViews(DBParams db_params, CoinsViewOptions options) : m_dbview{std::move(db_params), std::move(options)}, m_catcherview(&m_dbview) {} -void CoinsViews::InitCache() +void CoinsViews::InitCache(int32_t prevoutfetch_threads) { AssertLockHeld(::cs_main); m_cacheview = std::make_unique(&m_catcherview); - m_connect_block_view = std::make_unique(&*m_cacheview); + auto thread_pool{std::make_shared("prevout")}; + if (prevoutfetch_threads > 0) { + thread_pool->Start(prevoutfetch_threads); + LogInfo("Block input prevout fetching uses %d additional threads", prevoutfetch_threads); + } + m_connect_block_view = std::make_unique(&*m_cacheview, std::move(thread_pool)); } Chainstate::Chainstate( @@ -1939,7 +1945,7 @@ void Chainstate::InitCoinsCache(size_t cache_size_bytes) AssertLockHeld(::cs_main); assert(m_coins_views != nullptr); m_coinstip_cache_size_bytes = cache_size_bytes; - m_coins_views->InitCache(); + m_coins_views->InitCache(m_chainman.m_options.prevoutfetch_threads_num); } // Lock-free: depends on `m_cached_is_ibd`, which is latched by `UpdateIBDStatus()`. diff --git a/src/validation.h b/src/validation.h index 2472dd60c34..9728e6b0bf2 100644 --- a/src/validation.h +++ b/src/validation.h @@ -506,7 +506,7 @@ public: CoinsViews(DBParams db_params, CoinsViewOptions options); //! Initialize the CCoinsViewCache member. - void InitCache() EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + void InitCache(int32_t prevoutfetch_threads) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); }; enum class CoinsCacheSizeState diff --git a/test/functional/feature_block.py b/test/functional/feature_block.py index 5600eeec864..6e3f342cedd 100755 --- a/test/functional/feature_block.py +++ b/test/functional/feature_block.py @@ -92,6 +92,9 @@ class FullBlockTest(BitcoinTestFramework): self.setup_clean_chain = True self.extra_args = [[ '-testactivationheight=bip34@2', + # Override the functional-test default of 1 thread to exercise the multi-threaded + # prevout prefetching path in this block-heavy test. + '-prevoutfetchthreads=8', ]] def add_options(self, parser): diff --git a/test/functional/feature_proxy.py b/test/functional/feature_proxy.py index fa4e966ea6b..37a6e2f4ffc 100755 --- a/test/functional/feature_proxy.py +++ b/test/functional/feature_proxy.py @@ -135,6 +135,9 @@ class ProxyTest(BitcoinTestFramework): if self.have_unix_sockets: args[5] = ['-listen', f'-proxy=unix:{socket_path}'] args[6] = ['-listen', f'-onion=unix:{socket_path}'] + # This test launches many nodes; disable prevout prefetching so we don't spin up a + # thread pool for each one. + args = [a + ['-prevoutfetchthreads=0'] for a in args] self.add_nodes(self.num_nodes, extra_args=args) self.start_nodes() diff --git a/test/functional/test_framework/util.py b/test/functional/test_framework/util.py index 7d064d68a9f..a7f3252c2f1 100644 --- a/test/functional/test_framework/util.py +++ b/test/functional/test_framework/util.py @@ -566,6 +566,8 @@ def write_config(config_path, *, n, chain, extra_config="", disable_autoconnect= # nMaxConnections = available_fds - min_required_fds = 256 - 161 = 94; f.write("maxconnections=94\n") f.write("par=" + str(min(2, os.cpu_count())) + "\n") + # Use a single prevoutfetch worker thread to keep per-node resource usage low. + f.write("prevoutfetchthreads=1\n") f.write(extra_config) From ede11b83141d0d0998cd95ebabf8d1656c6f7765 Mon Sep 17 00:00:00 2001 From: Andrew Toth Date: Sat, 7 Mar 2026 11:34:37 -0500 Subject: [PATCH 03/10] validation: collect block inputs in CoinsViewOverlay before ConnectBlock Introduce CoinsViewOverlay::StartFetching, which maps all input prevouts of a block to a new m_inputs vector of InputToFetch elements. Returns a ResetGuard which is lifetime bound to the block, while the InputToFetch elements are lifetime bound to the block as well. Inputs spending outputs of an earlier transaction in the same block won't be in the cache or the db. They also won't be requested by FetchCoinFromBase, so we filter them out while building m_inputs to not waste time trying to fetch them. Build an unordered set of seen txids while flattening m_inputs and skip any prevout whose hash is already in the set. Introduce StopFetching to clear the m_inputs vector. CCoinsViewCache::Reset is made virtual and is overridden in CoinsViewOverlay. StopFetching is called on Reset, so the InputToFetch objects will not exceed the lifetime of the block. Introduce ProcessInput to fetch the utxo of an individual input in m_inputs. Each caller fetches the input at m_input_head and increments it, so each call will fetch the next input in the queue. Fetch coins from the m_inputs vector in FetchCoinFromBase by comparing the requested outpoint against the single input at m_input_tail. ConnectBlock requests prevouts in the same order StartFetching queued them, and same-block spends are filtered out, so the coin to serve is always the one at m_input_tail (aside from BIP30 checks, an invalid block, or when the thread pool is not yet. These cases fall back to base->PeekCoin). This is designed deliberately so multiple threads can call ProcessInput independently. Co-authored-by: l0rinc Co-authored-by: Hodlinator <172445034+hodlinator@users.noreply.github.com> --- src/coins.cpp | 30 ++++++++++++++++ src/coins.h | 69 +++++++++++++++++++++++++++++++++++- src/test/fuzz/coins_view.cpp | 1 + src/validation.cpp | 4 +-- 4 files changed, 101 insertions(+), 3 deletions(-) diff --git a/src/coins.cpp b/src/coins.cpp index c403e006c85..69ad264898e 100644 --- a/src/coins.cpp +++ b/src/coins.cpp @@ -5,11 +5,16 @@ #include #include +#include #include #include #include +#include #include +#include +#include + TRACEPOINT_SEMAPHORE(utxocache, add); TRACEPOINT_SEMAPHORE(utxocache, spent); TRACEPOINT_SEMAPHORE(utxocache, uncache); @@ -361,6 +366,31 @@ void CCoinsViewCache::SanityCheck() const assert(recomputed_usage == cachedCoinsUsage); } +CCoinsViewCache::ResetGuard CoinsViewOverlay::StartFetching(const CBlock& block LIFETIMEBOUND) noexcept +{ + Assert(m_inputs.empty()); + Assert(m_input_head.load(std::memory_order_relaxed) == 0); + Assert(m_input_tail == 0); + if (const auto workers_count{m_thread_pool->WorkersCount()}; workers_count > 0) { + // Loop through the block inputs and set their prevouts in the queue. + // Filter inputs that spend outputs created earlier in the same block. These outputs will be created + // directly in the cache from the tx that creates them, so they will not be requested from a base view. + std::unordered_set earlier_txids; + earlier_txids.reserve(block.vtx.size()); + for (const auto& tx : block.vtx | std::views::drop(1)) { + for (const auto& input : tx->vin) { + if (!earlier_txids.contains(input.prevout.hash)) m_inputs.emplace_back(input.prevout); + } + earlier_txids.emplace(tx->GetHash()); + } + // Only process inputs if we have something to fetch. + if (m_inputs.size()) { + while (ProcessInput()) {} + } + } + return CreateResetGuard(); +} + static const uint64_t MIN_TRANSACTION_OUTPUT_WEIGHT{WITNESS_SCALE_FACTOR * ::GetSerializeSize(CTxOut())}; static const uint64_t MAX_OUTPUTS_PER_BLOCK{MAX_BLOCK_WEIGHT / MIN_TRANSACTION_OUTPUT_WEIGHT}; diff --git a/src/coins.h b/src/coins.h index 69eee034b2e..23c5fa5ff1d 100644 --- a/src/coins.h +++ b/src/coins.h @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -21,10 +22,15 @@ #include #include +#include #include #include +#include #include +#include +#include +class CBlock; class ThreadPool; /** @@ -418,7 +424,7 @@ protected: * Discard all modifications made to this cache without flushing to the base view. * This can be used to efficiently reuse a cache instance across multiple operations. */ - void Reset() noexcept; + virtual void Reset() noexcept; /* Fetch the coin from base. Used for cache misses in FetchCoin. */ virtual std::optional FetchCoinFromBase(const COutPoint& outpoint) const; @@ -567,14 +573,72 @@ private: class CoinsViewOverlay : public CCoinsViewCache { private: + //! The latest input not yet being fetched. Workers atomically increment this when fetching. + std::atomic_uint32_t m_input_head{0}; + //! The latest input not yet accessed by a consumer. Only the main thread increments this. + mutable uint32_t m_input_tail{0}; + + //! The inputs of the block which is being fetched. + struct InputToFetch { + //! The outpoint of the input to fetch. + const COutPoint& outpoint; + //! The coin that workers will fetch and main thread will insert into cache. + //! Mutable so it can be moved in FetchCoinFromBase. + mutable std::optional coin{std::nullopt}; + + explicit InputToFetch(const COutPoint& o LIFETIMEBOUND) noexcept : outpoint{o} {} + }; + std::vector m_inputs{}; + + /** + * Claim and fetch the next input in the queue. + * + * @return true if an input prevout was fetched + * @return false if there are no more input prevouts in the queue to fetch + */ + bool ProcessInput() noexcept + { + const auto i{m_input_head.fetch_add(1, std::memory_order_relaxed)}; + if (i >= m_inputs.size()) return false; + + auto& input{m_inputs[i]}; + input.coin = base->PeekCoin(input.outpoint); + return true; + } + + //! Clear fetching data. + void StopFetching() noexcept + { + m_inputs.clear(); + m_input_head.store(0, std::memory_order_relaxed); + m_input_tail = 0; + } + std::optional FetchCoinFromBase(const COutPoint& outpoint) const override { + // This assumes ConnectBlock accesses all inputs in the same order as + // they are added to m_inputs in StartFetching. + if (m_input_tail < m_inputs.size() && m_inputs[m_input_tail].outpoint == outpoint) { + // We advance the tail since the input is cached and not accessed through this method again. + auto& input{m_inputs[m_input_tail++]}; + // We can move the coin since we won't access this input again. + return std::move(input.coin); + } + + // We will only get here for BIP30 checks, an invalid block, or if the threadpool has not been started. return base->PeekCoin(outpoint); } //! Non-null. std::shared_ptr m_thread_pool; +protected: + void Reset() noexcept override + { + StopFetching(); + CCoinsViewCache::Reset(); + } + public: explicit CoinsViewOverlay(CCoinsView* in_base, std::shared_ptr thread_pool, bool deterministic = false) noexcept @@ -582,6 +646,9 @@ public: { Assert(m_thread_pool); } + + //! Start fetching inputs from block. + [[nodiscard]] ResetGuard StartFetching(const CBlock& block LIFETIMEBOUND) noexcept; }; //! Utility function to add all of a transaction's outputs to a cache. diff --git a/src/test/fuzz/coins_view.cpp b/src/test/fuzz/coins_view.cpp index 793ea9e285e..62e577fd186 100644 --- a/src/test/fuzz/coins_view.cpp +++ b/src/test/fuzz/coins_view.cpp @@ -386,6 +386,7 @@ FUZZ_TARGET(coins_view_db, .init = initialize_coins_view) // called. FUZZ_TARGET(coins_view_overlay, .init = initialize_coins_view) EXCLUSIVE_LOCKS_REQUIRED(!g_thread_pool_mutex) { + SeedRandomStateForTest(SeedRand::ZEROS); // for SaltedTxidHasher FuzzedDataProvider fuzzed_data_provider{buffer.data(), buffer.size()}; MutationGuardCoinsViewCache backend_cache{&CoinsViewEmpty::Get(), /*deterministic=*/true}; CoinsViewOverlay coins_view_cache{&backend_cache, g_thread_pool, /*deterministic=*/true}; diff --git a/src/validation.cpp b/src/validation.cpp index fd1f440471b..d00834449b4 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -3052,8 +3052,8 @@ bool Chainstate::ConnectTip( LogDebug(BCLog::BENCH, " - Load block from disk: %.2fms\n", Ticks(time_2 - time_1)); { - CCoinsViewCache& view{*m_coins_views->m_connect_block_view}; - const auto reset_guard{view.CreateResetGuard()}; + CoinsViewOverlay& view{*m_coins_views->m_connect_block_view}; + const auto reset_guard{view.StartFetching(*block_to_connect)}; bool rv = ConnectBlock(*block_to_connect, state, pindexNew, view); if (m_chainman.m_options.signals) { m_chainman.m_options.signals->BlockChecked(block_to_connect, state); From fdf283036a1e16f546f96ca9c2d6d33f3a4fea56 Mon Sep 17 00:00:00 2001 From: Andrew Toth Date: Fri, 1 May 2026 18:16:36 -0400 Subject: [PATCH 04/10] coins: add ready flag to InputToFetch Prepares for ProcessInput to be called from multiple threads. This flag acts as a memory fence around InputToFetch::coin. There is no lock guarding reads and writes of the coin field. Instead we use the flag's release/acquire semantics to ensure that when the main thread reads the coin it will have happened after a worker thread has finished writing it. Co-authored-by: l0rinc --- src/coins.h | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/coins.h b/src/coins.h index 23c5fa5ff1d..df519f76de4 100644 --- a/src/coins.h +++ b/src/coins.h @@ -580,6 +580,8 @@ private: //! The inputs of the block which is being fetched. struct InputToFetch { + //! Workers set this after setting the coin. The main thread tests this before reading the coin. + std::atomic_flag ready{}; //! The outpoint of the input to fetch. const COutPoint& outpoint; //! The coin that workers will fetch and main thread will insert into cache. @@ -587,6 +589,14 @@ private: mutable std::optional coin{std::nullopt}; explicit InputToFetch(const COutPoint& o LIFETIMEBOUND) noexcept : outpoint{o} {} + + //! Move ctor is required for resizing m_inputs in StartFetching. Elements will never move once parallel tasks + //! are started, so we can assert that coin is nullopt and ready is false. + InputToFetch(InputToFetch&& other) noexcept : outpoint{other.outpoint} + { + Assert(!other.coin); + Assert(!other.ready.test(std::memory_order_relaxed)); + } }; std::vector m_inputs{}; @@ -603,6 +613,9 @@ private: auto& input{m_inputs[i]}; input.coin = base->PeekCoin(input.outpoint); + // Use release so writing coin above happens before the main thread acquires. + Assert(!input.ready.test_and_set(std::memory_order_release)); + input.ready.notify_one(); return true; } @@ -621,6 +634,8 @@ private: if (m_input_tail < m_inputs.size() && m_inputs[m_input_tail].outpoint == outpoint) { // We advance the tail since the input is cached and not accessed through this method again. auto& input{m_inputs[m_input_tail++]}; + // Wait until the coin is ready to be read. We need acquire so we match the worker thread's release. + input.ready.wait(/*old=*/false, std::memory_order_acquire); // We can move the coin since we won't access this input again. return std::move(input.coin); } From ab2a3792372c6b99b9d6749a1841dbb363264573 Mon Sep 17 00:00:00 2001 From: Andrew Toth Date: Fri, 1 May 2026 20:07:31 -0400 Subject: [PATCH 05/10] coins: fetch inputs in parallel Leverages the thread pool to fetch inputs on multiple threads, while the overlay serves inputs on the main thread. This is a performance improvement over blocking the main thread to fetch inputs. Co-authored-by: l0rinc --- src/coins.cpp | 17 +++++++++++++++-- src/coins.h | 36 +++++++++++++++++++++++++++++++++--- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/coins.cpp b/src/coins.cpp index 69ad264898e..7bb05f68c1a 100644 --- a/src/coins.cpp +++ b/src/coins.cpp @@ -368,6 +368,7 @@ void CCoinsViewCache::SanityCheck() const CCoinsViewCache::ResetGuard CoinsViewOverlay::StartFetching(const CBlock& block LIFETIMEBOUND) noexcept { + Assert(m_futures.empty()); Assert(m_inputs.empty()); Assert(m_input_head.load(std::memory_order_relaxed) == 0); Assert(m_input_tail == 0); @@ -383,9 +384,21 @@ CCoinsViewCache::ResetGuard CoinsViewOverlay::StartFetching(const CBlock& block } earlier_txids.emplace(tx->GetHash()); } - // Only process inputs if we have something to fetch. + // Only submit tasks if we have something to fetch. if (m_inputs.size()) { - while (ProcessInput()) {} + std::vector> tasks(workers_count, [this] { + while (ProcessInput()) {} + }); + if (auto futures{m_thread_pool->Submit(std::move(tasks))}) { + m_futures = std::move(*futures); + } else { + // Submit can fail if a shared owner of the thread pool outside of this class calls Stop() or + // Interrupt() on a different thread after we call WorkersCount() above. In that case parallel + // fetching will not make progress, so we clear the inputs to fall back to single threaded fetching. + LogWarning("Failed to submit prevout fetch tasks; falling back to single-threaded fetching for this block."); + m_inputs.clear(); + StopFetching(); // Assert nothing changed if we failed to start tasks. + } } } return CreateResetGuard(); diff --git a/src/coins.h b/src/coins.h index df519f76de4..5528bde0581 100644 --- a/src/coins.h +++ b/src/coins.h @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -24,6 +25,7 @@ #include #include +#include #include #include #include @@ -496,7 +498,7 @@ public: * If reallocate_cache is false, the cache will retain the same memory footprint * after flushing and should be destroyed to deallocate. */ - void Flush(bool reallocate_cache = true); + virtual void Flush(bool reallocate_cache = true); /** * Push the modifications applied to this cache to its base while retaining @@ -598,6 +600,7 @@ private: Assert(!other.ready.test(std::memory_order_relaxed)); } }; + //! Must only be mutated when m_futures is empty. Elements may be mutated when m_futures is not empty. std::vector m_inputs{}; /** @@ -619,9 +622,21 @@ private: return true; } - //! Clear fetching data. + //! Stop all worker threads and clear fetching data. + //! Calling this is idempotent, and may safely be called if not fetching. void StopFetching() noexcept { + if (m_futures.empty()) { + Assert(m_inputs.empty()); + Assert(m_input_head.load(std::memory_order_relaxed) == 0); + Assert(m_input_tail == 0); + return; + } + // Skip fetching the rest of the inputs by moving the head to the end. + m_input_head.store(m_inputs.size(), std::memory_order_relaxed); + // Wait for all threads to stop. + for (auto& future : m_futures) future.wait(); + m_futures.clear(); m_inputs.clear(); m_input_head.store(0, std::memory_order_relaxed); m_input_tail = 0; @@ -644,8 +659,9 @@ private: return base->PeekCoin(outpoint); } - //! Non-null. + //! Non-null. May have zero workers when input fetching is disabled. std::shared_ptr m_thread_pool; + std::vector> m_futures{}; protected: void Reset() noexcept override @@ -662,8 +678,22 @@ public: Assert(m_thread_pool); } + ~CoinsViewOverlay() noexcept override { StopFetching(); } + //! Start fetching inputs from block. [[nodiscard]] ResetGuard StartFetching(const CBlock& block LIFETIMEBOUND) noexcept; + + void Flush(bool reallocate_cache = true) override + { + if (!Assume(AllInputsConsumed())) { + LogWarning("Block %s input prevout prefetch queue was not fully consumed; inputs were accessed out of order, so prefetching degraded to serial lookups for this block.", GetBestBlock().ToString()); + } + StopFetching(); + CCoinsViewCache::Flush(reallocate_cache); + } + + //! Verify that all parallel fetched input prevouts have been consumed. + bool AllInputsConsumed() const noexcept { return m_input_tail == m_inputs.size(); } }; //! Utility function to add all of a transaction's outputs to a cache. From d69a3b20deca56ff8f925d93471d4caeacaa4d21 Mon Sep 17 00:00:00 2001 From: Andrew Toth Date: Sat, 7 Mar 2026 13:18:36 -0500 Subject: [PATCH 06/10] doc: update CoinsViewOverlay docstring to describe parallel fetching Co-authored-by: l0rinc --- src/coins.h | 59 ++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/src/coins.h b/src/coins.h index 5528bde0581..d1b4e0c8a9f 100644 --- a/src/coins.h +++ b/src/coins.h @@ -564,13 +564,62 @@ private: }; /** - * CCoinsViewCache overlay that avoids populating/mutating parent cache layers on cache misses. + * CCoinsViewCache subclass that asynchronously fetches most block input prevouts in parallel during ConnectBlock without + * mutating the base cache. * - * This is achieved by fetching coins from the base view using PeekCoin() instead of GetCoin(), - * so intermediate CCoinsViewCache layers are not filled. + * Only used in ConnectBlock to pass as an ephemeral view that can be reset if the block is invalid. + * It provides the same interface as CCoinsViewCache. + * It adds an additional StartFetching method to provide the block. * - * Used during ConnectBlock() as an ephemeral, resettable top-level view that is flushed only - * on success, so invalid blocks don't pollute the underlying cache. + * When a block is passed to StartFetching, the inputs of the block are flattened into a vector of InputToFetch + * objects. StartFetching then submits worker tasks to a ThreadPool and keeps the returned futures alive until fetching + * is stopped. + * + * ProcessInput() atomically fetches and increments m_input_head, so each thread can only access a single element of the + * m_inputs vector at a time. Workers race to claim inputs, so they may fetch elements in any order. If the fetched + * index is greater than or equal to the size of m_inputs, no more inputs can be fetched and false is returned. + * + * The worker claims the InputToFetch at this index, fetches the coin from the base cache and moves it into the + * InputToFetch object. The ready flag is then set with a release memory order. This allows the ready flag to be + * used as a memory fence, guaranteeing the coin being written to the object will have happened before another + * thread tests the flag with an acquire memory order. + * This assumes all base->PeekCoin() paths are safe for concurrent readers and do not mutate lower cache layers. + * + * When a coin is requested from the cache on the main thread and is not already in cacheCoins map, FetchCoinFromBase + * checks whether the next unconsumed entry in m_inputs has the requested outpoint. On a match, m_input_tail is advanced + * and the entry's ready flag is waited on with an acquire memory order until a worker has finished fetching it. The + * coin is then moved out and returned. Since the main thread is the only consumer of validation results, it blocks + * on the specific input it needs rather than racing workers for other inputs. + * + * StopFetching() is called in Flush() and in Reset() (the per-block teardown) so workers stop before the block they + * reference goes away. It stops fetching by moving m_input_head to the end of m_inputs (so workers quickly exit), + * then waits for all futures to complete and clears the per-block state (m_inputs and the head/tail counters). + * + * Workers advance m_input_head to fetch inputs. Main thread advances m_input_tail to consume. + * + * Before workers start: + * + * m_input_head + * m_input_tail + * │ + * ▼ + * ┌─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┐ + * m_inputs: │ waiting │ waiting │ waiting │ waiting │ waiting │ waiting │ waiting │ waiting │ waiting │ + * │ │ │ │ │ │ │ │ │ │ + * └─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┘ + * + * After workers start: + * + * Worker 2 Worker 0 Worker 3 Worker 1 m_input_head + * │ │ │ │ │ + * ▼ ▼ ▼ ▼ ▼ + * ┌─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┐ + * m_inputs: │ ready │ ready │fetching │ ready │fetching │fetching │fetching │ waiting │ waiting │ + * │consumed │ ✓ │ ● │ ✓ │ ● │ ● │ ● │ │ │ + * └─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┘ + * ▲ + * │ + * m_input_tail */ class CoinsViewOverlay : public CCoinsViewCache { From 760fb22dc370b0882bd345ff913f9337a9b6e4c1 Mon Sep 17 00:00:00 2001 From: Andrew Toth Date: Sat, 7 Mar 2026 14:39:03 -0500 Subject: [PATCH 07/10] test: add unit tests for CoinsViewOverlay::StartFetching Co-authored-by: l0rinc --- src/test/coinsviewoverlay_tests.cpp | 138 +++++++++++++++++++++++++++- 1 file changed, 135 insertions(+), 3 deletions(-) diff --git a/src/test/coinsviewoverlay_tests.cpp b/src/test/coinsviewoverlay_tests.cpp index fc7edfe97cd..d6403752b0e 100644 --- a/src/test/coinsviewoverlay_tests.cpp +++ b/src/test/coinsviewoverlay_tests.cpp @@ -19,6 +19,8 @@ #include #include #include +#include +#include namespace { @@ -37,10 +39,13 @@ CBlock CreateBlock() noexcept coinbase.vin.emplace_back(); block.vtx.push_back(MakeTransactionRef(coinbase)); + Txid prevhash{Txid::FromUint256(uint256{1})}; + for (const auto i : std::views::iota(1, NUM_TXS)) { CMutableTransaction tx; - Txid txid{Txid::FromUint256(uint256(i))}; + const Txid txid{i % 20 == 0 ? prevhash : Txid::FromUint256(uint256(i))}; tx.vin.emplace_back(txid, 0); + prevhash = tx.GetHash(); block.vtx.push_back(MakeTransactionRef(tx)); } @@ -52,12 +57,16 @@ void PopulateView(const CBlock& block, CCoinsView& view, bool spent = false) CCoinsViewCache cache{&view}; cache.SetBestBlock(uint256::ONE); + std::unordered_set txids{}; + txids.reserve(block.vtx.size() - 1); for (const auto& tx : block.vtx | std::views::drop(1)) { for (const auto& in : tx->vin) { + if (txids.contains(in.prevout.hash)) continue; Coin coin{}; if (!spent) coin.out.nValue = 1; cache.EmplaceCoinInternalDANGER(COutPoint{in.prevout}, std::move(coin)); } + txids.emplace(tx->GetHash()); } cache.Flush(); @@ -66,6 +75,8 @@ void PopulateView(const CBlock& block, CCoinsView& view, bool spent = false) void CheckCache(const CBlock& block, const CCoinsViewCache& cache) { uint32_t counter{0}; + std::unordered_set txids{}; + txids.reserve(block.vtx.size() - 1); for (const auto& tx : block.vtx) { if (tx->IsCoinBase()) { @@ -76,9 +87,11 @@ void CheckCache(const CBlock& block, const CCoinsViewCache& cache) const auto& first{cache.AccessCoin(outpoint)}; const auto& second{cache.AccessCoin(outpoint)}; BOOST_CHECK_EQUAL(&first, &second); - ++counter; - BOOST_CHECK(cache.HaveCoinInCache(outpoint)); + const auto have{cache.HaveCoinInCache(outpoint)}; + BOOST_CHECK_NE(txids.contains(outpoint.hash), have); + counter += have; } + txids.emplace(tx->GetHash()); } } BOOST_CHECK_EQUAL(cache.GetCacheSize(), counter); @@ -95,6 +108,7 @@ BOOST_AUTO_TEST_CASE(fetch_inputs_from_db) PopulateView(block, db); CCoinsViewCache main_cache{&db}; CoinsViewOverlay view{&main_cache, MakeStartedThreadPool()}; + const auto reset_guard{view.StartFetching(block)}; const auto& outpoint{block.vtx[1]->vin[0].prevout}; BOOST_CHECK(view.HaveCoin(outpoint)); @@ -122,6 +136,7 @@ BOOST_AUTO_TEST_CASE(fetch_inputs_from_cache) CCoinsViewCache main_cache{&db}; PopulateView(block, main_cache); CoinsViewOverlay view{&main_cache, MakeStartedThreadPool()}; + const auto reset_guard{view.StartFetching(block)}; CheckCache(block, view); const auto& outpoint{block.vtx[1]->vin[0].prevout}; @@ -142,6 +157,7 @@ BOOST_AUTO_TEST_CASE(fetch_no_double_spend) // Add all inputs as spent already in cache PopulateView(block, main_cache, /*spent=*/true); CoinsViewOverlay view{&main_cache, MakeStartedThreadPool()}; + const auto reset_guard{view.StartFetching(block)}; for (const auto& tx : block.vtx) { for (const auto& in : tx->vin) { const auto& c{view.AccessCoin(in.prevout)}; @@ -160,6 +176,7 @@ BOOST_AUTO_TEST_CASE(fetch_no_inputs) CCoinsViewDB db{{.path = "", .cache_bytes = 1_MiB, .memory_only = true}, {}}; CCoinsViewCache main_cache{&db}; CoinsViewOverlay view{&main_cache, MakeStartedThreadPool()}; + const auto reset_guard{view.StartFetching(block)}; for (const auto& tx : block.vtx) { for (const auto& in : tx->vin) { const auto& c{view.AccessCoin(in.prevout)}; @@ -171,5 +188,120 @@ BOOST_AUTO_TEST_CASE(fetch_no_inputs) BOOST_CHECK_EQUAL(view.GetCacheSize(), 0); } +// Access coins that are not block inputs +BOOST_AUTO_TEST_CASE(access_non_input_coins) +{ + CBlock block; + CMutableTransaction coinbase; + coinbase.vin.emplace_back(); + block.vtx.push_back(MakeTransactionRef(coinbase)); + CCoinsViewDB db{{.path = "", .cache_bytes = 1_MiB, .memory_only = true}, {}}; + CCoinsViewCache main_cache{&db}; + Coin coin{}; + coin.out.nValue = 1; + const COutPoint outpoint{Txid::FromUint256(uint256::ZERO), 0}; + main_cache.EmplaceCoinInternalDANGER(COutPoint{outpoint}, std::move(coin)); + + CoinsViewOverlay view{&main_cache, MakeStartedThreadPool()}; + const auto reset_guard{view.StartFetching(block)}; + + // Non-input fallback hit. + BOOST_CHECK(!view.AccessCoin(outpoint).IsSpent()); + + // Non-input fallback miss. + const COutPoint missing_outpoint{Txid::FromUint256(uint256::ONE), 0}; + BOOST_CHECK(view.AccessCoin(missing_outpoint).IsSpent()); + BOOST_CHECK(!view.HaveCoinInCache(missing_outpoint)); +} + +// Access a fetched input out of order (i.e. not the next one in m_inputs). +// FetchCoinFromBase must fall back to base->PeekCoin, and the coin must still +// be inserted into the cache. +BOOST_AUTO_TEST_CASE(fetch_out_of_order_input_uses_normal_lookup) +{ + const auto block{CreateBlock()}; + CCoinsViewDB db{{.path = "", .cache_bytes = 1_MiB, .memory_only = true}, {}}; + CCoinsViewCache main_cache{&db}; + PopulateView(block, main_cache); + + std::vector fetched_inputs; + std::unordered_set txids; + txids.reserve(block.vtx.size() - 1); + for (const auto& tx : block.vtx | std::views::drop(1)) { + for (const auto& input : tx->vin) { + if (!txids.contains(input.prevout.hash)) fetched_inputs.push_back(input.prevout); + } + txids.emplace(tx->GetHash()); + } + BOOST_REQUIRE_GE(fetched_inputs.size(), 2U); + + CoinsViewOverlay view{&main_cache, MakeStartedThreadPool()}; + const auto reset_guard{view.StartFetching(block)}; + + const auto& out_of_order_input{fetched_inputs[1]}; + BOOST_CHECK(!view.HaveCoinInCache(out_of_order_input)); + BOOST_CHECK(!view.AccessCoin(out_of_order_input).IsSpent()); + BOOST_CHECK(view.HaveCoinInCache(out_of_order_input)); + + CheckCache(block, view); +} + +// The ResetGuard returned by StartFetching must clear all per-block state when +// it goes out of scope, so the overlay can be reused for a subsequent block. +// Flush must also clear all per-block state to be reused. +BOOST_AUTO_TEST_CASE(fetch_state_is_reusable_after_teardown) +{ + const auto block{CreateBlock()}; + CCoinsViewDB db{{.path = "", .cache_bytes = 1_MiB, .memory_only = true}, {}}; + CCoinsViewCache main_cache{&db}; + PopulateView(block, main_cache); + CoinsViewOverlay view{&main_cache, MakeStartedThreadPool()}; + + for (const bool use_flush : {false, true, false}) { + { + const auto reset_guard{view.StartFetching(block)}; + CheckCache(block, view); + BOOST_CHECK_GT(view.GetCacheSize(), 0U); + if (use_flush) { + view.SetBestBlock(uint256::ONE); + view.Flush(); + } + } + BOOST_CHECK_EQUAL(view.GetCacheSize(), 0U); + } +} + BOOST_AUTO_TEST_SUITE_END() +BOOST_AUTO_TEST_SUITE(coinsviewoverlay_tests_noworkers) + +// Test that disabled input fetching falls back to normal cache lookups via base->PeekCoin. +BOOST_AUTO_TEST_CASE(fetch_unstarted_thread_pool) +{ + const auto block{CreateBlock()}; + CCoinsViewDB db{{.path = "", .cache_bytes = 1_MiB, .memory_only = true}, {}}; + CCoinsViewCache main_cache{&db}; + PopulateView(block, main_cache); + auto thread_pool{std::make_shared("fetch_none")}; + CoinsViewOverlay view{&main_cache, thread_pool}; + const auto reset_guard{view.StartFetching(block)}; + CheckCache(block, view); +} + +// Test that an interrupted thread pool falls back to normal cache lookups via base->PeekCoin. +BOOST_AUTO_TEST_CASE(fetch_interrupted_thread_pool_uses_normal_lookup) +{ + const auto block{CreateBlock()}; + CCoinsViewDB db{{.path = "", .cache_bytes = 1_MiB, .memory_only = true}, {}}; + CCoinsViewCache main_cache{&db}; + PopulateView(block, main_cache); + + auto thread_pool{std::make_shared("fetch_intr")}; + thread_pool->Start(DEFAULT_PREVOUTFETCH_THREADS); + thread_pool->Interrupt(); + CoinsViewOverlay view{&main_cache, thread_pool}; + const auto reset_guard{view.StartFetching(block)}; + CheckCache(block, view); +} + +BOOST_AUTO_TEST_SUITE_END() From ce610a6ff445bb8a812e650c91f501a1ecf0b19c Mon Sep 17 00:00:00 2001 From: Andrew Toth Date: Sat, 7 Mar 2026 14:41:12 -0500 Subject: [PATCH 08/10] fuzz: update harnesses to cover CoinsViewOverlay::StartFetching Co-authored-by: l0rinc Co-authored-by: sedited --- src/test/fuzz/coins_view.cpp | 61 ++++++++++++++++++++++++++++++-- src/test/fuzz/coinscache_sim.cpp | 58 ++++++++++++++++++++++++------ 2 files changed, 107 insertions(+), 12 deletions(-) diff --git a/src/test/fuzz/coins_view.cpp b/src/test/fuzz/coins_view.cpp index 62e577fd186..62f7461442d 100644 --- a/src/test/fuzz/coins_view.cpp +++ b/src/test/fuzz/coins_view.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include