Merge bitcoin/bitcoin#33512: coins: use dirty entry count for flush warnings and disk space checks

afb1bc120e validation: Use dirty entry count in flush warnings and disk space checks (Pieter Wuille)
b413491a1c coins: Keep track of number of dirty entries in `CCoinsViewCache` (Pieter Wuille)
7e52b1b945 fuzz: call `EmplaceCoinInternalDANGER` as well in `SimulationTest` (Lőrinc)

Pull request description:

  ### Problem
  Now that non-wiping flushes are possible (#28280, #28233), the cache may be mostly clean at flush time.
  But the flush warning, disk-space check, and benchmark logging still used total cache size, so a node with a 10 GiB cache that only needs to write a small fraction of dirty entries could still trigger a scary warning via the disk-space checks.

  The previous `DynamicMemoryUsage` metric was also fundamentally wrong for estimating disk writes, even before non-wiping flushes. In-memory coin size differs from on-disk write size due to LevelDB overhead, log doubling, and compaction.

  The warning also only fired in `FlushStateToDisk`, so `AssumeUTXO` snapshot loads never warned at all.

  ### Fix

  This PR tracks the actual number of dirty entries via `m_dirty_count` in `CCoinsViewCache`, maintained alongside the existing dirty-flag linked list, `SanityCheck` cross-validating both counts.

  The warning and benchmark log move from `FlushStateToDisk` down to `CCoinsViewDB::BatchWrite`, where the actual I/O happens. This is the single place all flush paths converge (regular flushes, syncs, and snapshot loads), so the warning now fires correctly for `AssumeUTXO` too.
  The threshold changes from 1 GiB of memory to 10 million dirty entries, which is roughly equivalent but avoids the in-memory vs on-disk size confusion.

  The disk-space safety check now uses `GetDirtyCount()` with the existing conservative 48-byte-per-entry estimate, preventing unnecessary shutdowns when the cache is large but mostly clean.

  ---

  Note: the first commit adds fuzz coverage for `EmplaceCoinInternalDANGER` in `SimulationTest` to exercise the accounting paths before modifying them.
  Note: this is a revival of #31703 with all outstanding review feedback addressed.

ACKs for top commit:
  Eunovo:
    Concept ACK afb1bc120e
  andrewtoth:
    re-ACK afb1bc120e
  sipa:
    Code review ACK afb1bc120e
  sedited:
    ACK afb1bc120e

Tree-SHA512: 4133c6669fd20836ae2fb62ed804cdf6ebaa61076927b54fc412e42455a2f0d4cadfab0844064f9c32431eacb1f5e47b78de8e5cde1b26ba7239a7becf92f369
This commit is contained in:
merge-script
2026-02-19 22:18:38 +01:00
6 changed files with 68 additions and 26 deletions

View File

@@ -95,6 +95,7 @@ public:
CCoinsMap& map() const { return cacheCoins; }
CoinsCachePair& sentinel() const { return m_sentinel; }
size_t& usage() const { return cachedCoinsUsage; }
size_t& dirty() const { return m_dirty_count; }
};
} // namespace
@@ -189,8 +190,11 @@ void SimulationTest(CCoinsView* base, bool fake_best_block)
(coin.IsSpent() ? added_an_entry : updated_an_entry) = true;
coin = newcoin;
}
bool is_overwrite = !coin.IsSpent() || m_rng.rand32() & 1;
stack.back()->AddCoin(COutPoint(txid, 0), std::move(newcoin), is_overwrite);
if (COutPoint op(txid, 0); !stack.back()->map().contains(op) && !newcoin.out.scriptPubKey.IsUnspendable() && m_rng.randbool()) {
stack.back()->EmplaceCoinInternalDANGER(std::move(op), std::move(newcoin));
} else {
stack.back()->AddCoin(op, std::move(newcoin), /*possible_overwrite=*/!coin.IsSpent() || m_rng.randbool());
}
} else {
// Spend the coin.
removed_an_entry = true;
@@ -653,8 +657,10 @@ static void WriteCoinsViewEntry(CCoinsView& view, const MaybeCoin& cache_coin)
CCoinsMapMemoryResource resource;
CCoinsMap map{0, CCoinsMap::hasher{}, CCoinsMap::key_equal{}, &resource};
if (cache_coin) InsertCoinsMapEntry(map, sentinel, *cache_coin);
auto cursor{CoinsViewCacheCursor(sentinel, map, /*will_erase=*/true)};
size_t dirty_count{cache_coin && cache_coin->IsDirty()};
auto cursor{CoinsViewCacheCursor(dirty_count, sentinel, map, /*will_erase=*/true)};
view.BatchWrite(cursor, {});
BOOST_CHECK_EQUAL(dirty_count, 0U);
}
class SingleEntryCacheTest
@@ -664,7 +670,10 @@ public:
{
auto base_cache_coin{base_value == ABSENT ? MISSING : CoinEntry{base_value, CoinEntry::State::DIRTY}};
WriteCoinsViewEntry(base, base_cache_coin);
if (cache_coin) cache.usage() += InsertCoinsMapEntry(cache.map(), cache.sentinel(), *cache_coin);
if (cache_coin) {
cache.usage() += InsertCoinsMapEntry(cache.map(), cache.sentinel(), *cache_coin);
cache.dirty() += cache_coin->IsDirty();
}
}
CCoinsView root;
@@ -1125,6 +1134,7 @@ BOOST_AUTO_TEST_CASE(ccoins_reset_guard)
const Coin coin{CTxOut{m_rng.randrange(10), CScript{} << m_rng.randbytes(CScriptBase::STATIC_SIZE + 1)}, 1, false};
cache.EmplaceCoinInternalDANGER(COutPoint{outpoint}, Coin{coin});
BOOST_CHECK_EQUAL(cache.GetDirtyCount(), 1U);
uint256 cache_best_block{m_rng.rand256()};
cache.SetBestBlock(cache_best_block);
@@ -1134,12 +1144,14 @@ BOOST_AUTO_TEST_CASE(ccoins_reset_guard)
BOOST_CHECK(cache.AccessCoin(outpoint) == coin);
BOOST_CHECK(!cache.AccessCoin(outpoint).IsSpent());
BOOST_CHECK_EQUAL(cache.GetCacheSize(), 1);
BOOST_CHECK_EQUAL(cache.GetDirtyCount(), 1);
BOOST_CHECK_EQUAL(cache.GetBestBlock(), cache_best_block);
BOOST_CHECK(!root_cache.HaveCoinInCache(outpoint));
}
BOOST_CHECK(cache.AccessCoin(outpoint).IsSpent());
BOOST_CHECK_EQUAL(cache.GetCacheSize(), 0);
BOOST_CHECK_EQUAL(cache.GetDirtyCount(), 0);
BOOST_CHECK_EQUAL(cache.GetBestBlock(), base_best_block);
BOOST_CHECK(!root_cache.HaveCoinInCache(outpoint));
@@ -1150,8 +1162,13 @@ BOOST_AUTO_TEST_CASE(ccoins_reset_guard)
BOOST_CHECK(cache.AccessCoin(outpoint).IsSpent());
BOOST_CHECK_EQUAL(cache.GetCacheSize(), 0);
BOOST_CHECK_EQUAL(cache.GetDirtyCount(), 0U);
BOOST_CHECK_EQUAL(cache.GetBestBlock(), base_best_block);
BOOST_CHECK(!root_cache.HaveCoinInCache(outpoint));
// Flush should be a no-op after reset.
cache.Flush();
BOOST_CHECK_EQUAL(cache.GetDirtyCount(), 0U);
}
BOOST_AUTO_TEST_SUITE_END()

