coins: don’t mutate main cache when connecting block #34165

pull andrewtoth wants to merge 2 commits into bitcoin:master from andrewtoth:dont-mutate-cache changing 8 files +242 βˆ’3
  1. andrewtoth commented at 8:32 pm on December 28, 2025: contributor

    This is a slightly modified version of the second commit of #31132, which can be merged as an independent change. It has a small benefit on its own, but will help in moving the parent PR forward.

    When accessing coins via the CCoinsViewCache, the const methods like GetCoin actually mutate cacheCoins internally to cache entries when they are pulled from the backing db. This is generally a performance improvement for single threaded access patterns, but it precludes us from accessing entries in a CCoinsViewCache from multiple threads without a lock.

    Another aspect is that when we add an ephemeral CCoinsViewCache view backed by the main cache for use in ConnectBlock(), we will insert entries into the main cache even if the block is determined to be invalid. This is not the biggest concern, since an invalid block requires proof-of-work. But, an attacker could craft multiple invalid blocks to fill the main cache. This would make us Flush the cache more often than necessary. Obviously this would be very expensive to do on mainnet.

    Introduce CoinsViewCacheNonMutating, a CCoinsViewCache subclass that reads coins without mutating the underlying cache via FetchCoin().

    Add PeekCoin() to look up a Coin through a stack of CCoinsViewCache layers without populating parent caches. This prevents the main cache from caching inputs pulled from disk for a block that has not yet been fully validated. Once Flush() is called on the view, these inputs will be added as spent to coinsCache in the main cache via BatchWrite().

    This is the foundation for async input fetching, where worker threads must not mutate shared state.

  2. DrahtBot added the label UTXO Db and Indexes on Dec 28, 2025
  3. DrahtBot commented at 8:32 pm on December 28, 2025: contributor

    The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

    Code Coverage & Benchmarks

    For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/34165.

    Reviews

    See the guideline for information on the review process.

    Type Reviewers
    ACK l0rinc

    If your review is incorrectly listed, please copy-paste <!–meta-tag:bot-skip–> into the comment that the bot should ignore.

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #34320 (coins: replace remaining HaveCoin calls with GetCoin by l0rinc)
    • #34164 (validation: add reusable coins view for ConnectBlock by andrewtoth)
    • #34124 (refactor: make CCoinsView a purely virtual abstract base class by l0rinc)
    • #32317 (kernel: Separate UTXO set access from validation functions by sedited)
    • #31132 (validation: fetch block inputs on parallel threads 3x faster IBD by andrewtoth)
    • #29700 (kernel, refactor: return error status on all fatal errors by ryanofsky)

    If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

  4. in src/validation.cpp:2982 in 9739ce6b10
    2978@@ -2978,7 +2979,7 @@ bool Chainstate::DisconnectTip(BlockValidationState& state, DisconnectedBlockTra
    2979     // Apply the block atomically to the chain state.
    2980     const auto time_start{SteadyClock::now()};
    2981     {
    2982-        CCoinsViewCache view(&CoinsTip());
    2983+        CoinsViewCacheNonMutating view(&CoinsTip());
    


    l0rinc commented at 1:01 pm on January 2, 2026:

    I tried understanding why this was added to DisconnectTip, since every indication was that this is about block connection:

    • Only used in ConnectBlock to pass as an ephemeral view that can be reset if the block is invalid.

    So how are the new tests passing if the implementation is completely broken? It seems we’re only testing CoinsViewCacheNonMutating in isolation but there are no tests added to check it’s actual integration.


    andrewtoth commented at 3:00 pm on January 2, 2026:
    This was an oopsie. Added CoinsViewCacheNonMutating to ConnectBlock and removed from DisconnectBlock.
  5. l0rinc changes_requested
  6. l0rinc commented at 1:28 pm on January 2, 2026: contributor

    The implementation talks about block connection but the implementation was added to block disconnect. I don’t yet know if we should apply it there or not, but I find it very likely that we should do it for block connection. A lot of tests were added for testing the new cache in isolation and they all passed while it was applied to the wrong place as far as I can tell.

    We should add a new integration test that actually checks that invalid blocks don’t pollute the underlying cache:

     0diff --git a/src/test/validation_chainstate_tests.cpp b/src/test/validation_chainstate_tests.cpp
     1--- a/src/test/validation_chainstate_tests.cpp	(revision 20f0b8304d089d641060f693d3284e69524fbcbe)
     2+++ b/src/test/validation_chainstate_tests.cpp	(date 1767360120004)
     3@@ -3,10 +3,12 @@
     4 // file COPYING or http://www.opensource.org/licenses/mit-license.php.
     5 //
     6 #include <chainparams.h>
     7+#include <consensus/amount.h>
     8 #include <consensus/validation.h>
     9 #include <node/kernel_notifications.h>
    10 #include <random.h>
    11 #include <rpc/blockchain.h>
    12+#include <script/script.h>
    13 #include <sync.h>
    14 #include <test/util/chainstate.h>
    15 #include <test/util/coins.h>
    16@@ -63,6 +65,30 @@
    17     }
    18 }
    19 
    20+BOOST_FIXTURE_TEST_CASE(connect_tip_does_not_cache_inputs_on_failed_connect, TestChain100Setup)
    21+{
    22+    Chainstate& chainstate = Assert(m_node.chainman)->ActiveChainstate();
    23+
    24+    COutPoint outpoint;
    25+    {
    26+        LOCK(cs_main);
    27+        outpoint = AddTestCoin(m_rng, chainstate.CoinsTip());
    28+        chainstate.CoinsTip().Flush(/*will_reuse_cache=*/false);
    29+    }
    30+
    31+    CMutableTransaction tx;
    32+    tx.vin.emplace_back(outpoint);
    33+    tx.vout.emplace_back(MAX_MONEY, CScript{} << OP_TRUE);
    34+
    35+    const auto tip{WITH_LOCK(cs_main, return chainstate.m_chain.Tip()->GetBlockHash())};
    36+    CBlock block = CreateBlock({tx}, CScript{} << OP_TRUE, chainstate);
    37+    Assert(m_node.chainman)->ProcessNewBlock(std::make_shared<CBlock>(block), true, true, nullptr);
    38+
    39+    LOCK(cs_main);
    40+    BOOST_CHECK_EQUAL(tip, chainstate.m_chain.Tip()->GetBlockHash()); // block rejected
    41+    BOOST_CHECK(!chainstate.CoinsTip().HaveCoinInCache(outpoint));    // input not cached
    42+}
    43+
    44 //! Test UpdateTip behavior for both active and background chainstates.
    45 //!
    46 //! When run on the background chainstate, UpdateTip should do a subset
    47diff --git a/src/validation.cpp b/src/validation.cpp
    48--- a/src/validation.cpp	(revision 20f0b8304d089d641060f693d3284e69524fbcbe)
    49+++ b/src/validation.cpp	(date 1767360154724)
    50@@ -3102,7 +3102,7 @@
    51     LogDebug(BCLog::BENCH, "  - Load block from disk: %.2fms\n",
    52              Ticks<MillisecondsDouble>(time_2 - time_1));
    53     {
    54-        CCoinsViewCache view(&CoinsTip());
    55+        CoinsViewCacheNonMutating view(&CoinsTip());
    56         bool rv = ConnectBlock(*block_to_connect, state, pindexNew, view);
    57         if (m_chainman.m_options.signals) {
    58             m_chainman.m_options.signals->BlockChecked(block_to_connect, state);
    

    Concept ACK (edited to remove the nack).

  7. andrewtoth force-pushed on Jan 2, 2026
  8. andrewtoth commented at 3:00 pm on January 2, 2026: contributor
    Thank you @l0rinc, I have taken your integration test. I had unit tests for this behavior but the integration test is a great idea.
  9. l0rinc referenced this in commit d3a7723aa8 on Jan 2, 2026
  10. andrewtoth force-pushed on Jan 3, 2026
  11. andrewtoth force-pushed on Jan 3, 2026
  12. DrahtBot added the label CI failed on Jan 3, 2026
  13. DrahtBot commented at 7:22 pm on January 3, 2026: contributor

    🚧 At least one of the CI tasks failed. Task macOS native: https://github.com/bitcoin/bitcoin/actions/runs/20681569759/job/59376499348 LLM reason (✨ experimental): Blockchain tests aborted due to a DisconnectTip failure during tip/disconnect handling (BLOCK_FAILED_VALID), causing the CI run to fail.

    Try to run the tests locally, according to the documentation. However, a CI failure may still happen due to a number of reasons, for example:

    • Possibly due to a silent merge conflict (the changes in this pull request being incompatible with the current code in the target branch). If so, make sure to rebase on the latest commit of the target branch.

    • A sanitizer issue, which can only be found by compiling with the sanitizer and running the affected test.

    • An intermittent issue.

    Leave a comment here, if you need help tracking down a confusing failure.

  14. DrahtBot removed the label CI failed on Jan 3, 2026
  15. coins: add PeekCoin()
    Introduce a helper to look up a Coin through a stack of CCoinsViewCache layers without populating parent caches.
    
    This is useful for ephemeral views (e.g. during ConnectBlock) that want to avoid polluting CoinsTip() when validating invalid blocks.
    
    Co-authored-by: l0rinc <pap.lorinc@gmail.com>
    725c750345
  16. in src/test/coinsviewcachenonmutating_tests.cpp:37 in 7a6e6160cb outdated
    32+
    33+    for (const auto i : std::views::iota(1, NUM_TXS)) {
    34+        CMutableTransaction tx;
    35+        Txid txid{Txid::FromUint256(uint256(i))};
    36+        tx.vin.emplace_back(txid, 0);
    37+        prevhash = tx.GetHash();
    


    l0rinc commented at 8:49 pm on January 3, 2026:
    This seems like a leftover.
  17. andrewtoth force-pushed on Jan 3, 2026
  18. in src/test/coinsviewcachenonmutating_tests.cpp:23 in 7a6e6160cb outdated
    18+#include <cstring>
    19+#include <ranges>
    20+
    21+BOOST_AUTO_TEST_SUITE(coinsviewcachenonmutating_tests)
    22+
    23+static CBlock CreateBlock() noexcept
    


    l0rinc commented at 8:54 pm on January 3, 2026:

    I understand that we want this to be realistic, but we could simplify considerably if we just returned the outpoints here:

     0static std::vector<COutPoint> CreateOutpoints() noexcept
     1{
     2    static constexpr uint32_t NUM_OUTPOINTS{100};
     3    std::vector<COutPoint> outpoints;
     4    outpoints.reserve(NUM_OUTPOINTS);
     5
     6    for (const auto i : std::views::iota(uint32_t{1}, NUM_OUTPOINTS + 1)) {
     7        outpoints.emplace_back(Txid::FromUint256(uint256(i)), /*nIn=*/0);
     8    }
     9
    10    return outpoints;
    11}
    

    If you disagree, just resolve without a comment.

  19. l0rinc approved
  20. l0rinc commented at 8:59 pm on January 3, 2026: contributor
    I’ll run a reindex-chainstate on these to see how they behave - as long as they don’t slow down anything, I would be fine with them.
  21. andrewtoth force-pushed on Jan 4, 2026
  22. l0rinc referenced this in commit 15c56be893 on Jan 4, 2026
  23. in src/coinsviewcachenonmutating.h:22 in 7fab35c24d
    17+{
    18+private:
    19+    CCoinsMap::iterator FetchCoin(const COutPoint& outpoint) const override
    20+    {
    21+        auto [ret, inserted]{cacheCoins.try_emplace(outpoint)};
    22+        if (!inserted) return ret;
    


    l0rinc commented at 7:21 pm on January 5, 2026:

    Hmm, we’re we’re duplicating the FetchCoin implementation here - and also making it virtual which introduces a slight regression on the hot path. Could we extract only the part that changes? I think we can simplify it by adding a virtual GetCoinFromBase which either calls base->GetCoin(outpoint) (default impl) or in CoinsViewCacheNonMutating it could be overwritten with the new base->PeekCoin(outpoint). That way only the if (inserted) branch would pay for virtual dispatch and there would be no duplicated FetchCoin logic, something like:

     0diff --git a/src/coins.cpp b/src/coins.cpp
     1--- a/src/coins.cpp	(revision 85b20448640f8209e453019c3e1494c1ae229d74)
     2+++ b/src/coins.cpp	(revision c3b34ec796a3fd6e34e0dfb862c61bc298ba8d6f)
     3@@ -59,10 +59,15 @@
     4     return memusage::DynamicUsage(cacheCoins) + cachedCoinsUsage;
     5 }
     6 
     7+std::optional<Coin> CCoinsViewCache::GetCoinFromBase(const COutPoint& outpoint) const
     8+{
     9+    return base->GetCoin(outpoint);
    10+}
    11+
    12 CCoinsMap::iterator CCoinsViewCache::FetchCoin(const COutPoint &outpoint) const {
    13     const auto [ret, inserted] = cacheCoins.try_emplace(outpoint);
    14     if (inserted) {
    15-        if (auto coin{base->GetCoin(outpoint)}) {
    16+        if (auto coin{GetCoinFromBase(outpoint)}) {
    17             ret->second.coin = std::move(*coin);
    18             cachedCoinsUsage += ret->second.coin.DynamicMemoryUsage();
    19             if (ret->second.coin.IsSpent()) { // TODO GetCoin cannot return spent coins
    20diff --git a/src/coins.h b/src/coins.h
    21--- a/src/coins.h	(revision 85b20448640f8209e453019c3e1494c1ae229d74)
    22+++ b/src/coins.h	(revision c3b34ec796a3fd6e34e0dfb862c61bc298ba8d6f)
    23@@ -382,6 +382,8 @@
    24     /* Cached dynamic memory usage for the inner Coin objects. */
    25     mutable size_t cachedCoinsUsage{0};
    26 
    27+    virtual std::optional<Coin> GetCoinFromBase(const COutPoint& outpoint) const;
    28+
    29 public:
    30     CCoinsViewCache(CCoinsView *baseIn, bool deterministic = false);
    31 
    32@@ -489,7 +491,7 @@
    33      * [@note](/bitcoin-bitcoin/contributor/note/) this is marked const, but may actually append to `cacheCoins`, increasing
    34      * memory usage.
    35      */
    36-    virtual CCoinsMap::iterator FetchCoin(const COutPoint &outpoint) const;
    37+    CCoinsMap::iterator FetchCoin(const COutPoint &outpoint) const;
    38 };
    39 
    40 //! Utility function to add all of a transaction's outputs to a cache.
    41diff --git a/src/coinsviewcachenonmutating.h b/src/coinsviewcachenonmutating.h
    42--- a/src/coinsviewcachenonmutating.h	(revision 85b20448640f8209e453019c3e1494c1ae229d74)
    43+++ b/src/coinsviewcachenonmutating.h	(revision c3b34ec796a3fd6e34e0dfb862c61bc298ba8d6f)
    44@@ -9,28 +9,16 @@
    45 #include <primitives/transaction.h>
    46 
    47 /**
    48- * CCoinsViewCache subclass that does not call GetCoin on base via FetchCoin, so base will not be mutated before Flush.
    49+ * CCoinsViewCache subclass that does not call GetCoin on base, so base will not be mutated before Flush.
    50  * Only used in ConnectBlock to pass as an ephemeral view that can be reset if the block is invalid.
    51- * It provides the same interface as CCoinsViewCache, overriding the FetchCoin private method.
    52+ * It provides the same interface as CCoinsViewCache, overriding GetCoinFromBase().
    53  */
    54 class CoinsViewCacheNonMutating : public CCoinsViewCache
    55 {
    56 private:
    57-    CCoinsMap::iterator FetchCoin(const COutPoint& outpoint) const override
    58+    std::optional<Coin> GetCoinFromBase(const COutPoint& outpoint) const override
    59     {
    60-        auto [ret, inserted]{cacheCoins.try_emplace(outpoint)};
    61-        if (!inserted) return ret;
    62-
    63-        // TODO: Remove spent checks once we no longer return spent coins in coinscache_sim CoinsViewBottom.
    64-        if (auto coin{base->PeekCoin(outpoint)}; coin && !coin->IsSpent()) {
    65-            ret->second.coin = std::move(*coin);
    66-        } else {
    67-            cacheCoins.erase(ret);
    68-            return cacheCoins.end();
    69-        }
    70-
    71-        cachedCoinsUsage += ret->second.coin.DynamicMemoryUsage();
    72-        return ret;
    73+        return base->PeekCoin(outpoint);
    74     }
    75 
    76 public:
    

    andrewtoth commented at 3:40 pm on January 10, 2026:
    Nice, done.
  24. in src/validation.cpp:3102 in 7fab35c24d outdated
    3098@@ -3098,7 +3099,7 @@ bool Chainstate::ConnectTip(
    3099     LogDebug(BCLog::BENCH, "  - Load block from disk: %.2fms\n",
    3100              Ticks<MillisecondsDouble>(time_2 - time_1));
    3101     {
    3102-        CCoinsViewCache view(&CoinsTip());
    3103+        CoinsViewCacheNonMutating view(&CoinsTip());
    


    l0rinc commented at 7:48 pm on January 5, 2026:
    here or in a follow-up we could add this to TestBlockValidity as well, where it would be even more relevant since it’s invoked with without PoW checks (so your PR arguments apply here even more)
  25. in src/test/coinsviewcachenonmutating_tests.cpp:103 in 7fab35c24d outdated
    87+    for (const auto& tx : block.vtx) {
    88+        for (const auto& in : tx->vin) {
    89+            BOOST_CHECK(!main_cache.HaveCoinInCache(in.prevout));
    90+        }
    91+    }
    92+}
    


    l0rinc commented at 8:35 pm on January 5, 2026:

    Checked the coverage of this and compared it with the suggested CCoinsViewCache::GetCoinFromBase - existing FetchCoin is covered that way. We could extend the coverage with HaveCoin()/GetCoin() as well, to check that they also do not populate the parent cache when reading inputs from disk, and that flushing an ephemeral CoinsViewCacheNonMutating layer propagates spentness to its parent:

     0diff --git a/src/test/coinsviewcachenonmutating_tests.cpp b/src/test/coinsviewcachenonmutating_tests.cpp
     1--- a/src/test/coinsviewcachenonmutating_tests.cpp	(revision c3b34ec796a3fd6e34e0dfb862c61bc298ba8d6f)
     2+++ b/src/test/coinsviewcachenonmutating_tests.cpp	(revision 3e8676d49ecf9e3c12cd641ce655e74f85f3a6e6)
     3@@ -82,6 +82,13 @@
     4     PopulateView(block, db);
     5     CCoinsViewCache main_cache{&db};
     6     CoinsViewCacheNonMutating view{&main_cache};
     7+    const auto& outpoint{block.vtx[1]->vin[0].prevout};
     8+
     9+    BOOST_CHECK(view.HaveCoin(outpoint));
    10+    BOOST_CHECK(view.GetCoin(outpoint).has_value());
    11+    BOOST_CHECK(!main_cache.HaveCoinInCache(outpoint));
    12+
    13     CheckCache(block, view);
    14     // Check that no coins have been moved up to main cache from db
    15     for (const auto& tx : block.vtx) {
    16@@ -89,6 +96,12 @@
    17             BOOST_CHECK(!main_cache.HaveCoinInCache(in.prevout));
    18         }
    19     }
    20+
    21+    view.SetBestBlock(uint256::ONE);
    22+    BOOST_CHECK(view.SpendCoin(outpoint));
    23+    view.Flush();
    24+    BOOST_CHECK(!main_cache.PeekCoin(outpoint).has_value());
    25 }
    26 
    27 BOOST_AUTO_TEST_CASE(fetch_inputs_from_cache)
    
  26. l0rinc changes_requested
  27. coins: don't mutate main cache when connecting block
    Introduce `CoinsViewCacheNonMutating`, a `CCoinsViewCache` subclass that reads
    coins without mutating the underlying cache via `FetchCoin()`.
    
    Use `PeekCoin()` to look up a Coin through a stack of `CCoinsViewCache` layers without populating parent caches. This prevents the main cache from caching inputs pulled from disk for a block that has not yet been fully validated. Once `Flush()` is called on the view, these inputs will be added as spent to `coinsCache` in the main cache via `BatchWrite()`.
    
    This is the foundation for async input fetching, where worker threads must not
    mutate shared state.
    
    Co-authored-by: l0rinc <pap.lorinc@gmail.com>
    2e67c5b604
  28. andrewtoth force-pushed on Jan 10, 2026
  29. l0rinc commented at 0:53 am on January 11, 2026: contributor

    Ran a reindex-chainstate, no measurable change compared to master: πŸ‘

    ACK 2e67c5b604d2cf2e56db6b5d2906391d22113941


github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-01-18 03:13 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me