View File

@@ -139,6 +139,7 @@ void TestCoinsView(FuzzedDataProvider& fuzzed_data_provider, CCoinsView& backend
[&] {
CoinsCachePair sentinel{};
sentinel.second.SelfRef(sentinel);
size_t dirty_count{0};
CCoinsMapMemoryResource resource;
CCoinsMap coins_map{0, SaltedOutpointHasher{/*deterministic=*/true}, CCoinsMap::key_equal{}, &resource};
LIMITED_WHILE(good_data && fuzzed_data_provider.ConsumeBool(), 10'000)
@@ -159,10 +160,11 @@ void TestCoinsView(FuzzedDataProvider& fuzzed_data_provider, CCoinsView& backend
auto it{coins_map.emplace(random_out_point, std::move(coins_cache_entry)).first};
if (dirty) CCoinsCacheEntry::SetDirty(*it, sentinel);
if (fresh) CCoinsCacheEntry::SetFresh(*it, sentinel);
dirty_count += dirty;
}
bool expected_code_path = false;
try {
auto cursor{CoinsViewCacheCursor(sentinel, coins_map, /*will_erase=*/true)};
auto cursor{CoinsViewCacheCursor(dirty_count, sentinel, coins_map, /*will_erase=*/true)};
uint256 best_block{coins_view_cache.GetBestBlock()};
if (fuzzed_data_provider.ConsumeBool()) best_block = ConsumeUInt256(fuzzed_data_provider);
// Set best block hash to non-null to satisfy the assertion in CCoinsViewDB::BatchWrite().