Skip to content

Instantly share code, notes, and snippets.

@LarryRuane
Created July 26, 2026 17:52
Show Gist options
  • Select an option

  • Save LarryRuane/e7389a3448e25caa7765f37a0765f37b to your computer and use it in GitHub Desktop.

Select an option

Save LarryRuane/e7389a3448e25caa7765f37a0765f37b to your computer and use it in GitHub Desktop.
claude - opus conversation July 2026
╭─── Claude Code v2.1.218 ─────────────────────────────────────────────────────╮
│ │ Tips for getting │
│ Welcome back Larry Michael! │ started │
│ │ Run /init to create a … │
│ ▐▛███▜▌ │ ─────────────────────── │
│ ▝▜█████▛▘ │ What's new │
│ ▘▘ ▝▝ │ Changed `/code-review`… │
│ Fable 5 · Claude Max · larryruane@gmail.com's │ Added screen-reader an… │
│ Organization │ Fixed Windows paths wi… │
│ /sd/g/w/bitcoin-spentdb │ /release-notes for more │
╰──────────────────────────────────────────────────────────────────────────────╯
▎ Fable 5 is now a standard part of your Max plan
▎ You can use up to 50% of your weekly usage limit on Fable 5. If you hit your limit, you can continue on Fable 5 with usage credits. Fable 5 draws down usage faster than Opus 4.8. Run /model and select Fable to use it. Learn more: https://support.claude.com/en/articles/15424964-claude-fable-5-promotional-access
❯ i'm trying to make a PR here (the unstaged files) to improve initial block
download (IBD) by making the dbcache (memory cache of UTXOs, unspent coins)
more efficient. this cache actually contains many "spent" coins, which are
needed to cause the coin to be deleted when we flush the cache to disk
(leveldb). the coin is spent in this cache, but still exists in leveldb, and
we need to delete it there too, when we flush the cache. currently (without
this patch), these spent coins use the same amount of memory as an unspent
coin. I'm attempting to store spent coins in a compressed or compact fashion,
because all we really need for spent coins is the coin's identity (its key,
i.e., its COutPoint). so I'm storing them in a vector, so there's very little
memory overhead, actually zero extra allocation overhead, the memory usage
is just 36 bytes (32+4), the minimum. But now when we look for a coin, and we
don't find it in the main map (cacheCoins), we have to look for it in this
vector, and if not found there, then we go down to leveldb to try to find it
there. this extra search in the vector (m_compact_spents) requires, for any
reasonble performance, the vector to be in sorted order, then we just use
binary search. the code as it is now is somewhat faster according to my own
benchmarks, but I think this binary search could be implemented in a faster
way, I think fewer CPU cache misses. So can I ask you to do 2 things, first,
check if there are any bugs in what i have now, and also, help me figure out
a more efficient way to search this sorted vector.
● I'll start by looking at the actual changes, then review for bugs and think
about the search optimization.
Read 1 file, ran 2 shell commands (ctrl+o to expand)
❯ oh and also, i haven't got the fuzzing to compile yet
Searched for 2 patterns (ctrl+o to expand)
❯ but this does run, the functional and unit tests both pass, and i've synched
the mainnet chain successfully ... I just would like to make it faster
Searched for 2 patterns, read 4 files, listed 2 directories (ctrl+o to expand)
● Update(src/coins.cpp)
⎿  User rejected update to src/coins.cpp
● Update(src/coins.cpp)
⎿  User rejected update to src/coins.cpp
✻ Cooked for 54m 37s
❯ oh right, i commented out the calls to IsCompactSpent in the two places it's
called because i wanted to benchmark how much overhead there is in doing the
binary search, and it's significant. so of course those lines will have to be
commented back in. do you see any other bugs?
Searched for 2 patterns, read 1 file, ran 1 shell command (ctrl+o to expand)
● Yes — several. I went through the whole diff carefully (the linked-list
handling, BatchWrite merge logic, and flush ordering actually hold up well —
Gemini's Merge() is correct, and the erase-spents-before-writing-cursor
ordering in CCoinsViewDB::BatchWrite is right). Here's everything else I
found, roughly by severity.
Real bugs
1. The new constructor parameter silently breaks 5 existing call sites. You
added initial_compact_spents as the second parameter, but these callers pass
deterministic positionally, and bool converts silently to size_t:
- src/test/fuzz/coins_view.cpp:357, 370, 381 — CCoinsViewCache{&x,
/*deterministic=*/true}
- src/test/fuzz/coinscache_sim.cpp:245, 376 — new CCoinsViewCache(&bottom,
/*deterministic=*/true)
In all five, true now binds to initial_compact_spents (=1) and deterministic
becomes false. So the fuzzers silently lose their deterministic salted hasher
(breaking fuzz reproducibility) and get compaction enabled with limit 1
unintentionally. This compiles without a peep — it's the nastiest kind of API
change. Recommendation: don't make it a constructor parameter at all. Your own
LMR comment in validation.cpp:InitCache already points the way — InitCache
doesn't know the cache size, so add a setter like EnableCompactSpents(size_t)
called after construction. That eliminates this entire hazard class and the
call-site churn.
2. Fuzz compile errors (this is why fuzzing doesn't build), all in
src/test/fuzz/coins_view.cpp:
- Lines 215–216: CCoinsCacheEntry::SetDirty(*it, sentinel) / SetFresh(*it,
sentinel) — sentinel is now a CoinsSentinel, but these take CoinsCachePair&.
Needs sentinel.unspent.
- Line 224: coins_view_cache.BatchWrite(cursor, spents, best_block) — spents
is never declared in that lambda. Needs a CompactSpentsList spents; (and
ideally the fuzzer should sometimes populate it for coverage — the receiving
BatchWrite doesn't require it sorted, so arbitrary outpoints are fine).
3. m_spent_count undercount can crash CompactSpents (your known ccoins_spend
issue, but worse than an Assume). When InsertCoinsMapEntry
(coins_tests.cpp:646) inserts a SPENT+DIRTY entry raw, m_spent_count stays 0.
Beyond the failed Assume(TrySub(...)), note the knock-on: CompactSpents does
new_spents.reserve(m_spent_count) and then assert(new_spents.size() <
new_spents.capacity()) — an undercounted m_spent_count makes that a hard
assert failure (and Bitcoin Core always compiles with asserts on). My
suggested fix, three parts:
- InsertCoinsMapEntry takes CoinsSentinel& and links spent+dirty entries onto
sentinel.spent instead of unspent.
- SingleEntryCacheTest bumps a new spent_count() accessor (mirroring the
existing dirty()/usage() pattern) for spent+dirty cache coins.
- Change the production Assume(TrySub(m_spent_count,
it->second.coin.IsSpent())) sites to IsSpent() && IsDirty(). In production
every spent map entry is dirty, so it's equivalent — but it makes the counter
immune to the test-only SPENT_CLEAN/SPENT_FRESH states that never go through
SetSpent.
4. The single-entry matrix tests and compaction don't mix. CCoinsViewCacheTest
hardcodes initial_compact_spents=1, so in SingleEntryCacheTest flows a
SpendCoin can immediately compact the entry out of the map — then
GetCoinsMapEntry sees MISSING where the test expects SPENT_DIRTY. Relatedly,
SelfTest still does BOOST_CHECK_EQUAL(GetCacheSize(), count) where count only
counts map entries, but GetCacheSize() now includes m_compact_spents.size() —
that comparison fails any time the vector is non-empty at check time. I'd give
CCoinsViewCacheTest a constructor parameter: matrix tests
(SingleEntryCacheTest) use 0, the big simulation tests use 1. Worth actually
running coins_tests in a Debug (not RelWithDebInfo) build once the calls are
uncommented — Assume only aborts there, and I suspect some of the "passing"
you're seeing is Assume failing silently in RelWithDebInfo.
Correct, but fragile — document or harden
5. AddCoin on a compact-spent outpoint marks the new entry FRESH (try_emplace
inserts → fresh = !dirty = true), even though the coin still exists in
leveldb. This is correct, but only because of two unstated invariants: (a)
vector entries are never removed except at flush, so the pending erase
survives even if the FRESH entry is later spent and dropped from the map; (b)
every BatchWrite implementation must apply spents erases before cursor writes.
Both hold today (you got this right in both CCoinsViewDB::BatchWrite and
CCoinsViewCache::BatchWrite), but nothing enforces (b) for future
implementations — the CCoinsView::BatchWrite doc comment in coins.h:361 should
state it as a hard requirement: "map/cursor entries always supersede spents
entries; implementations must process spents first."
6. Duplicate outpoints can enter m_compact_spents. Spend X → compact → re-add
X with possible_overwrite=true (pre-BIP34-style) → spend again → compact
again: std::merge keeps both copies. It's benign (binary_search works on
dupes, double batch.Erase is idempotent) but memory is double-counted and it's
a latent surprise. Cheap fix: use std::ranges::set_union instead of
std::merge in CompactSpents — it drops cross-range duplicates and self-heals.
Performance nits in your new code
- txdb.cpp try_write_batch takes std::string batch_kind by value — that
constructs a std::string per coin in the flush hot loop. Make it const char*
or std::string_view.
- LogDebug(BCLog::COINDB, "begin writing coins"); is missing \n (and "Lookups
%u" too).
Debug leftovers to strip before the PR
lmr_debug asserts; m_lookups (also an unsynchronized mutable increment from
const methods); COutPoint::ToString 64-char change (transaction.cpp);
time.sleep(10) in feature_block.py; NUM_SIMULATION_ITERATIONS 40k→400k; the
lmrcount hack in CheckWriteCoins; the CLOSE/NearFull() experiment in
validation.{h,cpp} (dead code); //FreeSpents();; the various .diff/debug files
in the repo root.
Making the lookup faster
Since your benchmark says the binary search overhead is significant, here's
how I'd attack it. The problem with std::binary_search here: ~23 probes over a
385 MB array of 36-byte keys ≈ 10–20 DRAM misses per lookup (the top few tree
levels stay cached, the rest don't). And the dominant caller is a negative
lookup — an outpoint that is not in the vector, on its way to leveldb.
Best fix: a blocked Bloom filter in front (your own bloom instinct is right —
this is the concrete design). The key insight that makes it clean:
m_compact_spents is immutable between compactions — it only changes inside
CompactSpents() and ReallocCompactSpents(). So:
- Rebuild the filter inside CompactSpents (one O(n) pass over the merged
vector) and clear it in ReallocCompactSpents. No incremental maintenance, no
deletions.
- Blocked layout: one 64-byte cache line per query — pick the block from some
txid bits, set/test ~8 bits within it from other txid bits. A negative lookup
becomes one memory access instead of 10–20.
- No hash function needed in principle (txids are uniform SHA256), but since
attackers can grind txids (same reason SaltedOutpointHasher exists), mix in
the existing salt — worst case a ground txid just causes a false positive →
you fall back to binary search, so it degrades gracefully, no DoS.
- ~2 bytes/key ⇒ ~21 MB for your observed 10.7M max (vs. 385 MB for the vector
itself), FP rate well under 1%. Count it in DynamicMemoryUsage().
- Correctness constraint: false positives are fine (you just do the binary
search and get the right answer); false negatives would be a consensus bug (an
existing leveldb coin reported spent — no wait, the dangerous direction is a
compacted spent reported unspent). Bloom filters guarantee no false negatives,
which is exactly the guarantee you need.
Secondary option (compatible with the above) if you want the search itself
faster: build a parallel std::vector<uint64_t> of each key's first 8 bytes
during the merge, and binary-search that. It's 86 MB instead of 385 MB, 8 keys
per cache line, so far more of the search tree stays cached; you only touch
the full vector to confirm the final match (64-bit prefix collisions at 10M
keys are essentially nonexistent, but handle equal-prefix runs correctly). If
you want to go further, lay that prefix array out in Eytzinger (BFS) order
with __builtin_prefetch a few levels ahead — the classic Khuong & Morin
result, ~2–3× over std::binary_search. I'd skip fancy S+-tree layouts: with
36-byte keys you get <2 keys per cache line, so the prefix array captures most
of the available win with much less code.
My recommendation: bloom filter only. It targets the actual hot case
(negatives), it's ~50 lines, and its rebuild cost hides inside the sort/merge
you're already paying for.
Want me to apply the fuzz fixes and the test-accounting fix, and/or implement
the blocked bloom filter so you can benchmark it?
✻ Cogitated for 4m 26s
※ recap: Reviewing your compact-spents dbcache patch for bugs and lookup speed: found the constructor-arg misbinding in 5 fuzz call sites, fuzz compile errors, and m_spent_count test issues. Next: say the word and I'll apply the fuzz fixes and bloom filter. (disable recaps in /config)
❯ for problem 4, please change initial_compact_spents to be 0, we'll figure
that out later
● Update(src/test/coins_tests.cpp)
⎿  Added 1 line, removed 1 line
84 class CCoinsViewCacheTest : public CCoinsViewCache
85 {
86 public:
87 - explicit CCoinsViewCacheTest(CCoinsView* _base) : CCoinsVie
-wCache(_base, /*initial_compact_spents=*/1) {}
87 + explicit CCoinsViewCacheTest(CCoinsView* _base) : CCoinsVie
+wCache(_base, /*initial_compact_spents=*/0) {}
88
89 void SelfTest(bool sanity_check = true) const
90 {
● User declined to answer questions
⎿  · Which of the remaining items should this plan cover? (The
initial_compact_spents=0 test change is already done.) (Fuzz compile fixes
/ Re-enable IsCompactSpent / m_spent_count test fix / Blocked bloom filter)
· How should compaction be enabled to avoid the silent bool→size_t
constructor hazard? (Setter method (Recommended) / Keep constructor param)
● What would you like to clarify about those questions? For example — whether
you'd rather split this into separate steps, something about the bloom filter
approach, or details of the setter-vs-constructor tradeoff. Let me know what's
on your mind and I'll reshape the plan (and the questions) accordingly.
✻ Cogitated for 32s
❯ i don't want to make a major change just yet, nothing regarding bloom filter.
for now, please fix the 1, 2, 3 only
● Updated plan
⎿  /plan to preview
● User approved Claude's plan
⎿  Plan saved to: ~/.claude/plans/noble-roaming-eich.md · /plan to edit
Fix compact-spents branch: fuzz compilation, re-enable checks,
m_spent_count test accounting
Context
The working branch (2026-07-compact-spents) stores spent coins compactly in
a sorted
vector (m_compact_spents) instead of full cacheCoins map entries, to make
the dbcache
more memory-efficient during IBD. A review found three sets of problems to
fix now
(bloom filter / API refactor deliberately deferred):
1. Fuzz targets don't compile, and 5 call sites silently misbind the new
constructor parameter (bool true → size_t initial_compact_spents=1, losing
deterministic=true).
2. The two IsCompactSpent() calls are commented out (left over from
benchmarking). Without them, a compact-spent coin falls through to leveldb
and
comes back as unspent — double-spend rejection breaks (feature_block.py and
coins_tests fail).
3. m_spent_count accounting breaks in unit tests: InsertCoinsMapEntry
inserts
SPENT+DIRTY entries raw without bumping m_spent_count, so later
Assume(TrySub(...))
guards fail, and an undercount can trip
assert(new_spents.size() < new_spents.capacity()) in CompactSpents().
Already done: CCoinsViewCacheTest now uses initial_compact_spents=0
(coins_tests.cpp:87).
Changes
1. Fuzz compile fixes
src/test/fuzz/coins_view.cpp
- Lines 215–216: CCoinsCacheEntry::SetDirty(*it, sentinel) / SetFresh(*it,
sentinel)
→ pass sentinel.unspent (they take CoinsCachePair&; sentinel is now
CoinsSentinel).
- Near line 219 (in the same lambda): declare CompactSpentsList spents; —
it is used
at line 224 (coins_view_cache.BatchWrite(cursor, spents, best_block)) but
never declared.
- Lines 357, 370, 381: {&x, /*deterministic=*/true} →
{&x, /*initial_compact_spents=*/0, /*deterministic=*/true} (restores
determinism;
0 preserves pre-branch behavior).
src/test/fuzz/coinscache_sim.cpp
- Lines 245, 376: same fix — new CCoinsViewCache(&base,
/*initial_compact_spents=*/0, /*deterministic=*/true).
2. Re-enable the IsCompactSpent checks
src/coins.cpp
- PeekCoin (~line 46): uncomment the if (IsCompactSpent(outpoint)) return
std::nullopt; block.
- FetchCoin (~line 83): uncomment the
if (IsCompactSpent(outpoint)) { cacheCoins.erase(ret); return
cacheCoins.end(); } block.
- Keep the surrounding LMR commentary untouched; only remove the /* */.
3. m_spent_count test accounting fix
src/test/coins_tests.cpp
- InsertCoinsMapEntry (line 646): change parameter from CoinsCachePair&
sentinel to
CoinsSentinel& sentinel. Link the entry onto sentinel.spent when the coin
is
spent AND dirty (cache_coin.value == SPENT && cache_coin.IsDirty()), else
sentinel.unspent (test-only states SPENT_CLEAN / SPENT_FRESH stay on the
unspent
list and are deliberately not counted).
- CCoinsViewCacheTest::sentinel() accessor (line ~106): return
CoinsSentinel&
(whole struct) instead of m_sentinel.unspent; update its call sites in this
file.
- Add accessor size_t& spent_count() const { return m_spent_count; } to
CCoinsViewCacheTest, mirroring the existing dirty() / usage() accessors.
- SingleEntryCacheTest ctor (line ~692): after inserting into cache.map(),
add
cache.spent_count() += cache_coin->IsDirty() && cache_coin->value == SPENT;
- WriteCoinsViewEntry (line 676): pass the whole sentinel instead of
sentinel.unspent.
src/coins.cpp — make the counter guards immune to test-only states
(equivalent in
production, where every spent map entry is dirty). Change
Assume(TrySub(m_spent_count, <entry>.coin.IsSpent())) →
Assume(TrySub(m_spent_count, <entry>.coin.IsSpent() && <entry>.IsDirty()))
at all
sites: AddCoin, SpendCoin, and the two places in
CCoinsViewCache::BatchWrite.
(Note: order matters — evaluate before the entry is cleared/overwritten, as
the code
already does.)
Verification
1. Rebuild and run the unit tests:
cmake --build build -j$(nproc) -t test_bitcoin then
./build/bin/test_bitcoin --run_test=coins_tests (also
coinscachepair_tests).
Expect ccoins_spend and the simulation tests to pass with the checks
re-enabled.
2. Fuzz compilation (the original blocker): configure a separate fuzz
build,
cmake -B build_fuzz -DBUILD_FOR_FUZZING=ON (clang), then build the fuzz
target;
run the coins_view and coinscache_sim targets briefly with a few seconds of
random input to confirm no immediate assertion failures.
3. Functional test that exercises double-spend rejection through the
compact-spents
path: build/test/functional/feature_block.py.
(Note: it currently contains a leftover time.sleep(10) — not removed by
this plan
unless requested.)
● Starting implementation. First, re-enabling the two IsCompactSpent checks in
coins.cpp:
● Update(src/coins.cpp)
⎿  Removed 2 lines
43 //
44 // LMR I actually did comment out this call (and the one be
low) and the functional test feature_block.py
45 // fails, and the coins_tests unit test; I believe those ar
e the only mutants.
46 - /*
46 if (IsCompactSpent(outpoint)) {
47 return std::nullopt;
48 }
50 - */
49 return base->PeekCoin(outpoint);
50 }
51
● Update(src/coins.cpp)
⎿  Removed 2 lines
78 if (inserted) {
79 // LMR here is the second of 2 places we must check to
see if there's a spent coin in the
80 // vector (this is a binary search).
81 - /*
81 if (IsCompactSpent(outpoint)) {
82 cacheCoins.erase(ret);
83 return cacheCoins.end();
84 }
86 - */
85 if (auto coin{FetchCoinFromBase(outpoint)}) {
86 ret->second.coin = std::move(*coin);
87 cachedCoinsUsage += ret->second.coin.DynamicMemoryU
sage();
● Now the fuzz fixes:
● Update(src/test/fuzz/coins_view.cpp)
⎿  Added 2 lines, removed 2 lines
212 bool fresh{!coins_view_cache.PeekCoin(rand
om_out_point) && fuzzed_data_provider.ConsumeBool()};
213 bool dirty{fresh || fuzzed_data_provider.C
onsumeBool()};
214 auto it{coins_map.emplace(random_out_point
, std::move(coins_cache_entry)).first};
215 - if (dirty) CCoinsCacheEntry::SetDirty(*it,
- sentinel);
216 - if (fresh) CCoinsCacheEntry::SetFresh(*it,
- sentinel);
215 + if (dirty) CCoinsCacheEntry::SetDirty(*it,
+ sentinel.unspent);
216 + if (fresh) CCoinsCacheEntry::SetFresh(*it,
+ sentinel.unspent);
217 dirty_count += dirty;
218 }
219 auto cursor{CoinsViewCacheCursor(dirty_count,
sentinel, coins_map, /*will_erase=*/true)};
● Update(src/test/fuzz/coins_view.cpp)
⎿  Added 1 line
217 dirty_count += dirty;
218 }
219 auto cursor{CoinsViewCacheCursor(dirty_count,
sentinel, coins_map, /*will_erase=*/true)};
220 + CompactSpentsList spents;
221 uint256 best_block{coins_view_cache.GetBestBlo
ck()};
222 if (fuzzed_data_provider.ConsumeBool()) best_b
lock = ConsumeUInt256(fuzzed_data_provider);
223 // Set best block hash to non-null to satisfy
the assertion in CCoinsViewDB::BatchWrite().
Ran 2 shell commands (ctrl+o to expand)
● Now coinscache_sim.cpp:
● Update(src/test/fuzz/coinscache_sim.cpp)
⎿  Added 1 line, removed 1 line
242 ++current_height;
243 // Make sure there is always at least one CCoinsViewCa
che.
244 if (caches.empty()) {
245 - caches.emplace_back(new CCoinsViewCache(&bottom, /
-*deterministic=*/true));
245 + caches.emplace_back(new CCoinsViewCache(&bottom, /
+*initial_compact_spents=*/0, /*deterministic=*/true));
246 sim_caches[caches.size()].Wipe();
247 }
248
● Update(src/test/fuzz/coinscache_sim.cpp)
⎿  Added 1 line, removed 1 line
373 if (caches.size() != MAX_CACHES) {
374 // Apply to real caches.
375 if (provider.ConsumeBool()) {
376 - caches.emplace_back(new CCoinsViewCach
-e(&*caches.back(), /*deterministic=*/true));
376 + caches.emplace_back(new CCoinsViewCach
+e(&*caches.back(), /*initial_compact_spents=*/0, /*determinist
+ic=*/true));
377 } else {
378 caches.emplace_back(new CoinsViewOverl
ay(&*caches.back(), /*deterministic=*/true));
379 }
Searched for 1 pattern (ctrl+o to expand)
● Update(src/test/coins_tests.cpp)
⎿  Added 2 lines, removed 1 line
103 }
104
105 CCoinsMap& map() const { return cacheCoins; }
106 - CoinsCachePair& sentinel() const { return m_sentinel.unspe
-nt; }
106 + CoinsSentinel& sentinel() const { return m_sentinel; }
107 + size_t& spent_count() const { return m_spent_count; }
108 size_t& usage() const { return cachedCoinsUsage; }
109 size_t& dirty() const { return m_dirty_count; }
110 };
● Update(src/test/coins_tests.cpp)
⎿  Added 9 lines, removed 7 lines
644 }
645 }
646
647 -static size_t InsertCoinsMapEntry(CCoinsMap& map, CoinsCachePa
-ir& sentinel, const CoinEntry& cache_coin)
647 +static size_t InsertCoinsMapEntry(CCoinsMap& map, CoinsSentine
+l& sentinel, const CoinEntry& cache_coin)
648 {
649 CCoinsCacheEntry entry;
650 SetCoinsValue(cache_coin.value, entry.coin);
651 - // LMR ccoins_spend fails because here we can add a SPENT
-coin to the map
652 - // directly (instead of calling AddCoin), without bumping
-m_spent_count. So
653 - // later when we spend this spent coin, we try to decremen
-t m_spent_count but
654 - // it's still zero (so we assert). Not sure what's the bes
-t way to fix this.
651 auto [iter, inserted] = map.emplace(OUTPOINT, std::move(en
try));
652 assert(inserted);
657 - if (cache_coin.IsDirty()) CCoinsCacheEntry::SetDirty(*iter
-, sentinel);
658 - if (cache_coin.IsFresh()) CCoinsCacheEntry::SetFresh(*iter
-, sentinel);
653 + // Spent coins that are dirty belong on the spent list, ma
+tching the
654 + // production invariant maintained by CCoinsViewCache::Set
+Spent(). The
655 + // test-only states SPENT_CLEAN and SPENT_FRESH cannot occ
+ur in production
656 + // and are deliberately kept off the spent list (and not c
+ounted).
657 + const bool spent{cache_coin.value == SPENT && cache_coin.I
+sDirty()};
658 + CoinsCachePair& list{spent ? sentinel.spent : sentinel.uns
+pent};
659 + if (cache_coin.IsDirty()) CCoinsCacheEntry::SetDirty(*iter
+, list);
660 + if (cache_coin.IsFresh()) CCoinsCacheEntry::SetFresh(*iter
+, list);
661 return iter->second.coin.DynamicMemoryUsage();
662 }
663
● Update(src/test/coins_tests.cpp)
⎿  Added 1 line, removed 1 line
676 CoinsSentinel sentinel{};
677 CCoinsMapMemoryResource resource;
678 CCoinsMap map{0, CCoinsMap::hasher{}, CCoinsMap::key_equal
{}, &resource};
679 - if (cache_coin) InsertCoinsMapEntry(map, sentinel.unspent,
- *cache_coin);
679 + if (cache_coin) InsertCoinsMapEntry(map, sentinel, *cache_
+coin);
680 size_t dirty_count{cache_coin && cache_coin->IsDirty()};
681 auto cursor{CoinsViewCacheCursor(dirty_count, sentinel, ma
p, /*will_erase=*/true)};
682 CompactSpentsList spents;
● Update(src/test/coins_tests.cpp)
⎿  Added 1 line
694 if (cache_coin) {
695 cache.usage() += InsertCoinsMapEntry(cache.map(),
cache.sentinel(), *cache_coin);
696 cache.dirty() += cache_coin->IsDirty();
697 + cache.spent_count() += cache_coin->value == SPENT
+&& cache_coin->IsDirty();
698 }
699 }
700
Searched for 1 pattern, ran 2 shell commands (ctrl+o to expand)
● While that builds, let me configure the fuzz build (the actual fuzz
compilation will wait for the main build to finish so they don't fight over
cores):
Ran 1 shell command (ctrl+o to expand)
● No clang available — I'll configure with GCC and -DBUILD_FUZZ_BINARY=ON, which
builds the fuzz targets (compile verification + corpus runs, just no
libFuzzer instrumentation):
Read 1 file, ran 5 shell commands (ctrl+o to expand)
● Both fuzz translation units now compile cleanly. Checking on the main build:
Read 1 file (ctrl+o to expand)
● The main build output is piped through tail, so it only prints at completion —
I'll get notified. Meanwhile, starting the full fuzz build in the background
(fresh build dir, so it takes a while):
Ran 1 shell command (ctrl+o to expand)
● Background command "Build test_bitcoin" completed (exit code 0)
● Main build succeeded. Running the unit tests:
Ran 1 shell command (ctrl+o to expand)
● ccoins_add has 15 failures — an entry ends up DIRTY where the test expects
DIRTY|FRESH. Let me look at the failing check:
Read 1 file (ctrl+o to expand)
● Update(src/coins.cpp)
⎿  Added 4 lines, removed 2 lines
134 it->second.coin = std::move(coin);
135 // LMR since spent and unspent coins are on different list
s (different sentinels), we have to
136 // remove this coin from whatever list it might be on (I t
hink in practice it's never on any list)
137 - // and make sure it gets added to the unspent list.
137 + // and make sure it gets added to the unspent list. SetCle
+an() also clears the FRESH flag, which
138 + // AddCoin() has always preserved on overwrite, so remembe
+r it and re-apply it below.
139 + const bool was_fresh{it->second.IsFresh()};
140 it->second.SetClean();
141 CCoinsCacheEntry::SetDirty(*it, m_sentinel.unspent);
142 ++m_dirty_count;
141 - if (fresh) CCoinsCacheEntry::SetFresh(*it, m_sentinel.unsp
-ent);
143 + if (fresh || was_fresh) CCoinsCacheEntry::SetFresh(*it, m_
+sentinel.unspent);
144 cachedCoinsUsage += it->second.coin.DynamicMemoryUsage();
145 TRACEPOINT(utxocache, add,
146 outpoint.hash.data(),
Ran 1 shell command (ctrl+o to expand)
● Background command "Build fuzz binary in background" completed (exit code 0)
● Unit tests all pass now, and the fuzz binary built successfully. Running the
full unit suite quickly plus the two fuzz targets on random inputs:
Ran 7 shell commands (ctrl+o to expand)
● Background command "Rebuild fuzz binary with BUILD_FOR_FUZZING=ON" completed
(exit code 0)
❯ just to let you know a convention i use, i always use gcc for optimized
builds (the build directory), and clang for debug builds (the debug
directory) because clang works much better with vscode (code) debugging. but
my installed clang compilers include the version number, 22, so it's clang-22
and clang++-22, so please use those for the debug builds
Searched for 1 pattern, ran 2 shell commands, wrote 2 memories (ctrl+o to expand)
● Update(src/coins.cpp)
⎿  Added 9 lines, removed 5 lines
151 }
152
153 void CCoinsViewCache::EmplaceCoinInternalDANGER(COutPoint&& ou
tpoint, Coin&& coin) {
154 - // LMR we put this on the unspent list below, so let's mak
-e sure it's unspent.
155 - // I'm almost positive this can't be a spent coin, that wo
-uld make no sense.
156 - assert(!coin.IsSpent());
154 + // LMR spent coins can be emplaced here (coinsviewoverlay_
+tests does it to
155 + // simulate a parent cache containing spent entries), so t
+hey must go on the
156 + // spent list to maintain the spent-list/m_spent_count inv
+ariant.
157 const auto mem_usage{coin.DynamicMemoryUsage()};
158 auto [it, inserted] = cacheCoins.try_emplace(std::move(out
point), std::move(coin));
159 if (inserted) {
160 - CCoinsCacheEntry::SetDirty(*it, m_sentinel.unspent);
161 - ++m_dirty_count;
160 + if (it->second.coin.IsSpent()) {
161 + SetSpent(*it);
162 + } else {
163 + CCoinsCacheEntry::SetDirty(*it, m_sentinel.unspent
+);
164 + ++m_dirty_count;
165 + }
166 cachedCoinsUsage += mem_usage;
167 }
168 }
Searched for 4 patterns, ran 9 shell commands (ctrl+o to expand)
● Background command "Rebuild fuzz binary with latest coins.cpp" completed (exit
code 0)
● Update(src/test/fuzz/coinscache_sim.cpp)
⎿  Added 1 line, removed 1 line
375 if (provider.ConsumeBool()) {
376 caches.emplace_back(new CCoinsViewCach
e(&*caches.back(), /*initial_compact_spents=*/0, /*determinist
ic=*/true));
377 } else {
378 - caches.emplace_back(new CoinsViewOverl
-ay(&*caches.back(), /*deterministic=*/true));
378 + caches.emplace_back(new CoinsViewOverl
+ay(&*caches.back(), /*initial_compact_spents=*/0, /*determinis
+tic=*/true));
379 }
380 // Apply to simulation data.
381 sim_caches[caches.size()].Wipe();
Searched for 1 pattern, ran 2 shell commands (ctrl+o to expand)
● All done — everything in the plan is fixed and verified, plus two additional
real bugs that the newly-passing tests exposed.
What was fixed
1. Fuzz compilation (the original blocker):
- coins_view.cpp: SetDirty/SetFresh now take sentinel.unspent; added the
missing CompactSpentsList spents; declaration.
- The positional-argument hazard turned out to be at seven call sites, not
five — gdb caught a CoinsViewOverlay at coinscache_sim.cpp:378 that my grep
missed because it doesn't contain the string "CCoinsViewCache". All sites in
coins_view.cpp (4) and coinscache_sim.cpp (3) now pass
/*initial_compact_spents=*/0, /*deterministic=*/true explicitly. That last one
was silently constructing caches with deterministic=false, which is what
tripped the fuzzer's "global random state" detector.
2. Both IsCompactSpent() calls re-enabled in PeekCoin and FetchCoin.
3. m_spent_count accounting: InsertCoinsMapEntry now takes the whole
CoinsSentinel and links spent+dirty entries onto the spent list;
SingleEntryCacheTest bumps a new spent_count() accessor; the four
Assume(TrySub(m_spent_count, ...)) guards now use IsSpent() && IsDirty() so
test-only states can't corrupt the counter.
Two additional bugs found by the tests
- AddCoin was dropping the FRESH flag on overwrite. The new SetClean() (needed
to move entries between lists) clears FRESH, which upstream AddCoin always
preserved — all 15 ccoins_add failures were exactly the FRESH-preserving rows.
Fixed by capturing the flag before SetClean and re-applying it, same pattern
as your BatchWrite "normal modification" path.
- EmplaceCoinInternalDANGER's assert(!coin.IsSpent()) was wrong —
coinsviewoverlay_tests legitimately emplaces spent coins. But your instinct
was half right: a spent coin there would land on the unspent list and evade
m_spent_count. It now routes spent coins through SetSpent().
Verification results
- coins_tests, coinsviewoverlay_tests, coinscachepair_tests: all pass.
- Full unit suite: passes except validation_flush_tests (3625 failures) —
that's your pre-existing CLOSE/NearFull experiment in validation.{h,cpp}
returning CLOSE where the test expects OK; I left it untouched.
- feature_block.py: passes against the fully rebuilt bitcoind.
- All four coins fuzz targets (coins_view, coins_view_db, coins_view_overlay,
coinscache_sim) compile and run clean over 200 random inputs each.
One note on your compiler convention (saved to memory): the build_fuzz/
directory I created uses gcc with -DBUILD_FOR_FUZZING=ON, which executes
corpus inputs but has no libFuzzer instrumentation. For actual fuzzing you'll
want it reconfigured with -DCMAKE_C_COMPILER=clang-22
-DCMAKE_CXX_COMPILER=clang++-22 -DSANITIZERS=fuzzer — happy to set that up
when you want it.
✻ Sautéed for 30m 59s
※ recap: Working on your compact-spents dbcache branch: fixed fuzz compilation, re-enabled the IsCompactSpent checks, and fixed spent-count test accounting; all tests and fuzz targets now pass. Next: set up a clang-22 libFuzzer build if you want real fuzzing. (disable recaps in /config)
❯ i just did a git add on all modified files, just so i can see more easily the
edits you will do. so let's go forward now with the bloom filter
● Staged — good, that makes the new edits easy to see with git diff.
Implementing the blocked bloom filter now. Design recap: one 64-byte
cache-line probe answers the dominant negative lookups; false positives fall
back to the binary search (so worst case is exactly today's behavior); the
filter is rebuilt inside CompactSpents() — the only place the vector changes —
and cleared with it.
First the filter class in coins.h:
Read 1 file (ctrl+o to expand)
● Update(src/coins.h)
⎿  Added 1 line
9 #include <attributes.h>
10 #include <compressor.h>
11 #include <core_memusage.h>
12 +#include <crypto/common.h>
13 #include <memusage.h>
14 #include <primitives/transaction.h>
15 #include <serialize.h>
● Update(src/coins.h)
⎿  Added 108 lines
230 // A sorted list of spent coins
231 using CompactSpentsList = std::vector<COutPoint>;
232
233 +/**
234 + * A blocked bloom filter over the compact spents vector (m_co
+mpact_spents).
235 + *
236 + * Most IsCompactSpent() lookups are negative: the outpoint be
+ing fetched is not
237 + * a compacted spent coin, and the lookup proceeds to the base
+ view. A binary
238 + * search over the (large) sorted vector costs O(log n) scatte
+red memory reads,
239 + * most of which are cache misses. This filter answers negativ
+e lookups with a
240 + * single 64-byte cache-line probe. Positive answers (rare, an
+d including false
241 + * positives) fall back to the binary search, so a false posit
+ive only costs an
242 + * unnecessary search; false negatives cannot occur, which is
+the property that
243 + * correctness depends on.
244 + *
245 + * The filter must always describe the current contents of m_c
+ompact_spents.
246 + * That vector only changes wholesale (rebuilt in CompactSpent
+s(), cleared in
247 + * ReallocCompactSpents()), so the filter is rebuilt or cleare
+d at those same
248 + * two places, and needs no incremental deletion support.
249 + *
250 + * Each entry sets HASHES bits within a single 64-byte block;
+~16 bits per entry
251 + * of total filter space keeps the false positive rate well un
+der 1%. Sizing to
252 + * ~2 bytes per entry is small next to the 36 bytes per entry
+of the vector.
253 + */
254 +class CompactSpentsFilter
255 +{
256 + //! 64 bytes, i.e. one cache line, per block.
257 + static constexpr size_t WORDS_PER_BLOCK{8};
258 + //! Bits set/tested per entry. 7 bit positions of 9 bits e
+ach (a block holds
259 + //! 512 bits) consume 63 of the 64 bits of one hash word.
260 + static constexpr int HASHES{7};
261 + //! Target filter bits per entry, before rounding the bloc
+k count up to a
262 + //! power of two.
263 + static constexpr size_t BITS_PER_ENTRY{16};
264 +
265 + //! Salts, random unless deterministic (see SaltedOutpoint
+Hasher). The txid
266 + //! part of an outpoint is already uniformly distributed,
+but is chosen by
267 + //! peers; the salt keeps an attacker from grinding txids
+that concentrate
268 + //! in one block. Even a successful grinding attack only r
+aises the false
269 + //! positive rate, degrading to the pre-filter behavior (b
+inary search).
270 + const uint64_t m_k0, m_k1;
271 + std::vector<uint64_t> m_words;
272 + uint64_t m_block_mask{0};
273 +
274 + static uint64_t Mix(uint64_t x) noexcept
275 + {
276 + // splitmix64 finalizer
277 + x ^= x >> 30; x *= 0xbf58476d1ce4e5b9ULL;
278 + x ^= x >> 27; x *= 0x94d049bb133111ebULL;
279 + x ^= x >> 31;
280 + return x;
281 + }
282 +
283 + //! Two independent 64-bit hashes: one selects the block,
+one supplies the
284 + //! bit positions within it.
285 + std::pair<uint64_t, uint64_t> Hash(const COutPoint& outpoi
+nt) const noexcept
286 + {
287 + const uint64_t h1{Mix((ReadLE64(outpoint.hash.data())
++ outpoint.n) ^ m_k0)};
288 + const uint64_t h2{Mix((ReadLE64(outpoint.hash.data() +
+ 8) - outpoint.n) ^ m_k1)};
289 + return {h1, h2};
290 + }
291 +
292 +public:
293 + explicit CompactSpentsFilter(bool deterministic);
294 +
295 + //! Clear the filter and size it for the given number of e
+ntries (0 frees all
296 + //! memory). Insert() may then be called for each entry.
297 + void Reset(size_t count)
298 + {
299 + m_words.clear();
300 + m_words.shrink_to_fit();
301 + m_block_mask = 0;
302 + if (count == 0) return;
303 + const size_t min_blocks{(count * BITS_PER_ENTRY + 511)
+ / 512};
304 + size_t blocks{1};
305 + while (blocks < min_blocks) blocks <<= 1;
306 + m_words.assign(blocks * WORDS_PER_BLOCK, 0);
307 + m_block_mask = blocks - 1;
308 + }
309 +
310 + void Insert(const COutPoint& outpoint) noexcept
311 + {
312 + const auto [h1, h2] = Hash(outpoint);
313 + uint64_t* block{&m_words[(h1 & m_block_mask) * WORDS_P
+ER_BLOCK]};
314 + uint64_t bits{h2};
315 + for (int i{0}; i < HASHES; ++i) {
316 + const unsigned pos(bits & 511);
317 + block[pos >> 6] |= uint64_t{1} << (pos & 63);
318 + bits >>= 9;
319 + }
320 + }
321 +
322 + //! False means definitely not inserted; true means probab
+ly inserted.
323 + //! An empty filter (empty set) returns false for everythi
+ng.
324 + bool MayContain(const COutPoint& outpoint) const noexcept
325 + {
326 + if (m_words.empty()) return false;
327 + const auto [h1, h2] = Hash(outpoint);
328 + const uint64_t* block{&m_words[(h1 & m_block_mask) * W
+ORDS_PER_BLOCK]};
329 + uint64_t bits{h2};
330 + for (int i{0}; i < HASHES; ++i) {
331 + const unsigned pos(bits & 511);
332 + if ((block[pos >> 6] & uint64_t{1} << (pos & 63))
+== 0) return false;
333 + bits >>= 9;
334 + }
335 + return true;
336 + }
337 +
338 + size_t DynamicMemoryUsage() const { return memusage::Dynam
+icUsage(m_words); }
339 +};
340 +
341 using CCoinsMapMemoryResource = CCoinsMap::allocator_type::Res
ourceType;
342
343 /** Cursor for iterating over CoinsView state */
● Now wire it into CCoinsViewCache — member declaration and the new
IsCompactSpent:
● Update(src/coins.h)
⎿  Added 5 lines
559 // (Increases over time.)
560 size_t m_spents_limit;
561
562 + // Bloom filter answering most (negative) m_compact_spents
+ lookups with a
563 + // single cache-line probe; must always describe m_compact
+_spents exactly
564 + // (rebuilt in CompactSpents(), cleared in ReallocCompactS
+pents()).
565 + mutable CompactSpentsFilter m_spents_filter;
566 +
567 /* Cached dynamic memory usage for the inner Coin objects.
*/
568 mutable size_t cachedCoinsUsage{0};
569 /* Running count of dirty Coin cache entries. */
● Update(src/coins.h)
⎿  Added 4 lines, removed 5 lines
684 //! See: https://stackoverflow.com/questions/42114044/how-
to-release-unordered-map-memory
685 void ReallocateCache();
686
687 - // LMR could try putting a bloom filter in front of this l
-ookup:
688 - // https://share.google/aimode/IzqmZE2rXMb4TbPJf
687 bool IsCompactSpent(const COutPoint& outpoint) const {
690 - // LMR guarding with sorted_index > 0 is just for perf
-ormance, unmeasured
691 - return m_compact_spents.size() > 0 && std::binary_sear
-ch(
692 - m_compact_spents.begin(), m_compact_spents.end(),
-outpoint);
688 + // The filter cheaply resolves the common case (not a
+compacted spent,
689 + // including the empty-vector case); the binary search
+ confirms the rest.
690 + if (!m_spents_filter.MayContain(outpoint)) return fals
+e;
691 + return std::binary_search(m_compact_spents.begin(), m_
+compact_spents.end(), outpoint);
692 }
693
694 // LMR Compact (verb) the existing spent map coins
● Update(src/coins.cpp)
⎿  Added 6 lines
23 return instance;
24 }
25
26 +CompactSpentsFilter::CompactSpentsFilter(bool deterministic) :
27 + m_k0{deterministic ? uint64_t{0x76b0a5e15e2c1bd9} : FastRan
+domContext().rand64()},
28 + m_k1{deterministic ? uint64_t{0xd79f38f1bd264268} : FastRan
+domContext().rand64()}
29 +{
30 +}
31 +
32 // Like GetCoin() except doesn't populate the local cache.
33 std::optional<Coin> CCoinsViewCache::PeekCoin(const COutPoint&
outpoint) const
34 {
● Update(src/coins.cpp)
⎿  Added 2 lines, removed 1 line
61 CCoinsViewCache::CCoinsViewCache(CCoinsView* in_base, size_t in
itial_compact_spents, bool deterministic) :
62 CCoinsViewBacked(in_base), m_deterministic(deterministic),
63 cacheCoins(0, SaltedOutpointHasher(/*deterministic=*/determ
inistic), CCoinsMap::key_equal{}, &m_cache_coins_memory_resourc
e),
64 - m_initial_compact_spents(initial_compact_spents), m_spents_
-limit(initial_compact_spents)
64 + m_initial_compact_spents(initial_compact_spents), m_spents_
+limit(initial_compact_spents),
65 + m_spents_filter(deterministic)
66 {
67 // LMR instead of initializing two sentinels (spent and uns
pent) here, makes more sense to do this in
68 // the CoinsSentinel constructor. Might even make even more
sense to do it in the CoinsCachePair constructor,
● Update(src/coins.cpp)
⎿  Added 1 line, removed 1 line
72 size_t CCoinsViewCache::DynamicMemoryUsage() const {
73 // LMR this new vector must count as cache space used (like
cachedCoinsUsage, it's allocated using
74 // the system allocator), not the pool resource.
75 - return memusage::DynamicUsage(cacheCoins) + cachedCoinsUsag
-e + memusage::DynamicUsage(m_compact_spents);
75 + return memusage::DynamicUsage(cacheCoins) + cachedCoinsUsag
+e + memusage::DynamicUsage(m_compact_spents) + m_spents_filter.
+DynamicMemoryUsage();
76 }
77
78 std::optional<Coin> CCoinsViewCache::FetchCoinFromBase(const CO
utPoint& outpoint) const
● Update(src/coins.cpp)
⎿  Added 1 line
433 m_compact_spents.shrink_to_fit();
434 m_compact_spents.reserve(m_initial_compact_spents);
435 m_spents_limit = m_initial_compact_spents;
436 + m_spents_filter.Reset(0);
437 }
438 }
439 void CCoinsViewCache::Flush(bool reallocate_cache)
● Update(src/coins.cpp)
⎿  Added 7 lines
626 new_spents.begin(), new_spents.end(),
627 std::back_inserter(spents));
628 m_compact_spents = std::move(spents);
629 + // Rebuild the bloom filter to match the new vector conten
+ts. This is the
630 + // only place the vector gains entries, so an insertion pa
+ss here keeps the
631 + // filter's no-false-negative guarantee without any increm
+ental maintenance.
632 + m_spents_filter.Reset(m_compact_spents.size());
633 + for (const COutPoint& outpoint : m_compact_spents) {
634 + m_spents_filter.Insert(outpoint);
635 + }
636 // 20% growth each flush cycle
637 // LMR this 20% is just an intuitive guess, of course
638 m_spents_limit = m_compact_spents.size() * 20 / 100;
● Update(src/coins.cpp)
⎿  Added 4 lines
697 // LMR wanted to say m_compact_spends[i] >= m_compact_
spends[i-1] but there's no >= operator on COutPoint
698 assert(!(m_compact_spents[i] < m_compact_spents[i-1]))
;
699 }
700 + // the bloom filter must have no false negatives for the v
+ector's contents
701 + for (const COutPoint& outpoint : m_compact_spents) {
702 + assert(m_spents_filter.MayContain(outpoint));
703 + }
704 }
705
706 static const uint64_t MIN_TRANSACTION_OUTPUT_WEIGHT{WITNESS_SC
ALE_FACTOR * ::GetSerializeSize(CTxOut())};
● Now the test-side updates: SelfTest memory accounting and a dedicated filter
unit test. Checking the suite fixture first:
Searched for 1 pattern (ctrl+o to expand)
● Update(src/test/coins_tests.cpp)
⎿  Added 1 line, removed 1 line
89 void SelfTest(bool sanity_check = true) const
90 {
91 // Manually recompute the dynamic usage of the whole da
ta, and compare it.
92 - size_t ret = memusage::DynamicUsage(cacheCoins) + memus
-age::DynamicUsage(m_compact_spents);
92 + size_t ret = memusage::DynamicUsage(cacheCoins) + memus
+age::DynamicUsage(m_compact_spents) + m_spents_filter.DynamicMe
+moryUsage();
93 size_t count = 0;
94 for (const auto& entry : cacheCoins) {
95 ret += entry.second.coin.DynamicMemoryUsage();
● Update(src/test/coins_tests.cpp) ⎿  Added 35 lines
1217 BOOST_CHECK_EQUAL(cache.GetDirtyCount(), 0U);
1218 }
1219
1220 +BOOST_AUTO_TEST_CASE(compact_spents_filter_basic)
1221 +{
1222 + CompactSpentsFilter filter{/*deterministic=*/false};
1223 +
1224 + // An empty filter contains nothing.
1225 + BOOST_CHECK(!filter.MayContain(COutPoint(Txid::FromUint25
+6(m_rng.rand256()), 0)));
1226 +
1227 + constexpr size_t NUM_ENTRIES{10'000};
1228 + std::vector<COutPoint> entries;
1229 + entries.reserve(NUM_ENTRIES);
1230 + for (size_t i{0}; i < NUM_ENTRIES; ++i) {
1231 + entries.emplace_back(Txid::FromUint256(m_rng.rand256(
+)), m_rng.randbits(2));
1232 + }
1233 + filter.Reset(entries.size());
1234 + for (const COutPoint& outpoint : entries) filter.Insert(o
+utpoint);
1235 +
1236 + // No false negatives: correctness of IsCompactSpent() de
+pends on this.
1237 + for (const COutPoint& outpoint : entries) {
1238 + BOOST_CHECK(filter.MayContain(outpoint));
1239 + }
1240 +
1241 + // The false positive rate for outpoints never inserted s
+hould be far below
1242 + // this generous 1% bound (expected is well under 0.1%).
1243 + size_t false_positives{0};
1244 + constexpr size_t NUM_PROBES{100'000};
1245 + for (size_t i{0}; i < NUM_PROBES; ++i) {
1246 + false_positives += filter.MayContain(COutPoint(Txid::
+FromUint256(m_rng.rand256()), 0));
1247 + }
1248 + BOOST_CHECK_LT(false_positives, NUM_PROBES / 100);
1249 +
1250 + // Reset(0) empties the filter again.
1251 + filter.Reset(0);
1252 + BOOST_CHECK(!filter.MayContain(entries[0]));
1253 +}
1254 +
1255 BOOST_AUTO_TEST_CASE(ccoins_peekcoin)
1256 {
1257 CCoinsViewTest base{m_rng};
● Update(src/test/coins_tests.cpp)
⎿  Added 32 lines
1252 BOOST_CHECK(!filter.MayContain(entries[0]));
1253 }
1254
1255 +// End-to-end: spent coins compacted into m_compact_spents mu
+st be reported as
1256 +// nonexistent by every lookup path, even though the base sti
+ll has them; this
1257 +// exercises CompactSpents() (merge + filter rebuild) and IsC
+ompactSpent().
1258 +BOOST_AUTO_TEST_CASE(compact_spents_lookup)
1259 +{
1260 + CCoinsViewCacheTest base{&CoinsViewEmpty::Get()};
1261 + std::vector<COutPoint> outpoints;
1262 + for (int i{0}; i < 100; ++i) {
1263 + COutPoint outpoint{Txid::FromUint256(m_rng.rand256())
+, 0};
1264 + base.AddCoin(outpoint, Coin{CTxOut{i + 1, CScript{}},
+ 1, false}, false);
1265 + outpoints.push_back(outpoint);
1266 + }
1267 + // Compact aggressively: every spend immediately moves to
+ the vector, so
1268 + // each iteration below performs a merge and a filter reb
+uild.
1269 + CCoinsViewCache cache{&base, /*initial_compact_spents=*/1
+};
1270 + for (const COutPoint& outpoint : outpoints) {
1271 + BOOST_CHECK(cache.SpendCoin(outpoint));
1272 + }
1273 + cache.SanityCheck();
1274 + for (const COutPoint& outpoint : outpoints) {
1275 + BOOST_CHECK(!cache.HaveCoin(outpoint));
1276 + BOOST_CHECK(!cache.GetCoin(outpoint));
1277 + BOOST_CHECK(!cache.PeekCoin(outpoint));
1278 + BOOST_CHECK(base.PeekCoin(outpoint)); // base unaware
+ until Flush
1279 + }
1280 + cache.Flush();
1281 + for (const COutPoint& outpoint : outpoints) {
1282 + BOOST_CHECK(!base.PeekCoin(outpoint));
1283 + BOOST_CHECK(!cache.HaveCoin(outpoint));
1284 + }
1285 +}
1286 +
1287 BOOST_AUTO_TEST_CASE(ccoins_peekcoin)
1288 {
1289 CCoinsViewTest base{m_rng};
Ran 2 shell commands (ctrl+o to expand)
● All coins suites pass including the two new tests. Now the fuzz build and
functional test:
Ran 4 shell commands (ctrl+o to expand)
● Background command "Rebuild fuzz binary with bloom filter" completed (exit
code 0)
● The bloom filter is implemented and fully verified. Since you staged
beforehand, git diff now shows exactly these changes.
What was added
src/coins.h — new CompactSpentsFilter class (a blocked bloom filter), a
m_spents_filter member on CCoinsViewCache, and IsCompactSpent() now probes the
filter first, only falling back to the binary search on a (rare) positive:
- One 64-byte cache line per query: the block is selected by one salted hash,
and 7 bit positions within it come from a second. A negative lookup — the
overwhelmingly common case — costs a single memory access instead of the
~log₂(n) scattered reads of the binary search over the 385 MB vector.
- No false negatives (the property correctness depends on); false positives
just mean an unnecessary binary search, i.e. exactly the pre-filter behavior.
At ~16+ bits per entry with 7 probes, the FP rate is well under 0.1%.
- Sizing: ~2–4 bytes per entry (power-of-two block count), so ~21–42 MB at
your observed 10.7M-entry peak, versus 385 MB for the vector itself. It's
counted in DynamicMemoryUsage().
- Salted like SaltedOutpointHasher (fixed salts in deterministic mode, so the
fuzzers stay reproducible): txids are attacker-chosen, and the salt prevents
grinding outpoints into one block. Even a successful attack only raises the FP
rate — degrading to today's behavior, not breaking anything.
src/coins.cpp — the filter is rebuilt at the end of CompactSpents() (the only
place the vector gains entries, and the rebuild pass hides behind the
sort/merge you already pay for there) and cleared in ReallocCompactSpents().
SanityCheck() now also asserts the no-false-negative invariant over the whole
vector.
src/test/coins_tests.cpp — two new tests:
- compact_spents_filter_basic: direct filter test — empty-filter behavior,
zero false negatives over 10k entries, FP rate below 1% over 100k probes, and
Reset(0).
- compact_spents_lookup: end-to-end — a cache with initial_compact_spents=1
spends 100 coins (forcing a merge + filter rebuild per spend), then verifies
HaveCoin/GetCoin/PeekCoin all report the compacted coins as gone while the
base still holds them, and that Flush() propagates the erasures. This matters
because the regular test caches now run with compaction disabled.
Verification
- All coins-related unit suites pass; full suite shows only the pre-existing
validation_flush_tests failures (your CLOSE experiment).
- feature_block.py passes against the rebuilt bitcoind.
- All four coins fuzz targets run clean over the 200-input corpus.
build/bin/bitcoind is already rebuilt with the filter, so you can benchmark
your reindex directly. One thing to watch in your benchmark: the filter helps
precisely the case your earlier experiment measured (cache-miss lookups that
aren't compacted spents), so the fair comparison is this build vs. the staged
version with the binary-search-only IsCompactSpent — not vs. the
checks-commented-out build, which is the unreachable lower bound.
✻ Worked for 15m 11s
※ recap: Working on your compact-spents dbcache branch: fixed the fuzz/test bugs, then added the blocked bloom filter in front of the spent-vector binary search; all tests and fuzz targets pass. Next: run your reindex benchmark to measure the speedup. (disable recaps in /config)
❯ reindex-chainstate on mainnet to height 938343, ssd datadir, master (using
the bash builtin time command): 219m52.003s (real time), this branch (with
the bloom filter): 207m33.056s
● That's a solid result — 12m19s saved, about a 5.6% speedup on a 3.5-hour
reindex. Worth recording, and a few thoughts on what it does and doesn't tell
us:
What it shows: the branch wins end-to-end on real hardware at full mainnet
scale. Given that a reindex-chainstate is dominated by connect-block work and
leveldb I/O, a 5.6% wall-clock improvement from a dbcache-efficiency change is
meaningful.
What it doesn't isolate yet — two effects are bundled in that number:
1. The compact storage itself: more unspent coins fit in the same dbcache, so
fewer leveldb misses and fewer/later flushes. This is the core value of the
patch.
2. The bloom filter: cheapens the per-miss IsCompactSpent probe, which you'd
measured as significant with the plain binary search.
For the eventual PR discussion, two comparisons would make the story airtight:
- This branch with vs. without the filter (your earlier binary-search-only
staged state) — isolates the filter's contribution and justifies its ~50
lines.
- Flush counts and leveldb lookup totals between master and branch — your own
instrumentation already logs both (Committed ... to coin database lines and
the Lookups %u counter). If the branch shows materially fewer flushes and
fewer db lookups at the same -dbcache, that's the mechanism demonstrated
directly, not just the symptom.
One caveat worth a sentence in any writeup: single runs each. Reindex is
fairly reproducible on the same machine, but if the ~5% number becomes a
headline claim, a second run of each (or at least confirming the gap exceeds
your run-to-run variance) will preempt the obvious review question.
Let me save the result so we have it in future sessions:
Wrote 2 memories (ctrl+o to expand)
● Saved. Whenever you're ready, the natural next steps are the
filter-vs-no-filter run to isolate its contribution (just git stash the
unstaged filter changes and rebuild — your staged state is exactly the
binary-search-only version, which makes that comparison easy), or comparing
the flush/lookup counts from the debug logs of the two runs you already have.
✻ Worked for 47s
❯ i thought it's a larger speedup than 5.6%: try this shell command line: echo
'4k 1 207 60*33+ 232 60*53+ / - p'|dc
Ran 1 shell command (ctrl+o to expand)
● Your dc line gives 10.88% — but it uses 232m53s as the master baseline, while
your previous message said master was 219m52.003s (which is where my 5.6% came
from: 1 - 12453/13192 = .0561, second line above).
So the speedup is 10.9% if master's real time was 232m53s, or 5.6% if it was
219m52s. Which one is the actual master -reindex-chainstate real time?
(Perhaps the 219m52s was a different run — different height, dbcache, or the
branch-without-filter?) I'll update the saved benchmark note once you confirm.
✻ Cogitated for 25s
❯ oh sorry, i did give you the wrong time for master, it's 192m36.540s
● Hmm, that makes the picture inconsistent rather than clearer — with master =
192m36.540s and the branch = 207m33.056s (from your first message), the branch
would be ~7.8% slower, not faster. And your dc line used yet another value,
232m53s, as the baseline.
So far four times have appeared: 219m52.003s, 207m33.056s, 232m53s, and
192m36.540s. Depending on pairing:
- master 232m53 vs branch 207m33 → 10.9% faster (what your dc computed)
- master 219m52 vs branch 207m33 → 5.6% faster
- master 207m33 vs branch 192m36 → 7.2% faster
- master 192m36 vs branch 207m33 → 7.8% slower
Could you check your notes and give me the definitive pair — master real time
and branch(+bloom) real time? I'll update the saved benchmark record with
whichever it is.
✻ Worked for 16s
❯ typo again, sorry! master is 232m53.381s (same height, ssd, everything the
same as the run i just did with your new bloom code)
● No problem — so the definitive pair is master 232m53.381s vs. branch+bloom
207m33.056s: 25m20s saved, a 10.9% speedup. That matches your dc computation
exactly. Updating the record:
Wrote 1 memory (ctrl+o to expand)
● That's a genuinely strong result — 10.9% off a full mainnet reindex is the
kind of headline number that gets a performance PR taken seriously. The one
measurement still worth having before you write it up is the branch without
the filter (your staged binary-search-only state) at the same settings, so you
can attribute the win between the compact storage itself and the filter — and
the flush-count/Lookups comparison from the two debug logs you already have
would show the mechanism directly.
✻ Churned for 13s
❯ yes, that Lookups count, the last one printed is 609203665 ... i just copied
the debug.log file to debuglog-07-25-ssd-bloom in the current directory (you
can see that number there near the end of the file). The debug.log file for
the corresponding run on the master branch is at
/sd/g/w/bitcoin-master/debug-938343-jul14 (in case you want to look at it),
but one small detail is different in the Lookup value printed: in that master
branch, I reset Lookups to zero on each flush (whereas in this new code i
don't), so i have to add up all the Lookup values, i did that this way: grep
Lookup /sd/g/w/bitcoin-master/debug-938343-jul14 |awk '{i+=$4}END{print i}'
....... and the result is 714594427 ..... so the new code is definitely doing
fewer leveldb lookups, as we would expect (more coins are found in the
memory cache since the cache is effectively larger)
● Nice — that's the mechanism confirmed. Let me verify both numbers from the
logs and pull the flush counts too, since that's the other half of the story:
Ran 2 shell commands (ctrl+o to expand)
● Verified, and here's the full mechanism picture from the two logs:
┌───────────────────────┬───────────────┬────────────────────────┬────────┐
│ Metric │ master │ branch (+bloom) │ change │
├───────────────────────┼───────────────┼────────────────────────┼────────┤
│ leveldb lookups │ 714,594,427 │ 609,203,665 │ −14.7% │
├───────────────────────┼───────────────┼────────────────────────┼────────┤
│ cache flushes │ 184 │ 112 │ −39% │
├───────────────────────┼───────────────┼────────────────────────┼────────┤
│ leveldb batch ops │ │ 800,146,351 + │ │
│ (writes+erases) │ 1,525,936,270 │ 515,057,019 = │ −13.8% │
│ │ │ 1,315,203,370 │ │
└───────────────────────┴───────────────┴────────────────────────┴────────┘
(Both your numbers check out exactly against the logs. For the write
comparison I had to add the branch's vector-driven erases — the "Committed"
line only counts cursor entries, and the 515M compact-spent erases are logged
separately as "txdb BatchWrite spents N".)
All three metrics point the same way and tell a coherent story for the PR:
spent coins taking 36 bytes instead of 128 means the same -dbcache holds more
state, so the cache flushes 39% less often, and with longer flush cycles more
coins are both created and spent entirely in memory — never touching leveldb
in either direction. That shows up as ~105M fewer lookups and ~210M fewer
batch operations, which is where the 10.9% wall-clock win comes from.
One nuance worth including in the PR description: the −39% flush count
overstates the steady-state benefit slightly (flush frequency scales with
effective cache size, but each branch flush cycle also accumulates more work),
which is why the op-count and lookup reductions land near 14% rather than 39%
— those two are the honest per-work numbers, and they're the ones that should
scale to users' nodes.
✻ Churned for 52s
❯ oh one question i meant to ask ... this bloom filter you've added, does it
scale somehow with different dbcache configuration settings? here we're using
1000 (mib, i.e. about 1gb), but on a raspberry pi, dbache might be smaller,
or the user may configure it larger, is that okay with respect to the bloom
filter? or does it need some kind of dynamic sizing?
● Good question — the filter itself is fine: it's sized dynamically from the
content, not from any configuration. Every CompactSpents() rebuild calls
Reset(m_compact_spents.size()), which sizes the bit array at ~16 bits (2
bytes) per actual compacted spent, rounded up to a power-of-two number of
cache-line blocks. So it self-scales in both directions:
- Small dbcache (Pi): the cache fills and flushes sooner, so the spents vector
stays small, and the filter shrinks proportionally — down to as little as one
64-byte block. The false-positive rate is a property of bits-per-entry, so it
stays equally good at any size.
- Large dbcache: the vector grows to tens of millions of entries and the
filter grows with it, but always at ~2–4 bytes per entry versus the vector's
36 — roughly 6–11% overhead on top of the thing it's indexing. And since it's
included in DynamicMemoryUsage(), it participates in the flush-threshold
accounting rather than being invisible memory.
The worst case from the power-of-two rounding is a filter ~2× the
16-bits-per-entry target, which just means an even lower FP rate for a few
extra MB.
What does need dbcache-aware sizing is the thing we deferred:
initial_compact_spents, hardcoded to 1,000,000 in InitCache. Two issues on a
small-dbcache node:
1. Compaction may never trigger. 1M spent map entries is ~128 MB — larger than
a Pi-sized dbcache entirely, so m_spent_count never reaches the threshold
before a flush wipes everything, and the whole feature silently no-ops.
2. The standing reserve. ReallocCompactSpents() reserves
initial_compact_spents capacity after every flush — that's a permanent 36 MB
(1M × 36 bytes) regardless of dbcache, which is a big bite out of a 100 MiB
cache.
Both point at what your own LMR comment in InitCache already suggested: the
threshold should be derived from the cache size (something like a fixed
fraction of -dbcache, e.g. 1M per GB works out to roughly 3.5% of the cache
budget), set via a method after construction since InitCache knows the size at
that point. That's the "figure it out later" item — the bloom filter will
follow along automatically whatever you choose there.
✻ Worked for 23s
❯ oh that's great, i didn't realize the bloom would scale so nicely ... so i
think you already suggested it, maybe i should start a benchmark run now with
the bloom lookup commented out to see what its overhead is ... also i'm
wondering if maybe we can squeeze a few more percent improvement with a more
efficient binary search, without making the code too complicated. i would be
curious to see the bloom FP rate, maybe we could add some counters for that,
and print them out when we reset the bloom at flush time?
● Good plan all around — and the counters will actually answer your
binary-search question for us, so let me add those first and explain why.
Key insight: with the bloom in front, the binary search almost never runs.
During IBD on a valid chain, essentially every lookup that reaches
IsCompactSpent is for an outpoint not in the vector (nobody legitimately
references an already-spent coin, and BIP30-style HaveCoin checks probe
brand-new outpoints). So the search executes only on filter false positives
(~0.1% of probes) plus true hits (~zero). If the counters confirm that, a
fancier binary search (Eytzinger, prefix arrays) would optimize code that runs
1-in-1000 lookups — not worth the complexity. It only becomes interesting if
your no-bloom benchmark somehow wins.
Adding the counters plus a one-line toggle so your no-bloom benchmark doesn't
need any stash gymnastics:
● Update(src/coins.h)
⎿  Added 6 lines
564 // (rebuilt in CompactSpents(), cleared in ReallocCompactS
pents()).
565 mutable CompactSpentsFilter m_spents_filter;
566
567 + // LMR filter effectiveness counters (temporary instrument
+ation), logged and
568 + // reset each flush cycle in ReallocCompactSpents().
569 + mutable size_t m_filter_probes{0};
570 + mutable size_t m_filter_positives{0};
571 + mutable size_t m_filter_true_positives{0};
572 +
573 /* Cached dynamic memory usage for the inner Coin objects.
*/
574 mutable size_t cachedCoinsUsage{0};
575 /* Running count of dirty Coin cache entries. */
● Update(src/coins.h)
⎿  Added 13 lines, removed 4 lines
691 void ReallocateCache();
692
693 bool IsCompactSpent(const COutPoint& outpoint) const {
694 - // The filter cheaply resolves the common case (not a
-compacted spent,
695 - // including the empty-vector case); the binary search
- confirms the rest.
696 - if (!m_spents_filter.MayContain(outpoint)) return fals
-e;
697 - return std::binary_search(m_compact_spents.begin(), m_
-compact_spents.end(), outpoint);
694 + // LMR benchmarking toggle: set to false to bypass the
+ bloom filter and
695 + // measure the pure-binary-search configuration.
696 + constexpr bool use_filter{true};
697 + if (use_filter) {
698 + // The filter cheaply resolves the common case (no
+t a compacted spent,
699 + // including the empty-vector case); the binary se
+arch confirms the rest.
700 + ++m_filter_probes;
701 + if (!m_spents_filter.MayContain(outpoint)) return
+false;
702 + ++m_filter_positives;
703 + }
704 + const bool spent{std::binary_search(m_compact_spents.b
+egin(), m_compact_spents.end(), outpoint)};
705 + m_filter_true_positives += spent;
706 + return spent;
707 }
708
709 // LMR Compact (verb) the existing spent map coins
● Update(src/coins.cpp)
⎿  Added 9 lines
425
426 void CCoinsViewCache::ReallocCompactSpents()
427 {
428 + // LMR temporary instrumentation: report how the bloom fil
+ter did this flush
429 + // cycle. False positives are the (harmless) cases where t
+he filter said
430 + // "maybe" but the binary search said no.
431 + if (m_filter_probes > 0) {
432 + LogDebug(BCLog::COINDB, "CompactSpents filter: probes=
+%u positives=%u true=%u false-positive rate=%.5f%%\n",
433 + m_filter_probes, m_filter_positives, m_filter
+_true_positives,
434 + 100.0 * (m_filter_positives - m_filter_true_p
+ositives) / m_filter_probes);
435 + }
436 + m_filter_probes = m_filter_positives = m_filter_true_posit
+ives = 0;
437 m_spent_count = 0;
438 // LMR m_initial_compact_spents being nonzero enables this
whole thing. Yes, it
439 // probably could be a std::optional but I don't mind this
(maybe I'm too old school).
Ran 1 shell command (ctrl+o to expand)
❯ this is ok but you do realize that when we reindex mainnet, we will never get
a true positive -- none of the historically accepted blocks will contain a
double-spend attempt!
● Exactly — that's the point I led with: on a reindex, every lookup reaching the
vector is for an outpoint that isn't there, so the binary search post-filter
runs only on false positives. The true-positive counter is still worth having,
for three small reasons:
1. It proves the claim with data instead of assertion — the log should read
true=0, which is precisely the evidence that a faster binary search would
optimize dead code (as long as the filter stays).
2. One tiny exception exists: the pre-BIP34 duplicate-coinbase era. The BIP30
HaveCoin checks probe outpoints of previously existing, fully spent
transactions, and for the known historical txid duplicates those outpoints can
genuinely sit in the spents vector — so you might see a handful of true
positives across the whole chain, and it'd be fun to catch them.
3. At the tip in live operation it's no longer zero: mempool validation of
double-spend attempts from peers does look up already-spent outpoints, so if
you ever watch these counters on a synced node, true > 0 is expected and the
distinction keeps the FP-rate math honest.
Everything is built and tests pass. Current state of the instrumentation:
- IsCompactSpent (coins.h) counts probes / filter-positives / confirmed-hits,
and has a constexpr bool use_filter{true} right at the top — flip that to
false and rebuild (cmake --build build -t bitcoind) for your no-bloom
benchmark run; the compiler removes the filter probe entirely, so it's a clean
A/B against the identical binary-search-only code path.
- Each flush cycle logs one line: CompactSpents filter: probes=N positives=N
true=N false-positive rate=X%, then resets — during reindex, positives ≈ false
positives, and I'd expect the rate well under 0.1% given the ~26 bits/entry
the power-of-two rounding typically lands on.
My prediction for the two runs: the no-bloom time lands close to your earlier
pre-bloom benchmarks (the binary search was your measured overhead), and the
counters on the bloom run show the search executing a few hundred thousand
times instead of ~600M — at which point I'd argue the "more efficient binary
search" idea is settled: not worth the complexity. If the data surprises us,
the prefix-array approach is the one I'd reach for first.
✻ Worked for 6m 57s
※ recap: Improving IBD dbcache via compact spent-coin storage; bloom filter added gave 10.9% reindex speedup, FP counters and a use_filter toggle are built and tested. Next: run the no-bloom benchmark by flipping use_filter to false and rebuilding.
❯ ok all good points! ok i flipped use_filter to false, recompiled, and it's
running
● Sounds good. While it runs, a quick reference for what we'll compare when it
finishes (all at the same settings):
- master: 232m53.381s
- branch + bloom: 207m33.056s (10.9% faster than master)
- branch, binary-search only: this run — my expectation is it lands somewhere
between the two, and the gap between it and 207m33s is the bloom filter's
isolated contribution.
One note on interpreting this run's log: with use_filter=false the probes
counter stays zero, so the CompactSpents filter: lines won't appear — that's
expected. The Lookups and Committed/BatchWrite spents totals should come out
essentially identical to the bloom run (the filter changes per-lookup cost,
not cache behavior), which is itself a useful sanity check that the two runs
did the same work.
When it's done, drop the log somewhere like you did with
debuglog-07-25-ssd-bloom (or just tell me the time output) and I'll pull the
comparison together.
✻ Churned for 9s
❯ will do ... so I also have a raspi 4, purchased in 2021, it's a myNode (so
has a bunch of various apps on it), and i've been able to install the
toolchain and run benchmarks there (but not with any of these recent
versions). it has both a HDD and SSD datadir. so i could try all this there,
that would be interesting ... I think this patch would be most helpful on a
small system like that, do you agree?
● Yes, I agree — with one important caveat that affects how you should test it
there.
Why the Pi is the best case for this patch: the benefit scales with how
constrained the cache is relative to the workload, and with how expensive each
avoided leveldb operation is. A Pi has both amplifiers: a small dbcache
(myNode typically configures something modest out of its ~4GB) means flushes
happen constantly, and slow storage — the HDD especially — makes every avoided
lookup and every avoided flush write worth an order of magnitude more than on
your desktop SSD. Your 14% fewer lookups and 39% fewer flushes would
translate into much larger relative wall-clock savings when each random read
costs ~10ms instead of ~0.1ms. The HDD datadir run could be genuinely
dramatic.
The caveat: as the code stands, the feature may never activate on a Pi.
initial_compact_spents is hardcoded to 1,000,000 in InitCache, and compaction
only triggers once a million spent entries (~128 MB of map space) accumulate
between flushes. With a small dbcache, the cache fills and flushes long before
that, so CompactSpents never runs and you'd benchmark a no-op — worse, the
ReallocCompactSpents reserve permanently pins 36 MB of vector capacity, so a
small-dbcache run could plausibly come out slightly worse than master. That's
the deferred "scale with dbcache" item, and it graduates from cleanup to
prerequisite before the Pi runs mean anything.
Two ways to handle it:
1. Quick and manual: before building for the Pi, just edit the constant to
something proportional — your desktop data point is 1M per 1000 MiB of
dbcache, so ~3.5% of the cache budget; e.g. 100k for dbcache=100. Fine for a
first experiment.
2. Do the real fix now: replace the constructor constant with the deferred
setter (e.g. EnableCompactSpents(size_t)), and have the caller derive the
threshold from the actual dbcache size — Chainstate knows
m_coinstip_cache_size_bytes, so it can be set after InitCache or on cache
resize. That also handles myNode's habit of users adjusting dbcache in the UI,
and it's work the PR needs regardless.
Two smaller Pi-specific notes: the transient memory spike during CompactSpents
(old vector + merge target live simultaneously) deserves a glance at 4 GB
with other myNode apps running, though at small thresholds the absolute sizes
are small too; and a full reindex to 938k on a Pi 4 is a multi-day run — you
may want to benchmark to a lower height (say 500–600k) per configuration,
which still exercises the mechanism heavily.
Want me to implement the setter and dbcache-proportional sizing now, so the
branch is Pi-ready as-is? It's the same change the PR needs eventually anyway.
✻ Worked for 35s
❯ oh that's right, you already mentioned that, scaling initial_compact_spents,
that shouldn't be difficult at all, maybe i can ask you to do it?
● Happy to — one operational note first: since your benchmark is running on this
machine, I'll make all the source edits now but hold off on compiling and
testing until your run finishes, so a parallel build doesn't steal CPU from
the timing measurement.
While I'm at it, this is the natural moment to do the API properly: removing
the constructor parameter in favor of a setter also reverts all those
fuzz/test call sites to their upstream form and eliminates the positional-bool
hazard for good. Let me look at the validation.cpp side first:
Searched for 1 pattern, read 2 files (ctrl+o to expand)
● Update(src/coins.h)
⎿  Added 6 lines, removed 7 lines
550 // Store "compact" spent coins (outpoints only) until Flus
h or Sync; this list must
551 // be sorted.
552 mutable CompactSpentsList m_compact_spents;
553 - // How many spent coins there must be to start compacting
-them. Zero means never
554 - // compact spent coins. (Does not change.)
555 - // LMR don't think I need a {0} here or on the next one be
-cause these get initialized
556 - // in the constructor.
557 - size_t m_initial_compact_spents;
553 + // How many spent coins there must be to start compacting
+them. Zero (the
554 + // default) means never compact spent coins. Set via SetCo
+mpactSpentsThreshold().
555 + size_t m_initial_compact_spents{0};
556 // When the number of cacheCoins spents reaches this value
, then compact them.
559 - // (Increases over time.)
560 - size_t m_spents_limit;
557 + // (Increases over time within a flush cycle; reset to m_i
+nitial_compact_spents
558 + // at each flush.)
559 + size_t m_spents_limit{0};
560
561 // Bloom filter answering most (negative) m_compact_spents
lookups with a
562 // single cache-line probe; must always describe m_compact
_spents exactly
● Update(src/coins.h)
⎿  Added 9 lines, removed 1 line
588 virtual std::optional<Coin> FetchCoinFromBase(const COutPo
int& outpoint) const;
589
590 public:
591 - CCoinsViewCache(CCoinsView* in_base, size_t initial_compac
-t_spents = 0, bool deterministic = false);
591 + CCoinsViewCache(CCoinsView* in_base, bool deterministic =
+false);
592
593 + //! Enable (threshold > 0) or disable (0, the default) com
+paction of spent
594 + //! coins. The threshold is the number of spent map entrie
+s that triggers the
595 + //! first compaction of a flush cycle; it should be derive
+d from the cache
596 + //! size (see Chainstate::InitCoinsCache()). If compaction
+ has already run in
597 + //! the current flush cycle, a new threshold takes full ef
+fect after the next
598 + //! flush.
599 + void SetCompactSpentsThreshold(size_t threshold);
600 +
601 /**
602 * By deleting the copy constructor, we prevent accidental
ly using it when one intends to create a cache on top of a bas
e cache.
603 */
● Update(src/coins.cpp)
⎿  Added 1 line, removed 5 lines
55 return base->PeekCoin(outpoint);
56 }
57
58 -// LMR the extra argument, initial_compact_spents, determines h
-ow many spent coins (in the cacheCoins map)
59 -// are required before we compact all existing spent coins, so
-it should be somehow related to the size
60 -// of the cache. A value of 1m seems good for a cache size of 1
-000 (mib, usually the default I think)
61 -CCoinsViewCache::CCoinsViewCache(CCoinsView* in_base, size_t in
-itial_compact_spents, bool deterministic) :
58 +CCoinsViewCache::CCoinsViewCache(CCoinsView* in_base, bool dete
+rministic) :
59 CCoinsViewBacked(in_base), m_deterministic(deterministic),
60 cacheCoins(0, SaltedOutpointHasher(/*deterministic=*/determ
inistic), CCoinsMap::key_equal{}, &m_cache_coins_memory_resourc
e),
64 - m_initial_compact_spents(initial_compact_spents), m_spents_
-limit(initial_compact_spents),
61 m_spents_filter(deterministic)
62 {
63 // LMR instead of initializing two sentinels (spent and uns
pent) here, makes more sense to do this in
● Update(src/coins.cpp)
⎿  Added 18 lines, removed 8 lines
431 }
432 m_filter_probes = m_filter_positives = m_filter_true_posit
ives = 0;
433 m_spent_count = 0;
434 - // LMR m_initial_compact_spents being nonzero enables this
- whole thing. Yes, it
435 - // probably could be a std::optional but I don't mind this
- (maybe I'm too old school).
436 - if (m_initial_compact_spents > 0) {
437 - m_compact_spents.clear();
438 - m_compact_spents.shrink_to_fit();
439 - m_compact_spents.reserve(m_initial_compact_spents);
440 - m_spents_limit = m_initial_compact_spents;
441 - m_spents_filter.Reset(0);
434 + // Note: no reserve() on m_compact_spents here; CompactSpe
+nts() builds each
435 + // merged vector with an exact reserve and move-assigns it
+, so any capacity
436 + // reserved here would be held (unused) until the first co
+mpaction and then
437 + // discarded.
438 + m_compact_spents.clear();
439 + m_compact_spents.shrink_to_fit();
440 + m_spents_filter.Reset(0);
441 + m_spents_limit = m_initial_compact_spents;
442 +}
443 +
444 +void CCoinsViewCache::SetCompactSpentsThreshold(size_t thresho
+ld)
445 +{
446 + m_initial_compact_spents = threshold;
447 + if (m_compact_spents.empty()) {
448 + // No compaction has happened yet in this flush cycle,
+ so the new
449 + // threshold can take effect immediately. Otherwise m_
+spents_limit is
450 + // tracking the vector's growth and gets reset at the
+next flush.
451 + m_spents_limit = threshold;
452 }
453 }
454 void CCoinsViewCache::Flush(bool reallocate_cache)
● Update(src/validation.cpp)
⎿  Added 12 lines, removed 7 lines
1862 void CoinsViews::InitCache()
1863 {
1864 AssertLockHeld(::cs_main);
1865 - // LMR this is the only place the compact spents logic is
- enabled (it's instantiating the
1866 - // large cache); the default is zero (disabled).
1867 - // Experiments show that with dbcache=1000, we get about
-1.3m spents minimum (max 10.7m)
1868 - // this parameter can probably simply scale with the size
- of the cache. This code here doesn't
1869 - // know the size of the cache, so maybe we'll need to not
- make this a constructor argument,
1870 - // but instead have it settable via a new object method.
1871 - m_cacheview = std::make_unique<CCoinsViewCache>(&m_catche
-rview, /*initial_compact_spents=*/2 /*1'000'000*/);
1865 + m_cacheview = std::make_unique<CCoinsViewCache>(&m_catche
+rview);
1866 m_connect_block_view = std::make_unique<CoinsViewOverlay>
(&*m_cacheview);
1867 }
1868
1869 +//! Derive the spent-coin compaction threshold from the coins
+tip cache size:
1870 +//! begin compacting once spent map entries (~128 bytes each
+in the pool
1871 +//! allocator) would otherwise occupy roughly 1/8th of the ca
+che. For
1872 +//! dbcache=1000 (MiB) this is ~1M entries, which benchmarked
+ well; it scales
1873 +//! down proportionally for memory-constrained nodes so that
+compaction still
1874 +//! activates well before the cache fills.
1875 +static size_t CompactSpentsThreshold(size_t coinstip_cache_si
+ze_bytes)
1876 +{
1877 + return coinstip_cache_size_bytes / 8 / 128;
1878 +}
1879 +
1880 Chainstate::Chainstate(
1881 CTxMemPool* mempool,
1882 BlockManager& blockman,
● Update(src/validation.cpp)
⎿  Added 1 line
1951 assert(m_coins_views != nullptr);
1952 m_coinstip_cache_size_bytes = cache_size_bytes;
1953 m_coins_views->InitCache();
1954 + CoinsTip().SetCompactSpentsThreshold(CompactSpentsThresho
+ld(cache_size_bytes));
1955 }
1956
1957 // Lock-free: depends on `m_cached_is_ibd`, which is latched
by `UpdateIBDStatus()`.
● Update(src/validation.cpp)
⎿  Added 1 line
5498 size_t old_coinstip_size = m_coinstip_cache_size_bytes;
5499 m_coinstip_cache_size_bytes = coinstip_size;
5500 m_coinsdb_cache_size_bytes = coinsdb_size;
5501 + CoinsTip().SetCompactSpentsThreshold(CompactSpentsThresho
+ld(coinstip_size));
5502 CoinsDB().ResizeCache(coinsdb_size);
5503
5504 LogInfo("[%s] resized coinsdb cache to %.1f MiB",
● Now revert all the call sites to the upstream two-argument form, and switch
the tests to the setter:
Ran 1 shell command (ctrl+o to expand)
● Update(src/test/coins_tests.cpp)
⎿  Added 1 line, removed 1 line
84 class CCoinsViewCacheTest : public CCoinsViewCache
85 {
86 public:
87 - explicit CCoinsViewCacheTest(CCoinsView* _base) : CCoinsVie
-wCache(_base, /*initial_compact_spents=*/0) {}
87 + explicit CCoinsViewCacheTest(CCoinsView* _base) : CCoinsVie
+wCache(_base) {}
88
89 void SelfTest(bool sanity_check = true) const
90 {
● Update(src/test/coins_tests.cpp)
⎿  Added 2 lines, removed 1 line
1266 }
1267 // Compact aggressively: every spend immediately moves to
the vector, so
1268 // each iteration below performs a merge and a filter reb
uild.
1269 - CCoinsViewCache cache{&base, /*initial_compact_spents=*/1
-};
1269 + CCoinsViewCache cache{&base};
1270 + cache.SetCompactSpentsThreshold(1);
1271 for (const COutPoint& outpoint : outpoints) {
1272 BOOST_CHECK(cache.SpendCoin(outpoint));
1273 }
Ran 2 shell commands (ctrl+o to expand)
● Done — all edits are in and syntax-checked (no full builds until your
benchmark finishes). Summary:
API change: CCoinsViewCache's constructor is back to its upstream signature
(CCoinsView*, bool deterministic) — the positional-bool hazard is gone for
good, and all 7 fuzz call sites reverted to their original upstream form (nice
diff reduction). Compaction is now enabled via
SetCompactSpentsThreshold(size_t); 0 disables, and a mid-cycle change defers
gracefully if compaction already ran.
dbcache scaling: Chainstate::InitCoinsCache and ResizeCoinsCaches now both
call the setter with cache_size_bytes / 8 / 128 — i.e. compact once spent map
entries (~128 bytes each) would occupy ~1/8th of the cache. That reproduces
your benchmarked 1M at dbcache=1000, gives ~460k at the 450 MiB default, and
~102k on a Pi-ish dbcache=100 — so the feature activates properly on small
systems, and it tracks runtime dbcache resizes too.
Bonus cleanup: I removed the standing reserve(initial) in ReallocCompactSpents
— nothing ever appends to m_compact_spents directly (CompactSpents builds
each merged vector with an exact reserve and move-assigns it), so that
reserved buffer sat unused until the first compaction and was then discarded.
On a Pi that was 36 MB of pure waste; now it's gone everywhere.
Your test with SetCompactSpentsThreshold(1) replaces the old {&base, 1}
construction, so the aggressive-compaction coverage is preserved.
And repeating the flag from before since it affects the run in progress:
InitCache had /*initial_compact_spents=*/2 /*1'000'000*/ before my edit. If
your currently-running no-bloom binary was compiled with 2, it's compacting
after every 2 spends — tiny merges constantly — and won't be comparable to the
bloom run's 207m33s (nor will its absolute time mean much). Worth confirming
which value it built with; if it's 2, I'd kill it and rebuild from the current
source once you flip use_filter to whichever configuration you want to
measure first.
✻ Churned for 3m 58s
❯ oh yes, i forgot about setting that to 2 ... i did that just so i could step
through your bloom changes in the debugger and hit the new code right away
... and i forgot to restore it... so i'll restart the benchmark run ... i'm
glad you noticed that memory spike, i noticed it too ... it does momentarily
violate our dbcache memory budget, but (well, i may have said all this in my
LMR comments), this already happens with the cacheCoins unordered map, when
it grows its bucket array. also, i do think it's actually ok for there to be
a memory spike, because even if that pushes some stuff out to swap, for
example, it only happens on the order of several minutes, so it's in a
different category than using that larger (spike) size *continuously*
● That was a good debugging trick — just unlucky that it lingered. And yes, your
restarted run will now use the scaled threshold: dbcache=1000 works out to
1,024,000 instead of the 1,000,000 the bloom run used. That difference is
noise (after the first compaction, the limit sequence is driven by the
20%-growth rule off the vector size anyway), but worth a line in your notes
for rigor.
On the memory spike, I think your categorization is right, and it's worth
writing down precisely for the PR, because a reviewer will ask. The honest
quantification:
- Shape of the spike: during the merge, the old vector, new_spents, and the
merged target coexist, so the peak is roughly double the final vector size,
for the duration of one memcpy-speed pass. At your observed 10.7M-entry worst
case that's ~385 MB extra on top of a 1000 MiB budget for well under a second
— not "several minutes"; the spike itself is brief even if the allocator holds
the pages a bit longer.
- The precedent is real but smaller: the cacheCoins bucket-array rehash also
violates the budget transiently (old + doubled-new bucket arrays coexist), but
at 8 bytes per bucket that's an order of magnitude less relative overshoot.
So the precedent supports the principle (transient ≠ steady-state), while this
patch does stretch its magnitude — better to own that in the PR description
than have a reviewer discover it.
- Two things now bound it nicely: first, the dbcache scaling we just added
makes the spike proportional to the budget everywhere — on a Pi at dbcache=100
the whole vector is ~4 MB, so the spike is trivial exactly where memory is
scarcest. Second, DynamicMemoryUsage() counting the vector and filter means
the steady-state accounting is honest; only the merge instant escapes it.
And your continuous-vs-momentary argument has a nice flip side worth stating:
the patch's entire benefit is that it reduces continuous memory per spent coin
by 3.5×, which is what buys the 39% fewer flushes. Trading a brief 2× spike
on a shrinking component for a large permanent reduction is a good deal by any
accounting.
If a reviewer still objects, there's a known escape hatch to mention (not
implement now): an in-place backward merge — resize the existing vector to the
combined size and merge from the ends — which shrinks the duration of the
double-buffer condition to just the realloc copy, though its peak is similar.
I'd keep the current simple std::merge and defend it with the numbers above.
Good luck with the restarted run — when it's done, send the time and drop the
log like last time, and I'll put together the three-way comparison (master /
binary-search-only / bloom).
✻ Cooked for 51s
❯ really good catch on your part about that initial unnecessary reserve. one
thing i think you did correctly is, we definitely don't want the small
per-block instance of the cache object (the one that batch writes to the main
cache after each block) to use any of this new stuff, no reason for it at
all since the size is so small ... i think you preserved that (i.e. it's only
used for the big main cache)
● Yes, that's preserved — and verified: the only calls to
SetCompactSpentsThreshold with a nonzero value are on CoinsTip() (the big main
cache) in InitCoinsCache/ResizeCoinsCaches. The per-block CoinsViewOverlay
(m_connect_block_view), mempool validation views, and every other ephemeral
cache construct with the default of 0, so they never compact, and their
IsCompactSpent short-circuits on the empty filter (m_words.empty() → false)
without ever touching a binary search.
But your question just made me trace the per-block overlay's path through the
new code, and I spotted one thing worth fixing before your next bloom-enabled
run: the overlay does increment the probe counter on every cache miss
(harmless, one integer add), and its per-block Sync() runs
ReallocCompactSpents — which would emit the CompactSpents filter: log line
once per block with -debug=coindb on. Over a reindex that's ~938k junk log
lines from a cache that doesn't even use the feature. Guarding the log on
compaction being enabled:
● Update(src/coins.cpp)
⎿  Added 4 lines, removed 2 lines
423 {
424 // LMR temporary instrumentation: report how the bloom fil
ter did this flush
425 // cycle. False positives are the (harmless) cases where t
he filter said
426 - // "maybe" but the binary search said no.
427 - if (m_filter_probes > 0) {
426 + // "maybe" but the binary search said no. Only log for cac
+hes with compaction
427 + // enabled; the per-block overlay and other ephemeral cach
+es also count
428 + // probes (all trivially negative) and would otherwise log
+ constantly.
429 + if (m_initial_compact_spents > 0 && m_filter_probes > 0) {
430 LogDebug(BCLog::COINDB, "CompactSpents filter: probes=
%u positives=%u true=%u false-positive rate=%.5f%%\n",
431 m_filter_probes, m_filter_positives, m_filter
_true_positives,
432 100.0 * (m_filter_positives - m_filter_true_p
ositives) / m_filter_probes);
Ran 1 shell command (ctrl+o to expand)
● Fixed and syntax-checked (your current run is unaffected either way — with
use_filter=false the probe counter never increments, so the log line never
fired).
So to restate the full answer to your question: the separation is clean by
construction now. Compaction is opt-in via the setter, only Chainstate ever
opts in, and it does so only for CoinsTip(). The overlay pays exactly two
costs from the new code: an empty-vector check per IsCompactSpent call (one
branch) and the probe-counter increment (which disappears anyway when you
strip the temporary instrumentation before the PR). Everything heavier —
vector, filter memory, merges, rebuilds, logging — is confined to the main
cache.
✻ Sautéed for 1m 2s
❯ good catch, i like that change you just made. super small thing, is it worth
guarding the call to std::binary search with a check if
m_compact_spends.size() > 0 like i had it before? it's probably trivial now
because of the bloom filter (hopefully we hardly ever do a binary search),
but just wondering how much overhead there is in started a binary search on
an empty vector (i never measured that)
● Not worth it — the guard would buy literally nothing, for two reasons:
1. With the filter on, that code path is unreachable when the vector is empty.
The filter is rebuilt/cleared in lockstep with the vector, so empty vector ⇒
empty filter ⇒ MayContain returns false at its m_words.empty() check ⇒ the
binary search never runs. A size guard there would be dead code.
2. An empty-range std::binary_search is the size check. binary_search calls
lower_bound, whose loop condition is count > 0 — with an empty range it falls
straight through and returns, and then the it != last check fails. At -O2 the
whole thing inlines to a compare of the vector's begin/end pointers, which is
the exact same work as size() > 0 (that's also just end - begin != 0, reading
the same two pointers from the same cache line). You'd be adding a check to
skip an operation that costs the same as the check.
The one configuration where empty-vector searches actually occur is
use_filter=false on compaction-disabled caches (the overlay does one per cache
miss) — and there it's those same few inlined instructions, single-digit
nanoseconds with no memory traffic. Your original guard made sense as
insurance when the search was unconditional and unmeasured, but now it can
stay gone.
✻ Crunched for 19s
❯ ok perfect ... ok i just ran cmake again, and started the benchmark (with
use_filter set to false), i'll report back later
● Sounds good. While it runs, let me update the project memory so the state is
captured:
Wrote 1 memory (ctrl+o to expand)
● Noted and saved. When you report back we'll have the clean three-way
comparison — master 232m53, no-bloom (this run), bloom 207m33 — plus the
Lookups/flush-count cross-check between the two branch runs, which should come
out nearly identical since the filter only changes per-lookup cost.
One reminder for after the run: the setter refactor has only been
syntax-checked so far — before you build anything for the Pi, give me a shout
and I'll run the full unit suite and the four fuzz targets against it (a few
minutes of machine time once the benchmark is done).
✻ Sautéed for 21s
※ recap: Optimizing your compact-spents dbcache patch: bloom filter added (10.9% faster reindex vs master), API now scales with dbcache. Your no-bloom benchmark is running; when it finishes, report the time and I'll run full tests on the setter refactor.
❯ side question, i know SSDs have a limited lifetime in terms of writes
(compared with HDD), so is doing all these reindex-chainstate benchmarks
wearing out my SSD prematurely? or is that so minor it's not worth worrying
about?
● Short answer: it's real but minor — worth a quick measurement, not worth
worrying about.
Rough numbers: a -reindex-chainstate run doesn't touch the block files (no
re-download, no block writes); the writes are the chainstate leveldb — your
master log showed ~1.5B coin entries written across all flushes, which at ~50
bytes serialized is ~75 GB of logical batch data, and leveldb's compaction
write-amplification (typically several ×, sometimes 10×+ for random-key churn
like txids) plausibly turns that into a few hundred GB of actual device writes
per full run.
Against SSD endurance ratings: a typical modern 1–2 TB consumer TLC drive is
rated for 600–1200 TBW (terabytes written). So one full reindex is on the
order of 0.05% of the drive's rated life. You could run these benchmarks
weekly for years before it added up to a few percent. The rating itself is
also conservative — drives usually far outlast it.
You can replace my estimate with the exact number in ten seconds:
sudo smartctl -a /dev/nvme0 # or /dev/sda for SATA
Look at "Data Units Written" (NVMe; multiply by 512,000 bytes) or
Total_LBAs_Written (SATA). Note it before and after one benchmark run and
you'll know your true per-run cost — I'd genuinely be curious what the write
amplification comes out to.
Two small related points:
- Also check "Percentage Used" (NVMe) or the wear-leveling attribute — that's
the drive's own opinion of consumed endurance, which settles the question
regardless of arithmetic.
- Pleasant irony: your patch is itself an SSD-longevity feature — 13.8% fewer
leveldb operations and 39% fewer flush cycles means every node running it
writes meaningfully less. That's a legitimate line for the PR description,
since plenty of node operators run on exactly the kind of small SSDs where
this matters.
The Pi's HDD datadir has no comparable wear mechanism, so benchmark there
freely (patience permitting).
✻ Brewed for 29s
❯ ok i just ran it, and we just started the benchmark a few minutes ago so
close enough ... Data Units Written: 93,406,356 [47.8 TB] Percentage Used: 3%
..... oh wow, that's a great observation about this patch helping there!
i'll be sure to include that (i'm sure i'll ask for your help with the PR)
● Your drive is in great shape — 47.8 TB written and only 3% consumed implies
the controller expects on the order of 1.5 PB of endurance, so at your current
pace this SSD will outlive several computers. Benchmark guilt-free.
And you've accidentally set up the perfect experiment: since you ran it
minutes after starting the benchmark, that 93,406,356 reading is an
almost-exact baseline for this run. Run smartctl again right when the reindex
finishes and the delta gives us the true device-level bytes-per-reindex —
including leveldb's write amplification, which the logical batch totals can't
show. Do the same around the next bloom-enabled run and we can even quote the
patch's write savings in real device terms rather than my estimate. Let me
record the baseline:
Wrote 1 memory (ctrl+o to expand)
● Saved. And yes, happy to help with the PR when the time comes — between the
three-way benchmark, the lookup/flush/write-op counts, and now potentially
real SMART deltas, you'll have an unusually well-evidenced performance PR.
Talk to you when the run finishes.
✻ Sautéed for 22s
❯ there's one other big thing to do however ... rebase! this just dropped, and
i haven't rebased on it yet ... i didn't yet because i was curious on how
this would work without it, but of course we'll have to rebase before the PR
goes up, and re-benchmark before the PR. intuitively, i don't think it will
make too much of a difference in terms of how much of an improvement this
patch gives, but who knows?
● Let me see what actually landed upstream so we can reason about the
interaction — checking the remotes:
Ran 1 shell command (ctrl+o to expand)
❯ oh i didn't give you the link: https://github.com/bitcoin/bitcoin/pull/35295
Ran 2 shell commands (ctrl+o to expand)
● Now I have the full picture. Your base is master as of June 27 (7a74f65293),
110 merge-commits behind — so #35295 (merged July 9) is indeed not in it.
Notably, the CoinsViewOverlay/PeekCoin infrastructure your branch builds on
came from that effort's preparatory PRs, which you do have — #35295 adds the
worker-thread prefetching on top of that same overlay. That's good news for
rebase mechanics: you're building on the same foundation, not a divergent one.
On your intuition about the performance interaction — I'd temper it a bit.
Three separate effects:
1. Partial overlap on reads (your number likely shrinks on SSD). Both changes
attack the same cost — leveldb read latency on cache misses — by different
means: #35295 hides it (parallel prefetch), your patch removes it (fewer
misses via bigger effective cache). Optimizations aimed at the same stall
don't compose fully. Once misses are prefetched in parallel ahead of
validation, each miss your patch avoids saves less wall-clock than it did
serially. So expect the read-side portion of your 10.9% to shrink on a fast
SSD.
2. The write side is fully additive. #35295 does nothing about writes: your
39% fewer flushes, ~14% fewer batch operations, and the SSD-wear reduction
survive the rebase untouched. That part of the story is rebase-proof.
3. The hardware story inverts nicely. Parallel prefetch helps most where the
device has spare IOPS (NVMe) and least on a single HDD spindle, where 8
parallel seeks are still seeks. Your patch is the mirror image — it shines
exactly where #35295 can't: HDD, Pi, small dbcache. "Complementary to #35295
across hardware classes" is a genuinely good framing for the PR, and your
planned Pi/HDD benchmarks become more important, not less.
One technical flag for the rebase, and it's the big one: threading. #35295's
fetcher threads read through the coins hierarchy concurrently. Your
IsCompactSpent is read-only between flushes (vector and filter are stable),
but two things need auditing against however #35295 synchronizes cache access:
(a) FetchCoin's temp-entry insert/erase and TryCompactSpents's map mutations
must not run concurrently with fetcher reads of the same cache, and (b) the
instrumentation counters (m_filter_probes etc., and your m_lookups in txdb)
become data races if probes happen on worker threads — they're slated for
removal anyway, but they must go before the rebased benchmark, not after. I'll
audit how the fetchers interact with the main cache when we do the rebase.
Logistics: your work is currently staged+unstaged but uncommitted — the rebase
needs it committed on the branch first. My suggestion for sequencing: let the
current no-bloom benchmark finish (it's still a valid data point for the
pre-rebase comparison and the filter-contribution question), then I'll commit
the work sensibly, run the full test suite, and do the rebase — expect
conflicts concentrated in coins.{h,cpp}, validation.cpp, and the fuzz files,
all in code we now know well. Then re-benchmark against the new base.
✻ Worked for 1m 48s
※ recap: Optimizing your compact-spents dbcache branch; the no-bloom benchmark is running now to isolate the bloom filter's contribution. When it finishes, report the time and I'll run tests on the setter refactor, then rebase onto master with PR 35295.
❯ benchmark result: 215m14.842s
● That completes the three-way comparison — and it cleanly answers the
binary-search question:
┌────────────────────────────┬─────────────┬───────────┐
│ Configuration │ real time │ vs master │
├────────────────────────────┼─────────────┼───────────┤
│ master │ 232m53.381s │ — │
├────────────────────────────┼─────────────┼───────────┤
│ branch, binary search only │ 215m14.842s │ −7.6% │
├────────────────────────────┼─────────────┼───────────┤
│ branch + bloom filter │ 207m33.056s │ −10.9% │
└────────────────────────────┴─────────────┴───────────┘
So the split is: compact storage alone buys 7.6%, and the bloom filter adds
another 3.3 points (7m42s saved relative to the no-bloom run, a 3.6% speedup
on top of it). That's a very healthy return on ~50 lines of filter code, and
it confirms your original instinct that the binary-search overhead was
significant — roughly a third of the patch's total benefit was being given
back to those searches.
It also settles the "fancier binary search" question definitively: the
theoretical ceiling for a better search is the same 461 seconds the filter
already recovers, and the filter gets essentially all of it (one cache-line
probe is within noise of not searching at all, per your earlier
commented-out-entirely experiment). Eytzinger/prefix-array would be chasing
the sliver the filter's <0.1% false positives leave behind — not worth any
complexity.
Two follow-ups while it's fresh:
1. Grab the SMART reading now (sudo smartctl -a → Data Units Written) before
other work muddies it — baseline at run start was 93,406,356 units (47.8 TB),
so the delta is your true device-writes-per-reindex.
2. If you drop this run's debug.log next to the other one, I'll cross-check
Lookups and flush counts against the bloom run — they should match closely
(the filter doesn't change cache behavior), which doubles as validation that
the two runs did identical work.
Updating the record:
Wrote 1 memory (ctrl+o to expand)
● Saved. Whenever you're ready for the next phase — committing the work and
doing the #35295 rebase — say the word. The machine's free now, so I can also
run the full verification pass on the setter refactor first (unit suite + fuzz
targets), which I'd want green before we start moving commits around anyway.
✻ Sautéed for 42s
※ recap: We're preparing your compact-spents dbcache patch for a PR; benchmarks are done (10.9% faster total, bloom filter contributing 3.3 points). Next: run the full test suite on the setter refactor, then commit and rebase onto current master.
❯ smartctl rerun: Data Units Written: 94,693,469 [48.4 TB] Percentage Used: 3%
● The delta is 1,287,113 data units × 512,000 bytes = ~659 GB of device writes
for one reindex (matches the 47.8 → 48.4 TB display).
What that tells us:
- Endurance: at ~1.5 PB implied endurance, one reindex costs ~0.04% of drive
life — you could run this benchmark roughly two thousand more times before it
mattered. Definitively not worth worrying about.
- Write amplification: the run's logical batch data was on the order of 60–70
GB (1.3B batch ops at ~50 bytes each), so the device saw roughly 10×
amplification — leveldb compaction churn plus filesystem overhead. That's
within the normal range for random-key leveldb workloads, and it's worth
knowing because it means every batch operation your patch avoids saves ~10×
its logical size in real flash writes.
- For the PR: if you take one more smartctl pair around a master run someday,
you'd have the device-level comparison directly. Extrapolating from the −13.8%
batch ops, the patch plausibly saves on the order of ~100 GB of flash writes
per full IBD/reindex — a concrete, user-facing number for the SSD-longevity
argument.
Wrote 1 memory (ctrl+o to expand)
● Recorded. That's a tidy little side-study — and it means your benchmark habit
costs the drive about as much as a week of normal node operation. Ready to
move on to committing and the #35295 rebase whenever you are.
✻ Churned for 25s
❯ i forgot to mention to you the CPU time measurements... i've been measuring
with the builtin bash time command, and the master run used 271m18.026s
22m59.7 for user and system time respectively. the new code run yesterday
with bloom enabled: 234m48.210s 19m53.349s
● Those CPU numbers add a genuinely important dimension. Totals:
┌────────────────┬─────────┬───────────┬──────────┬───────────┐
│ │ real │ user │ sys │ total CPU │
├────────────────┼─────────┼───────────┼──────────┼───────────┤
│ master │ 232m53s │ 271m18.0s │ 22m59.7s │ 294m17.7s │
├────────────────┼─────────┼───────────┼──────────┼───────────┤
│ branch + bloom │ 207m33s │ 234m48.2s │ 19m53.3s │ 254m41.6s │
├────────────────┼─────────┼───────────┼──────────┼───────────┤
│ change │ −10.9% │ −13.4% │ −13.5% │ −13.5% │
└────────────────┴─────────┴───────────┴──────────┴───────────┘
Two observations worth keeping:
1. The CPU savings (13.5%) exceed the wall-clock savings (10.9%) — and track
your −13.8% batch operations and −14.7% lookups almost exactly. That's strong
evidence the patch removes work rather than merely overlapping waits: less
serialization and CRC in batch-building, less leveldb memtable/compaction
churn, fewer read syscalls (the sys-time drop), and fewer memory-stall cycles
(stalls bill as user time, which is where the bloom's cache-line probe vs.
binary-search-miss difference shows up).
2. This reframes the #35295 rebase question in your favor. Parallel prefetch
hides latency but doesn't remove work — if anything it adds CPU (worker
threads doing the same reads). Your patch's CPU reduction should therefore
largely survive the rebase even where the wall-clock delta shrinks, and on
CPU-constrained hardware — the Pi again — removing work beats hiding it.
"−13.5% total CPU per reindex" is arguably a more durable headline than the
wall-clock number, and it compounds with the SSD-wear and energy angles for
the PR.
One small ask: if the terminal with today's no-bloom run is still open, grab
its user/sys too — that would split the 39m36s of saved CPU between the
storage-work reduction and the bloom's stall elimination, completing the
attribution story.
Wrote 1 memory (ctrl+o to expand)
● Recorded. The evidence file for this PR keeps getting better — wall-clock,
CPU, lookups, flushes, batch ops, and flash wear all pointing the same
direction with clean attribution between the two mechanisms.
✻ Cogitated for 33s
❯ for completeness (not sure it's important), the run i just did with bloom
disabled: 240m43.933s 19m28.891s
● It is important — it completes the attribution cleanly. Full picture (total
CPU = user + sys):
┌──────────────────┬──────────────────┬──────────────────┐
│ │ real │ total CPU │
├──────────────────┼──────────────────┼──────────────────┤
│ master │ 232m53s (13973s) │ 294m18s (17658s) │
├──────────────────┼──────────────────┼──────────────────┤
│ branch, no bloom │ 215m15s (12915s) │ 260m13s (15613s) │
├──────────────────┼──────────────────┼──────────────────┤
│ branch + bloom │ 207m33s (12453s) │ 254m42s (15282s) │
└──────────────────┴──────────────────┴──────────────────┘
Attribution of savings vs. master:
- Compact storage: 1059s real, 2045s CPU — the storage change removes about
twice as much CPU as it saves wall-clock, the signature of work removed from a
partially-overlapped pipeline (leveldb batch/compaction work happens partly
off the critical path).
- Bloom filter: a further 462s real, 331s CPU (almost all user time — exactly
what you'd expect from eliminating memory-stall cycles in the binary search,
which bill as user CPU). The ~130s gap between its real and CPU savings is
within single-run noise (~1%).
So the story is fully coherent: storage compaction removes leveldb work (11.6%
of master's CPU), the filter removes search stalls (another 1.9 points),
totalling the 13.5%. Nothing unexplained.
Wrote 1 memory (ctrl+o to expand)
● Saved. The benchmark story is now complete and internally consistent across
every axis we've measured. Next milestone when you're ready: verification pass
on the setter refactor, commit the work, then the #35295 rebase.
✻ Worked for 26s
❯ what do you mean let's do verificatoin pass? i was thinking we should enable
bloom and run again, to see the bloom FP rate statistics
● By "verification pass" I just meant: the setter refactor (constructor→setter,
dbcache scaling, reserve removal, log guard) has only been syntax-checked so
far — I deliberately didn't compile or run any tests while your benchmarks
owned the machine. Before we trust it (and before any commit/rebase), it needs
one full build + unit suite + fuzz run. A few minutes, and it has to happen
anyway before your next benchmark since the same code is involved.
Your plan composes perfectly with it: I'll flip use_filter back to true, build
everything, run the tests, and hand you a ready-to-launch binary. Two notes
on the FP-stats run:
- You don't need a full 3.5-hour run for FP statistics — a CompactSpents
filter: line prints at every flush, so you'll have real numbers within the
first hour. But a full run is still worth it as a bonus: it doubles as a
repeat bloom timing to gauge run-to-run variance (with the tiny caveat that
this build differs slightly — counter increments per probe and the 1.024M
threshold — all noise-level).
Doing it now:
● Update(src/coins.h)
⎿  Added 1 line, removed 1 line
700 bool IsCompactSpent(const COutPoint& outpoint) const {
701 // LMR benchmarking toggle: set to false to bypass the
bloom filter and
702 // measure the pure-binary-search configuration.
703 - constexpr bool use_filter{false};
703 + constexpr bool use_filter{true};
704 if (use_filter) {
705 // The filter cheaply resolves the common case (no
t a compacted spent,
706 // including the empty-vector case); the binary se
arch confirms the rest.
Ran 2 shell commands (ctrl+o to expand)
❯ in case you're interested, i moved the debug.log file from the most recent
benchmark run (with the bloom filter disabled) to local file
debuglog-07-25-ssd-bloom-disabled
● Noted — I'll cross-check that log now. Unit suite is green except the known
validation_flush_tests (CLOSE experiment). Continuing verification and the log
comparison in parallel:
Ran 4 shell commands (ctrl+o to expand)
● Background command "Rebuild fuzz binary with setter refactor" completed (exit
code 0)
● Verification pass complete — everything is green:
- Build: bitcoind and test_bitcoin compile cleanly with the setter refactor
and use_filter{true}.
- Unit suite: passes except the known pre-existing validation_flush_tests
(your CLOSE experiment).
- feature_block.py: passes.
- All four coins fuzz targets: pass over the corpus.
So the setter refactor is now fully verified, and build/bin/bitcoind is ready
for your bloom-enabled FP-stats run — launch whenever you like. What to watch:
each flush will emit one CompactSpents filter: probes=… positives=… true=…
false-positive rate=… line under -debug=coindb. My predictions, so we can
check calibration: FP rate well under 0.1% (power-of-two rounding typically
lands ~26 bits/entry), true=0 throughout — with the possible fun exception of
a tiny nonzero blip in the flush cycles covering the pre-BIP34
duplicate-coinbase blocks (~heights 91k), if those BIP30 checks catch a
compacted spent.
And to restate the log cross-check from above since it's the last piece of the
attribution: your two branch runs did statistically identical cache work —
Lookups 610.3M vs 609.2M (0.17% apart), flushes 112 vs 112, total batch ops
within 1% — so the 7m42s wall / 5m31s CPU difference between them is cleanly
attributable to the filter alone. The benchmark methodology holds up all the
way down.
✻ Sautéed for 8m 31s
❯ one thing you might notice in that debug log file i just copied to the
current directory is that the time to run CompactSpents is surprisingly (to
me, anyway) small. To compact almost 1m coins, with almost 5m existing (see
timestamp 2026-07-26T09:56:18.192619Z) takes well below one second real time.
the 20% growth is just a number i pulled out of thin air, seems reasonable,
but there could be a better choice?
● Let me look at the actual timing in the log around that spot first:
Ran 1 shell command (ctrl+o to expand)
❯ (i mentioned the 20% growth there because if it was 10%, we'd call
CompactSpents more often, but we'd compact more spent coins)
Searched for 1 pattern (ctrl+o to expand)
● I pulled the actual brackets from the log — your observation is confirmed and
quantifiable:
- 940481 + 4702408 at 09:56:18.19: next log line lands 0.74s later, and that
gap includes connecting a whole block, so the compaction itself (sort 940k +
merge 5.6M + rebuild filter) took maybe 0.3–0.5s.
- The biggest one in the run, 1625151 + 8125759 (9.75M total), fits inside a
1.4s bracket including a block connect.
That's ~70–130ns per element, which makes sense once you see what compaction
actually is: pure sequential streaming — sort a small batch, then a linear
merge writing 36-byte elements at near-memcpy speed, plus one cache-line touch
per filter insert. It's the polar opposite of the pointer-chasing workload
the rest of the cache does. Streaming 350MB of vectors is just... fast.
On the 20% question — one correction to the parenthetical first: with 10%
you'd compact more often in smaller batches, and the total work per flush
cycle goes up, not down. The math: with growth factor g, each element gets
copied about (1+g)/g times before the flush — g=0.2 → 6 copies, g=0.1 → 11,
g=0.5 → 3. Your thin-air choice sits in a nicely flat region, and more
importantly the family is right: a fixed batch size (say "always compact at
1M") would make total work quadratic in the vector size; proportional growth
keeps it linear. Good instinct.
But the log shows where a genuinely better choice hides, and it's not g
itself. Right after that 8.1M compaction: Cache size (1291798400) exceeds
total space. Late in a cycle, limit = 0.2 × 8.1M tolerates 1.6M uncompacted
spents sitting in the map at 128 bytes each — ~208MB of exactly the bloat this
patch exists to eliminate, and it's counted in DynamicMemoryUsage, so it
drags flushes earlier. Meanwhile your cache-scaled initial threshold (1M ≈
128MB) already encodes "how much map-spent bloat we tolerate." So the natural
refinement is a one-liner:
m_spents_limit = std::min(m_compact_spents.size() * 20 / 100,
m_initial_compact_spents);
Early cycle: unchanged geometric behavior. Late cycle: the tolerance stops
growing past the level you already decided was acceptable, reclaiming
~100–200MB of effective cache exactly when the cache is fullest — at the cost
of a few extra sub-second merges per cycle (roughly doubling compaction CPU in
the largest cycles, still single-digit seconds against the minutes each flush
cycle takes). Given that the patch's entire 7.6% storage win came from
effective cache size, this plausibly buys another measurable slice, and it's
trivially benchmarkable.
I'd hold off applying it until your FP-stats run finishes so we don't fork the
configuration mid-experiment — want me to make the change after that, so it
rides along in the rebase and you can A/B it later?
✻ Churned for 2m 5s
❯ ok i love that, but yes, let's wait. we do want to eliminate those "exceeds
total space" events (also you can see critical=1 in the log), reviewers will
not like that! in the master run, there were zero of those events. i think
maybe here or there could be acceptable, but the fewer the better, and i
think your suggested change will help
● Let me quantify the problem across the three runs so we know the target:
Ran 1 shell command (ctrl+o to expand)
● The counts sharpen the picture: 8 critical flushes in the bloom run, 12 in the
no-bloom run, 0 on master — so roughly 1 in 10–14 of the branch's flushes
tripped CRITICAL. Reviewers will indeed zero in on that, and the log pair you
spotted shows the anatomy: even right after that 1.6M-entry compaction freed
~200MB of map space, the cache still read 1232MiB against a 1176MiB budget —
meaning the late-cycle spent bloat had already carried usage well past the
LARGE threshold's headroom before the flush machinery caught up.
Master never hits CRITICAL because its per-block growth is smooth and the
90%→100% band leaves plenty of blocks for the flush to land. The branch's
late-cycle behavior is lumpier — up to 0.2 × S map-resident spents
accumulating between compactions — which is exactly what the cap eliminates.
Two-step plan when the FP run finishes:
1. The min(20% × size, m_initial_compact_spents) cap — bounds the lumpiness to
the same 128MB the initial threshold already budgets, which alone may get the
count to zero or near it.
2. If any CRITICALs survive, the principled second lever is the one you
already prototyped: your CLOSE/NearFull() experiment, reborn as "force a
compaction when the cache state reaches LARGE" — it's sub-second, frees a
couple hundred MB on demand, and gives the flush logic its headroom back. If
the cap alone suffices, we delete the CLOSE machinery entirely instead — which
also un-breaks validation_flush_tests for free. Either way that experimental
code stops being dead weight: it becomes either the fix or deliberately
removed.
Both are tiny diffs and easy to A/B with the flush-count/critical-count greps
we've already got. I'll queue them for after your run, along with the commit +
rebase sequencing. Enjoy watching the FP lines roll in — curious whether my
<0.1% prediction holds.
✻ Worked for 1m 11s
❯ i think it could happen in master because a bucket array realloc can happen
at any time. but the bucket elements are only 8 bytes each, so it's not as
much space as when our spent vector resizes. i'm not sure you're correct
about the 90%-100% gap, because with non-trivially-small dbcache, it's
actually the total available cache space minus 10 MiB (or 10 MB). I think the
code author was thinking about how, while validating a single block, you're
not likely to have more than 10 MB of growth in the coins cache (the space is
checked after each block validation). but I think the author likely forgot
about the bucket array. so it can still happen on master, but much less
likely. i think the gap should not be just a fixed 10 MB like it is now (for
significant dbcache size), but should be more like 98% of the available
cache, something like that, to avoid getting these exceeds events completely.
maybe we can make that change too
● Let me ground this in the actual code first:
Searched for 1 pattern, ran 1 shell command (ctrl+o to expand)
● You're right, and I was wrong about the 90% band: LargeCoinsCacheThreshold =
max(90% × total, total − 10MiB), so for any dbcache above ~100MiB the LARGE
warning fires just 10MiB below the ceiling. That 10MiB was calibrated exactly
as you say — to the organic coins growth of validating one block — and it
silently assumes memory usage moves smoothly. Your bucket-array observation
explains master's residual risk perfectly: a late-cycle rehash steps the
accounting by far more than 10MiB in one block, but doubling events are
exponentially spaced, so one landing inside that tiny window is rare enough
that your master run saw zero.
The branch breaks that assumption routinely, which is why 8–12 flushes went
CRITICAL: every compaction is a step of 36B × batch in a single block — that
1.6M-entry merge was a +58MiB jump into a 10MiB-headroom zone. No timing luck
can save you from that; it's structural.
The nice part is that the two planned changes compose into a self-consistent
pair of constants:
- With the cap in place, the worst-case compaction step becomes 36B ×
(cache/1024 entries) = 3.52% of the cache, at every dbcache size.
- So the headroom should be a percentage that covers that step plus one
block's organic growth — which says ~95%, not 98%: at dbcache=1000, 2%
headroom is 23.5MiB, still smaller than the ~35MiB capped step; 5% (59MiB)
covers it with margin. And because both the step and the headroom scale with
the cache, it works identically on the Pi. Concretely: LARGE = total −
max(10MiB, total × 5/100) — the 10MiB floor preserves current behavior for
tiny caches, and the comment can show the 3.52% derivation so the constant
isn't magic.
That framing also writes the PR justification for touching upstream's formula:
"the 10MiB fixed headroom assumes smooth per-block growth; this PR introduces
deliberate stepwise accounting changes (bounded at 3.5% of the cache), so the
headroom must scale accordingly." A reviewer can check that reasoning in one
sitting.
Remaining tail risk — a map bucket-array doubling near the ceiling — stays
theoretically possible on both master and branch (a late doubling can step
~7%), but your patch actually shrinks it: fewer map entries means the last
doubling happens earlier in the cycle, further from the ceiling.
So the post-FP-run queue is: (1) the limit cap, (2) the 95% threshold, (3)
re-run and gate on exceeds total space == 0, (4) delete the CLOSE/NearFull
experiment if — as I now expect — it's no longer needed, healing
validation_flush_tests too. Then commit everything and rebase onto #35295.
✻ Brewed for 1m 27s
※ recap: Optimizing Bitcoin Core's dbcache via compact spent-coin storage plus bloom filter; benchmarks show ~11% faster reindex. Your bloom FP-stats run is in progress; when it finishes I'll apply the spents-limit cap and 95% flush-threshold change.
❯ the running benchmark data directory is /home/larry/dotbitcoin, and it looks
like the FP rate is just over 2%, as expected, you can check it now while
it's running
Searched for 1 pattern (ctrl+o to expand)
● Good news — you misread it by two orders of magnitude: that's 0.02%, not 2%.
The log lines read e.g. false-positive rate=0.02283% — two hundredths of a
percent. The consistent range across all 13 flush cycles so far is
0.020%–0.027%, which lands right where the blocked-bloom math says it should
for ~16–26 bits/entry (the power-of-two rounding makes it wobble depending on
where the vector size falls), and comfortably inside my <0.1% prediction.
The other predictions are holding too:
- true=0 on every line so far — zero genuine hits, confirming the binary
search post-filter is executing only ~1,000 times per ~4.7M probes (0.02%).
The "faster binary search" question is now empirically dead: the code it would
speed up runs once per five thousand lookups.
- Probes are ~4–5M per cycle with positives ~1,000 — so the filter is
eliminating 99.98% of the binary searches the no-bloom run performed. That's
the 7m42s / 331s CPU you measured, now with its microscopic mechanism visible.
One structural note from the cadence: those filter lines are arriving every ~2
minutes, which means ReallocCompactSpents is running at the periodic
incremental Sync(), not just at full flushes — so each line covers one sync
interval. Fine for statistics (arguably nicer — more samples), just worth
knowing that "13 lines" ≠ "13 full flush cycles" when you read the log later.
Keep an eye out for one fun detail as the run passes height ~91,800: if a
true=1 ever appears, it'll be the BIP30 checks meeting the historical
duplicate-coinbase outpoints — the only legitimate true positives a valid
chain can produce. If the whole run ends true=0 everywhere, that's fine too
(those heights may fall before the first compaction of their cycle).
✻ Churned for 1m 23s
❯ you can tell if it's a flush versus sync in the log by seeing large=1 (flush)
or large=0 (which is a sync), so far i see only 2 sync out of 15. i don't
really understand how it decides whether to do a flush versus sync
● Let me read the actual decision logic so I can explain it precisely:
Searched for 1 pattern, read 1 file (ctrl+o to expand)
● Now I can give you the precise answer — it's one line, validation.cpp:2836:
empty_cache ? CoinsTip().Flush() : CoinsTip().Sync();
where empty_cache = FORCE_FLUSH || fCacheLarge || fCacheCritical. So the rule
is:
- Flush (large=1) — the write was triggered by memory pressure (cache ≥ LARGE,
i.e. within 10MiB of budget, or CRITICAL, or an explicit FORCE_FLUSH like
shutdown/resize). Since the whole point is reclaiming memory, it writes
everything and wipes the cache.
- Sync (large=0) — the write was triggered for any other reason while memory
is fine: overwhelmingly the periodic timer (m_next_write, randomized 50–70
min, line 2851 — randomized so the network's nodes don't all hit disk in
sync), plus prune-driven writes and FORCE_SYNC. Its purpose is bounding
crash-recovery replay time, and since memory isn't the problem, it writes the
dirty entries but keeps the cache contents warm — throwing away hot coins
would be pure waste.
Your 13-flush/2-sync ratio then falls right out: every write (either kind)
re-arms the 50–70 min timer, so a sync only happens when a flush cycle takes
longer than the timer. At this run's pace the cache is filling to LARGE
roughly every 2 minutes, so memory pressure nearly always wins the race; the
two syncs mark stretches where a cycle happened to span the timer (or
startup).
Two details worth knowing since they touch our work:
1. There's a mode asymmetry: fCacheCritical is only checked at IF_NEEDED
(called during block activation) while fCacheLarge only at PERIODIC (called
between activations). That's why CRITICAL exists at all as a distinct state —
it's the "can't wait for the next periodic check" escape hatch. Our goal of
zero CRITICALs amounts to: LARGE + the headroom must always catch it first.
2. Line 2845 right below is your commented-out CLOSE → NearFull() hook —
confirming it's positioned exactly where a "compact proactively before we get
squeezed" trigger would go, if the cap + 95% threshold don't already zero the
CRITICALs on their own.
Also note what Sync means for our patch: it drains and clears m_compact_spents
(the erases get batch-written) but keeps the map's unspent coins — so after a
sync the vector restarts empty while the cache stays hot. That's handled
correctly by ReallocCompactSpents being called from both paths.
✻ Churned for 1m 2s
❯ one thing i've wondered for a while (before starting on this project) is if
it might be possible for Flush to retain the cache entries (but they would be
non-dirty) like Sync does. i would think that would make the cache much more
efficient, since in many cases it wouldn't have to fault unspent coins in
from leveldb (they would be cache hits). but yes, there would still be memory
pressure, but what might be possible (or maybe not, i was just thinking
might be possible) is, when we need a cache entry, just grab one of the
existing non-dirty unspent entries and use (replace) it with the new coin, no
need to write it to disk since it's not dirty. there's probably a reason
this isn't possible or is a bad idea, but i don't understand why it wouldn't
work.
● Your instinct is correct — there's no fundamental reason it can't work. Clean
(non-dirty) entries are, by definition, exact copies of what's in leveldb, so
retaining them after a flush is always safe, and evicting one at any moment is
always safe (no write needed, exactly as you say, and no FRESH-flag or
crash-consistency hazard — those only constrain dirty entries). What you're
describing is turning the coins cache from a "write buffer that gets wiped"
into a true replacement cache. The reasons it isn't done are engineering
economics, not correctness:
1. Finding a victim is the missing machinery. Dirty and fresh entries are on
the linked lists; clean entries are on no list at all. "Grab a non-dirty
entry" means either adding a third linked list (16 bytes/entry overhead on
everything, plus list maintenance on the hottest paths — note your
spent/unspent sentinel split is literally the pattern you'd extend) or a
roaming bucket-cursor that skips dirty entries (cheap, effectively random
eviction — and random is respectable here since the salted hasher makes
iteration order adversary-proof).
2. The pool allocator changes what "freeing" means. Erasing one entry returns
its node to the pool freelist, not to the OS. For steady-state replacement
(your proposal) that's perfectly fine — evict one, insert one, RSS constant.
Where it bites is bulk shrinking: nTotalSpace is dynamic (the coins cache
borrows unused mempool budget, so it shrinks as the mempool fills), and today
the only way to actually give memory back is the full wipe + ReallocateCache.
A retaining cache needs an answer for "the budget just dropped 200MB" beyond
node-by-node erase that reclaims nothing.
3. The IBD payoff is real but bounded. Right after a flush the cache refills
mostly with new dirty coins; retention only converts misses on older coins
into hits. Spends are recency-biased, so with 1GB holding ~6% of the UTXO set,
a decent slice of your 609M leveldb lookups would become hits — but nothing
like all of them. And the value has been shrinking: #35295's parallel prefetch
now hides much of the refault latency (though retention would still save the
work — the CPU/leveldb-op argument, same one your patch benefits from).
4. Upstream has been circling this for years. The special case already merged:
prune-driven flushes no longer wipe the cache (that was andrewtoth's #28280)
— which is exactly your idea applied where it was free, since prune flushes
aren't memory-pressure flushes. Sync() itself is another half-step: retain
everything, when memory permits. The full version — retain + evict-on-demand —
has been experimented with periodically and always stalled on the same trade:
policy machinery on the hottest code path in the node, versus gains that
benchmarks found modest and workload-dependent. Old experiments also predate
both the pool allocator and prefetching, so the calculus keeps shifting.
The connection to your patch is worth noticing: compact-spents attacks the
same waste from the opposite end. Retention says "don't throw away clean
entries"; your patch says "make entries cheap enough that we flush 39% less
often" — every avoided flush is an avoided wipe, so you're already recovering
a chunk of what retention would recover, with no eviction policy needed.
They'd compose, but definitely as a separate future PR — this one has exactly
the right scope as it stands.
If you want to gauge the ceiling empirically someday: count, at each flush,
how many of the wiped clean unspent entries get re-faulted from leveldb within
the following cycle. That number (vs. 609M total lookups) is retention's
maximum possible win on this workload — measurable with a few log lines,
before writing any eviction code.
✻ Cooked for 1m 26s
❯ but maybe the clean coins can be on a new linked list? so all coins would be
on a list, unspent dirty, spent (that separation is what this patch does), or
unspent clean. then we could find a clean coin to replace in O(1) time. am i
missing something?
● No, you're not missing anything fundamental — and in fact the idea is cheaper
than I implied a moment ago, for a reason sitting right in front of us: every
CCoinsCacheEntry already carries the m_prev/m_next pointers. Clean entries
just leave them null today. The 16 bytes/entry I called overhead is already
paid for on every entry in the map; a clean list would simply use dead space.
Your CoinsSentinel struct extends naturally: unspent, spent, clean.
The hot-path cost analysis also comes out better than "list maintenance on the
hottest paths" suggests:
- A pure cache hit (AccessCoin/GetCoin on an existing entry) changes no flags
— zero list operations. The hottest operation is untouched.
- List links change only at state transitions (fault-in, spend, add,
flush-clean) — and every one of those already does something far more
expensive (a map insert/erase or a disk write). The relink is noise.
- Head-of-list eviction gives you FIFO-by-fault-time victims in O(1). True LRU
would require relinking on every read — that's the one variant that does
touch the hot path, and you likely don't need it; FIFO or CLOCK-style second
chance is a perfectly respectable policy for this workload.
So the mechanism is sound. What remains — the things that make it a real
project rather than an afternoon — are these three:
1. Budget shrinkage. Steady-state replacement (evict one clean, admit one new)
keeps memory flat via the pool freelist. But when nTotalSpace drops — the
mempool reclaiming its borrowed budget — per-entry eviction returns nothing to
the OS; the pool arena only shrinks on full teardown. The workable hybrid:
retain-and-replace normally, keep the full wipe + ReallocateCache as the
rare-path response to a genuine budget reduction (and shutdown). You need that
code anyway — it already exists.
2. Flush semantics shift. Flush becomes "write dirty entries, move them to the
clean list, keep everything" — i.e. Sync — and the only thing that ever
empties the map is the rare-path wipe. Memory pressure then gets absorbed by
eviction instead of flushing, which means the flush trigger itself wants
rethinking (you'd flush when the dirty portion alone threatens the budget,
since clean entries are reclaimable on demand). That's a deeper behavioral
change than it first appears — it touches the same GetCoinsCacheSizeState
logic we're already planning to adjust.
3. Proving it's worth it. The review bar for adding an eviction policy to
consensus-critical caching is high, and #35295 has already harvested some of
the same win by hiding refault latency. This is why I'd still run the cheap
measurement first: log, at each flush, how many of the about-to-be-wiped clean
unspent entries get re-faulted from leveldb in the next cycle. That single
number is the retention win's ceiling. If it's 5% of lookups, the idea waits;
if it's 40%, you have the opening line of the next PR's description.
One more pleasant synergy worth noting: eviction gives your NearFull() hook a
gentler cousin — when approaching the budget, evicting clean coins is even
cheaper than compacting spents, and completely incremental. A retain-cache
with both levers might rarely need memory-pressure flushes at all.
But all of that is the PR after this one. The current patch stands alone, the
sentinel refactor you've already done is the natural foundation for the clean
list, and the measurement hook can ride along whenever. For now: FP run, cap,
95% threshold, zero CRITICALs, commit, rebase.
✻ Brewed for 1m 5s
❯ oh yes, absoutely for sure, this would be way after the current one, if we
pursue it at all (but it seems like it may be worth pursuing)
● Agreed — it goes in the "promising, measurable, later" bucket. Let me add it
to the project notes so it doesn't get lost:
Wrote 1 memory (ctrl+o to expand)
● Saved. Meanwhile the queue for the current PR stands: FP run finishes → apply
the limit cap and the 95% LARGE threshold → re-run and gate on zero exceeds
total space events → strip the debug instrumentation → commit → rebase onto
#35295 → re-benchmark against the new base. I'll be ready whenever the run
completes.
✻ Worked for 15s
❯ one thing interesting in the logs that actually shows the compaction is
working is that, for a while after each CompactSpents event, the cache= log
at the end of each log line for each block doesn't change. that's expected,
because the coins being created are using memory that was freed by compacting
spents. but the DynamicMemory measurement of the cache is based on the pool
resource, so that memory is still allocated by the system (you were saying
this too just above). so it's interesting, but could be slightly confusing to
someone watching the logs who doesn't understand this. they would be
wondering, why isn't the cache going up?
● Your explanation is exactly right, and it's worth being precise about the
mechanism because it's actually the patch's win made visible:
memusage::DynamicUsage on a pool-allocated map counts allocated chunks in the
arena, not live nodes. Compaction returns ~a million nodes to the pool
freelist in one instant (chunk count unchanged, so cache= doesn't drop), and
then every new coin for the next stretch of blocks consumes a freelist node
instead of forcing a new chunk (so cache= doesn't rise either). The plateau
ends exactly when the freelist drains. Each block connected during a plateau
is a block of free cache occupancy — that flat line is precisely the "3.5×
cheaper spent coins" claim, drawn in the log.
Two things worth noting about the confusion concern:
1. The phenomenon isn't new, just amplified. Master already exhibits a milder
version: every FRESH coin spent within a cycle erases its map entry, feeding
the freelist, so master's cache= also grows sub-linearly during high-churn
stretches. Your patch turns a gentle sub-linearity into conspicuous flat
plateaus following visible CompactSpents events. And the accounting itself is
correct — arena-based measurement is what tracks actual RSS, which is what the
dbcache budget exists to bound. You wouldn't want it to report freelist space
as free.
2. The cheap fix is to make the log self-explanatory. Since the CompactSpents
debug line immediately precedes each plateau, extending it to say what
happened to memory closes the loop for any log reader, e.g.:
2. CompactSpents compacting: 940481 existing: 4702408 (freed ~120 MB of map
nodes for reuse; cache usage will plateau until consumed)
2. — or a shorter version, plus a sentence in the eventual code comment / PR
description pointing out that flat cache= after compaction is expected and is
the mechanism working. Reviewers reading their own test logs will hit exactly
the confusion you predicted, so preempting it in the PR text is probably worth
more than the log wording itself.
I'd fold the log-line tweak in with the cap change after the FP run finishes —
same file, same function, one commit.
✻ Crunched for 41s
❯ i don't quite like making that log line have that many words, but yes, maybe
something shorter, or just document elsewhere. that reminds me, we should
mention in the PR that one way to think about why this patch works is that
once a coin becomes spent, it stays spent until the reset event (flush or
sync), which maps perfectly to both a vector and bloom filter, because you
can't remove entries from those. leaving it as a map entry gives it
flexibility (being erased) that is unneeded, and there's a natural price to
pay for that. there is a VERY unusual scenario where a spent coin can be
deleted, actually replaced by an unspent, which is a chain reorg, but we
cover that too by giving priority to the map, which BatchWrite does by
processing the compact spents first (you said this earlier, that we need to
very clearly document this requirement). another thing: some may wonder, as i
initially did, whether these new data structures should use the pool
resource. but the answer is definitely no, because first of all, the pool
resource only works with fairly small allocations, although that can be
increased. but mainly because, its real advantage is when items are allocated
and freed very often, when there is a lot of churn, which there is in the
dbcache. but the spents don't have churn, so there's no advantage to having
the vector and bloom filter be part of the resource. might be worth
explaining that in the PR description
● All three points are exactly the kind of material that makes a big PR
reviewable, and rather than let them scatter across our conversation, let me
start a working draft file that accumulates them — we keep generating
PR-description content (benchmarks, mechanisms, design rationale) and it
should live somewhere durable:
● Write(pr-notes.md)
⎿  Wrote 116 lines to pr-notes.md
1 # PR description working notes — compact spents (draft, not for
2
3 Scratch material for the eventual PR description / commit messag
4
6
7 Once a coin becomes spent, it stays spent until the next reset e
8 Sync). The set of spent coins is therefore *append-only within a
n epoch*, and
tion — a sorted
a bloom filter
… +169 lines (ctrl+o to expand)
● Created pr-notes.md in the repo root (marked as a scratch draft, not for
commit) with everything so far — the monotonicity framing, the BatchWrite
ordering requirement, the pool-resource rationale, all benchmark numbers, and
the pending cap/threshold items.
On your specific points:
The monotonicity framing is the best one-paragraph explanation of the patch we
have, and I'd lead the PR design section with it. One sharpening worth
adopting: even the reorg case isn't a deletion from the vector — the re-added
coin lives in the map, the map shadows the vector on every lookup, and the
epoch-boundary ordering (spents erased before cursor writes) makes the newer
state win. So the vector's append-only property is literally never violated;
"deletion" is expressed as shadowing plus ordering. That's a stronger claim
than "we handle the exception" and it's what makes the design easy to trust.
Pool resource: agreed on both your reasons, and I added a third that reviewers
will appreciate — keeping the structures out of the pool keeps their
accounting and lifecycle independent of the arena (they're counted explicitly
and freed at reset without touching the pool's chunk lifecycle). Your churn
argument is the core one though: the pool amortizes high-frequency node-sized
alloc/free, and the spents structures do ~a dozen large long-lived allocations
per cycle. Opposite workload shape in both size and frequency.
Log line: agreed on brevity. Shortest useful form would be appending just the
freed byte count, e.g. CompactSpents compacting: 940481 existing: 4702408
freed=115MiB — one token, and a curious reader greps from there. The full "why
the plateau" explanation belongs in a comment above CompactSpents and one
line in the PR description, which is where I've put it in the notes. We can
finalize wording when the cap change goes in.
✻ Cogitated for 1m 15s
❯ love all that. could you make the change now to that log line? On the
monotonicity aspect, one thing i had to think about long and hard is, i was
worried about the following sequence: coin gets spent and turned into a spent
coin (it was not FRESH), then coin gets re-created as unspent, then it gets
deleted somehow. then that shadowing would no longer exist. There is an
Uncache method, and I'm not sure what it's used for, but i convinced myself
that this couldn't be a problem.. i think because it would always be DIRTY,
and if you look at that method, it won't touch a dirty coin.
● Both done below — the log line change, and your Uncache reasoning deserves to
be captured because it's exactly right and it generalizes into a clean
invariant. First the code:
● Update(src/coins.cpp)
⎿  Added 5 lines, removed 1 line
622 assert(m_initial_compact_spents > 0);
623 assert(m_spent_count >= m_spents_limit);
624 assert(m_spent_count > 0);
625 - LogDebug(BCLog::COINDB, "CompactSpents compacting: %i exis
-ting: %i\n", m_spent_count, m_compact_spents.size());
625 + // The freed value is approximate (map pool nodes are ~128
+ bytes). The freed
626 + // nodes return to the pool's freelist, not to the OS, so
+the reported cache
627 + // size plateaus after this event until new coins consume
+the freelist.
628 + LogDebug(BCLog::COINDB, "CompactSpents compacting: %i exis
+ting: %i freed~%iMiB\n",
629 + m_spent_count, m_compact_spents.size(), m_spent_c
+ount * 128 / (1 << 20));
630 //FreeSpents();
631 CompactSpentsList new_spents;
632 new_spents.reserve(m_spent_count);
Ran 1 shell command (ctrl+o to expand)
● Update(pr-notes.md)
⎿  Added 26 lines
25 > spents first makes the newer state win. Both `CCoinsViewDB::B
atchWrite` and
26 > `CCoinsViewCache::BatchWrite` do this; the base-class doc com
ment states it.
27
28 +### The shadow invariant (why shadowing can never be lost mid-e
+poch)
29 +
30 +The scary scenario: coin spent (non-FRESH) → compacted into the
+ vector → coin
31 +re-created unspent (a DIRTY map entry now *shadows* the stale v
+ector entry) →
32 +the map entry somehow disappears → the vector would wrongly rep
+ort the coin
33 +spent. This cannot happen; enumerate every path that removes or
+ cleans a map
34 +entry:
35 +
36 +1. **`Uncache()`** — the mempool's anti-pollution eviction — re
+fuses DIRTY
37 + entries, and a shadowing entry is always DIRTY (`AddCoin` ma
+rks DIRTY
38 + unconditionally). Safe.
39 +2. **`SpendCoin` of a FRESH re-added coin** erases the map entr
+y — but that is
40 + itself a semantic spend: the coin is now spent again, so the
+ surviving
41 + vector entry states the truth once more, and its pending lev
+eldb erase is
42 + exactly right. Safe.
43 +3. **`SetClean` transitions** occur only while `BatchWrite` con
+sumes the cursor
44 + — i.e. during Flush/Sync, the epoch reset that drains and cl
+ears the vector
45 + at the same moment (`ReallocCompactSpents`). Safe.
46 +4. **`Reset()`** clears map and vector together. Safe.
47 +
48 +Invariant: *a map entry shadowing a vector entry is always DIRT
+Y, and every
49 +path that removes or cleans a map entry is either itself a sema
+ntic state
50 +change that restores the vector entry's truth, or an epoch rese
+t that empties
51 +the vector simultaneously.* Worth stating in a code comment nea
+r
52 +IsCompactSpent.
53 +
54 ## Why the new structures do NOT use the pool resource
55
56 A question reviewers (and we ourselves, initially) will ask: sh
ould
● Done, syntax-checked (no rebuild while your run is going — the new freed~%iMiB
field will show up starting with the next build, alongside the cap change).
On what Uncache is actually for, since you mentioned not being sure: it's the
mempool's anti-pollution valve. When a transaction arrives for mempool
acceptance, validating it faults its input coins into the dbcache. If the tx
is rejected, those coins would linger as useless clean entries — and a peer
could exploit that by spamming invalid transactions to bloat your cache and
evict genuinely useful entries. So MemPoolAccept tracks which coins it pulled
in (coins_to_uncache) and calls Uncache on them when acceptance fails. The
reason it refuses dirty entries is fundamental, not incidental: a dirty entry
is unflushed state — removing it would silently lose a write. Which is
precisely why your reasoning holds: the refusal that protects normal dirty
state automatically protects the shadow entries too.
And your instinct to chase that scenario "long and hard" was the right
paranoia — I've written it up in the notes as the general shadow invariant
with all four removal/clean paths enumerated: Uncache (refuses dirty),
FRESH-shadow re-spend (the erase is itself a semantic spend, so the surviving
vector entry becomes true again), SetClean (only happens inside the epoch
reset that drains the vector anyway), and Reset (clears both together). Each
path is either semantics-preserving or simultaneous-with-vector-drain, so
shadowing can't be lost mid-epoch. That enumeration is PR-description gold —
it's the difference between "we think reorgs are handled" and "here is the
closed list of ways a shadow can disappear, and why each is safe" — and it
deserves a compact version in a comment near IsCompactSpent when we do the
cleanup pass.
✻ Churned for 1m 46s
❯ just a quick check, in the LogDebug lines i've been adding, and i see you did
this too just now, i'm using the format character %i (integer), is that the
best to use for size_t values? i think it's intended to be the most flexible,
so should be ok for both signed and unsigned, and various sizes of integers,
but just thought i'd ask you to make sure
● Short answer: with Bitcoin Core's logging it's safe, because
LogDebug/strprintf are tinyformat, not C printf — and tinyformat is type-safe.
The actual argument type (deduced by template) determines how the value is
rendered; the conversion character mostly just selects formatting (base,
width, etc.). So %i with a size_t prints the full 64-bit value correctly — no
truncation, no undefined behavior. The same holds for %d and %u; you'd have
needed %zu only in real printf, which is exactly the trap tinyformat exists to
remove.
Two nuances worth knowing:
1. Convention: the codebase overwhelmingly uses %d (and %u) for integral types
including size_t — %i is functionally identical but rare, so a reviewer might
nit it purely for consistency. When we do the cleanup pass I'd normalize the
new lines to %d.
2. What can still go wrong: the count of format specifiers must match the
argument count — that's now checked at compile time (ConstevalFormatString),
which is also why a stray literal % in a log message fails to build. And the
one genuinely dangerous place is any string that might ever reach a real C
printf-family function (e.g. leveldb internals or third-party code) — there
the C rules apply again.
So your instinct was right in effect, though the reason is "tinyformat ignores
your lie about the type" rather than "%i is flexible" — in C printf, %i is
strictly int-sized and would have been wrong for size_t.
✻ Baked for 22s
❯ okay i agree, let's change those to %d to head off review complaints, i'm
fine with that! if you want to do that now, go for it
Searched for 1 pattern (ctrl+o to expand)
● Update(src/txdb.cpp)
⎿  Added 1 line, removed 1 line
170
171 // It's important to erase these spent coin first, in case
a coin in this spent
172 // list is also in the cursor; the cursor coin must be wri
tten (not erased).
173 - LogDebug(BCLog::COINDB, "txdb BatchWrite spents %i\n", spe
-nts.size());
173 + LogDebug(BCLog::COINDB, "txdb BatchWrite spents %d\n", spe
+nts.size());
174 for (const COutPoint& outpoint : spents) {
175 CoinEntry entry(&outpoint);
176 batch.Erase(entry);
● Update(src/txdb.cpp)
⎿  Added 1 line, removed 1 line
201
202 LogDebug(BCLog::COINDB, "Writing final batch of %.2f MiB\n
", batch.ApproximateSize() / double(1_MiB));
203 m_db->WriteBatch(batch);
204 - LogDebug(BCLog::COINDB, "Committed %u (%i spent) changed t
-ransaction outputs (out of %u) to coin database...", (unsigned
- int)dirty_count, spent_count, (unsigned int)count);
204 + LogDebug(BCLog::COINDB, "Committed %u (%d spent) changed t
+ransaction outputs (out of %u) to coin database...", (unsigned
+ int)dirty_count, spent_count, (unsigned int)count);
205 LogDebug(BCLog::COINDB, "Lookups %u", m_lookups);
206 }
207
● Update(src/coins.cpp)
⎿  Added 1 line, removed 1 line
625 // The freed value is approximate (map pool nodes are ~128
bytes). The freed
626 // nodes return to the pool's freelist, not to the OS, so
the reported cache
627 // size plateaus after this event until new coins consume
the freelist.
628 - LogDebug(BCLog::COINDB, "CompactSpents compacting: %i exis
-ting: %i freed~%iMiB\n",
628 + LogDebug(BCLog::COINDB, "CompactSpents compacting: %d exis
+ting: %d freed~%dMiB\n",
629 m_spent_count, m_compact_spents.size(), m_spent_c
ount * 128 / (1 << 20));
630 //FreeSpents();
631 CompactSpentsList new_spents;
Ran 1 shell command (ctrl+o to expand)
● Done — all three branch-added %i uses are now %d (txdb.cpp's two lines and the
CompactSpents line), syntax-checked clean. The upstream %is in validation.cpp
are untouched, and I left the %us alone (including the filter-stats line)
since %u is also idiomatic in the codebase — though all of these
instrumentation lines are on the strip-before-PR list anyway, so their format
specifiers are mostly living on borrowed time.
Running tally of changes queued but not yet built (waiting on your FP run):
the %d normalizations and the freed~MiB log field. Still pending after that:
the limit cap, the 95% LARGE threshold, then the full rebuild + test pass.
✻ Cooked for 40s
❯ in a C project i worked on a long time ago, we had a test mode in which our
equivalent of LogDebug would generate the string to print but not actually
print it anywhere (just go reduce screen clutter or to keep the log file from
being too big). This was very helpful because without this, there could be
bugs at the LogDebug call sites where there could be a crash but only if a
specific log category was enabled. so for example, LogPrint("%d", a/b) ....
if b is zero, but the LogPrint never evaluates its arguments, this would sail
right through, no problem. But then a customer enables that log, and boom,
crash. but i think bitcoin core prevents this kind of problem too, doesn't
it?
● Let me check the exact mechanics in this tree so the answer is precise:
Searched for 1 pattern, ran 1 shell command (ctrl+o to expand)
● Confirmed both halves. The macro is the classic conditional form — if
(shouldlog(category)) { format-and-print(__VA_ARGS__); } — so arguments are
not evaluated when the category is disabled, exactly the hazard class from
your C project. Your a/b example would sail through silently until a user
enables that category. Bitcoin Core doesn't have a "format but discard" mode;
instead it defuses the problem from three other directions:
1. The test framework runs every functional-test node with -debug (all
categories) — that's test_node.py:144, with only leveldb and rand excluded for
noise. So CI is your old test mode in effect: every LogDebug site reached by
a functional test gets its arguments fully evaluated and formatted, and a
divide-by-zero there fails the test rather than a customer's node. The
residual exposure is exactly what you'd expect: sites in the two excluded
categories, and sites (or crashing input values) no test reaches.
2. Format strings are checked at compile time — the count of specifiers must
match the argument count, verified via consteval machinery regardless of
whether the category is ever enabled. So the "enabled a category and the
format string itself was broken" variant can't survive a build.
3. tinyformat's type safety removes the UB class — a wrong conversion
character can't corrupt memory the way mismatched printf specifiers can in C;
worst case is a formatting exception, not a wild pointer read.
What none of that covers, and the developer notes warn about explicitly, is
the flip side of conditional evaluation: side effects in log arguments —
LogDebug(cat, "%d", counter++) increments only for users with the category on,
which is a behavior fork rather than a crash and arguably nastier to debug.
For our branch this all lands comfortably: the new lines are in coindb, which
functional tests enable, so feature_block.py has been evaluating and
formatting every one of them on each run — and their arguments are all plain
member reads with no side effects and no arithmetic that can trap (the
m_spent_count * 128 / (1 << 20) divides by a constant).
✻ Sautéed for 1m 14s
❯ cool, thanks for checking all that, i thought so but wasn't positive. One
other thing we should mention in the PR is that feature_block.py does test
the filter/binary search logic, because if you disable those lookups, that
test fails. i believe that's the only functional test that fails, but great
that there is one! Also, the test_bitcoin coins_test fails if that lookup is
disabled ... Hey here's a thought! what if we add code that says, if the
bloom filter returns not found, we still do the binary search but within an
Assume? won't be any performance penalty on optimized build, but will check
in debug build (but still not slow things down too much) ... reviewers might
like to have that assurance
● Let me double-check the Assume semantics in this tree before answering,
because the answer hinges on it:
Searched for 1 pattern, ran 1 shell command (ctrl+o to expand)
● Update(src/coins.h)
⎿  Added 9 lines, removed 1 line
705 // The filter cheaply resolves the common case (no
t a compacted spent,
706 // including the empty-vector case); the binary se
arch confirms the rest.
707 ++m_filter_probes;
708 - if (!m_spents_filter.MayContain(outpoint)) return
-false;
708 + if (!m_spents_filter.MayContain(outpoint)) {
709 + if constexpr (G_ABORT_ON_FAILED_ASSUME) {
710 + // Debug builds only (compiles to nothing
+in release, where
711 + // it would negate the filter's benefit):
+verify the filter's
712 + // no-false-negative guarantee, on which c
+orrectness depends.
713 + Assume(!std::binary_search(m_compact_spent
+s.begin(), m_compact_spents.end(), outpoint));
714 + }
715 + return false;
716 + }
717 ++m_filter_positives;
718 }
719 const bool spent{std::binary_search(m_compact_spents.b
egin(), m_compact_spents.end(), outpoint)};
Ran 1 shell command (ctrl+o to expand)
● Syntax-clean. Also adding your mutation-coverage observation to the PR notes,
alongside the full verification story:
● Update(pr-notes.md)
⎿  Added 19 lines
51 the vector simultaneously.* Worth stating in a code comment nea
r
52 IsCompactSpent.
53
54 +## Test coverage / verification of the lookup correctness
55 +
56 +- **Mutation coverage**: disabling the `IsCompactSpent()` looku
+ps (the mutant
57 + that would silently accept double-spends) fails both `feature
+_block.py`
58 + (functional) and `coins_tests` (unit). Believed to be the onl
+y tests that
59 + catch this mutant — good that they exist; worth saying so in
+the PR.
60 +- **No-false-negative guarantee, verified two ways**:
61 + 1. `SanityCheck()` asserts `MayContain(v)` for every element
+of the vector —
62 + by contrapositive this proves any filter-negative outpoint
+ is not in the
63 + vector, exhaustively for the current state. Runs throughou
+t unit/fuzz tests.
64 + 2. Debug builds additionally verify per lookup: on every filt
+er-negative
65 + answer, an `if constexpr (G_ABORT_ON_FAILED_ASSUME)` block
+ runs the binary
66 + search inside `Assume`. Compiles to nothing in release. (N
+B: a bare
67 + `Assume` would NOT work — Assume always evaluates its expr
+ession in all
68 + build types; only the abort is debug-gated.)
69 +- `compact_spents_filter_basic` unit test: no false negatives o
+ver 10k inserts,
70 + FP rate bound, empty/Reset behavior. `compact_spents_lookup`
+unit test:
71 + end-to-end spend→compact→lookup-shadowing→flush against a bas
+e cache.
72 +
73 ## Why the new structures do NOT use the pool resource
74
75 A question reviewers (and we ourselves, initially) will ask: sh
ould
● Done and noted. The verification story now has a nice two-pronged shape for
reviewers:
1. Exhaustive, state-level: the SanityCheck loop asserts MayContain(v) for
every vector element — whose contrapositive is the no-false-negative guarantee
for the entire current state, checked constantly throughout unit and fuzz
tests.
2. Continuous, lookup-level (your idea, debug builds only): every
filter-negative answer re-verifies against the binary search, via if constexpr
(G_ABORT_ON_FAILED_ASSUME) so release builds compile it out entirely — the
assurance you wanted reviewers to have, at literally zero release cost.
And your mutation-coverage point is in the notes too — "commenting out the
lookup fails feature_block.py and coins_tests, and here's why those are the
only two" is exactly the kind of sentence that saves a reviewer an afternoon
of wondering whether the test suite would catch the scariest possible
regression.
One small heads-up for your Pi plans: since you build debug with clang-22,
that debug build will run the per-lookup verification — so don't benchmark
with it (which you wouldn't anyway; benchmarks come from the gcc build/ dir
where it compiles away).
Current queue unchanged: FP run finishes → limit cap + 95% LARGE threshold →
rebuild, full test pass, re-check CRITICAL count → strip instrumentation →
commit → rebase onto #35295.
✻ Cogitated for 1m 38s
❯ wow, that's great i had no idea G_ABORT_ON_FAILED_ASSUME exists! that's very
cool. yes, i realized right after i typed that message to you, the Assume
does run in optimized builds, it just doesn't care about the result (doesn't
crash), so it does need to be guarded by G_ABORT_ON_FAILED_ASSUME, excellent
... glad you mentioned that about clang, makes sense ... I think this guard
may be too much clutter for the final version, but reviewers will have an
opinion on that, definitely good to have in the first review version, love
that.
● Agreed on all counts — ship it in the first review version and let reviewers
vote. If it survives review but they want it tidier, it collapses nicely into
a one-liner helper (VerifyFilterMiss(outpoint)) so the hot path reads clean,
but that's a bikeshed for later. There's also a decent chance a reviewer
strengthens it instead — "debug-only verification of the scary invariant"
tends to be popular with the people who review consensus-adjacent code.
Status stands: waiting on the FP run, then cap + threshold + rebuild +
CRITICAL recount, strip instrumentation, commit, rebase. The draft PR material
in pr-notes.md now covers the framing, the shadow invariant, the
pool-resource rationale, the verification story, and all benchmark evidence —
it'll be a well-armed PR.
✻ Churned for 12s
❯ i don't quite understand how it could be strengthened?
● Fair question — "strengthen" was doing a lot of work in that sentence.
Concretely, the kinds of things a reviewer might ask for beyond the per-lookup
debug check:
1. A dedicated fuzz target for the filter itself. There's direct precedent:
src/test/fuzz/bloom_filter.cpp and rolling_bloom_filter.cpp exist for the
other bloom filters in the codebase. The pattern is differential: feed random
insert/query sequences, mirror them in a std::set, and assert the
no-false-negative property on every query — so the guarantee gets checked
against adversarial input shapes (empty resets, size-1 filters, duplicate
inserts, boundary counts around the power-of-two rounding) rather than the
friendly random data in our unit test. New probabilistic data structure in
consensus-adjacent code + existing precedent = very likely request.
2. Enabling compaction in coinscache_sim. This is the big one, and it's
already on your "figure out later" list. That fuzzer compares the whole cache
hierarchy against a naive ground-truth model — running it with
SetCompactSpentsThreshold(1) would exercise compaction, the filter, shadowing,
and the BatchWrite ordering against arbitrary operation sequences including
reorg-like patterns. It's the strongest verification the design can get, much
stronger than the per-lookup check (which only validates the filter against
the vector, not the vector against the truth).
3. Cheap always-on asserts at the rebuild sites. The per-lookup check is
debug-only by necessity (it's expensive). But O(1) consistency checks are free
enough for release: e.g. after CompactSpents, assert the filter is non-empty
iff the vector is — catching a desync at its source rather than waiting for a
lookup to trip over it.
4. static_asserts on the filter's constants — HASHES * 9 <= 64, block size is
a power of two, etc. — so a future "tune the parameters" patch can't silently
break the bit-packing.
The per-lookup positive direction, for completeness, needs no check at all:
when the filter says "maybe," the binary search is the answer — it's
self-verifying by construction.
Items 3 and 4 are five-minute additions we could just include; items 1 and 2
are real work that could either go in the first version (preempting the
request) or wait to see if reviewers ask. Given that #2 doubles as the answer
to your deferred "should tests enable compaction" question, it's probably
worth doing before the PR goes up.
✻ Worked for 26s
❯ oh i see ... another thought is, this definitely would be just just for
initial review, is to call SanityCheck from a few strategic places (but i've
seen before that there are places you can't call it where you would think you
can, because of the weird coins_tests -- it does things that aren't possible
in production) guarded by G_ABORT_ON_FAILED_ASSUME ... too bad there's not a
clang equivalent for that!
● Quick check on how that flag actually gets set, because there's good news
hiding in your lament:
Searched for 1 pattern (ctrl+o to expand)
● Good news on the lament: there's nothing to wish for —
G_ABORT_ON_FAILED_ASSUME is build-type-driven, not compiler-driven.
ABORT_ON_FAILED_ASSUME is a compile definition CMake sets for Debug
configurations, so your clang-22 debug/ build gets it automatically, identical
to gcc. And notice the other branch of that constexpr: fuzzing builds force
it true unconditionally — meaning the per-lookup verification we just added
has been silently promoted: it runs during every coins fuzz execution too,
where the inputs are adversarial. That's a nicer property than we ordered.
On strategic SanityCheck calls — yes, with one placement trick that
neutralizes exactly the coins_tests weirdness you've been burned by before.
The problem: the matrix tests raw-insert states that can't exist in production
(SPENT_CLEAN, SPENT_FRESH...) and deliberately skip SanityCheck for them; if
we embed calls inside coins.cpp functions those tests invoke (AddCoin,
BatchWrite...), Debug CI trips over intentionally-impossible states. The
dodge: put the calls one layer up, in validation.cpp — e.g. right after
CoinsTip().Flush() / Sync() in FlushStateToDisk, and perhaps after
CompactSpents-heavy stretches via the periodic flush path. Unit tests never
execute FlushStateToDisk, so only production-shaped state ever reaches those
checks. Guarded with if constexpr (G_ABORT_ON_FAILED_ASSUME), it costs nothing
in release, and in Debug it's an O(map + vector) pass at flush cadence —
trivial next to a Debug-build IBD.
One more pattern worth knowing exists, in case reviewers want verification
available in release builds too: the mempool precedent. CTxMemPool::check() is
a full internal-consistency audit that runs in any build, gated by the
-checkmempool=<ratio> runtime flag rather than build type. A -checkcoins-style
knob doing periodic SanityCheck would follow that exact precedent — probably
overkill for this PR, but it's the established answer if someone asks "how
would a release user verify this."
So the initial-review hardening menu, cheapest first: the two static_asserts,
the O(1) rebuild-site asserts, validation-level SanityCheck calls under the
debug guard, and — the substantial one — compaction enabled in coinscache_sim.
All are queued behind the cap/threshold work; none block the benchmark
analysis.
✻ Cogitated for 53s
※ recap: Improving IBD via the compact-spents dbcache patch; currently waiting for your bloom-enabled FP-stats reindex to finish. Next: apply the compaction-limit cap and 95% flush threshold, then rebuild and confirm zero critical flushes before commit and rebase.
❯ ok so i've lost track a little .. are there code changes you can make now? if
so, go ahead please
● Yes — several queued items are now unblocked. Let me first check whether your
FP benchmark is still running (that gates whether I can build):
Ran 1 shell command (ctrl+o to expand)
● Update(src/coins.cpp)
⎿  Added 8 lines, removed 6 lines
654 for (const COutPoint& outpoint : m_compact_spents) {
655 m_spents_filter.Insert(outpoint);
656 }
657 - // 20% growth each flush cycle
658 - // LMR this 20% is just an intuitive guess, of course
659 - m_spents_limit = m_compact_spents.size() * 20 / 100;
660 - // LMR needed because in some tests, we start this mechani
-sm very small,
661 - // initial_compact_spents = 1 (anything non-zero enables i
-t).
662 - // But we have to make progress
657 + // Geometric growth (20% of the vector size) keeps the tot
+al merge work per
658 + // flush cycle linear in the final vector size (a fixed ba
+tch size would be
659 + // quadratic). The cap bounds how much map-resident spent
+bloat (~128 bytes
660 + // per entry) can accumulate late in a cycle to the same l
+evel the initial
661 + // threshold already deems acceptable (~1/8 of the cache),
+ at the cost of a
662 + // few extra sub-second merges.
663 + m_spents_limit = std::min(m_compact_spents.size() * 20 / 1
+00, m_initial_compact_spents);
664 + // Tests can enable compaction with a threshold as small a
+s 1; always make progress.
665 if (m_spents_limit == 0) m_spents_limit = 1;
666 }
667
● Update(src/validation.h)
⎿  Added 11 lines, removed 2 lines
520 {
521 // No periodic flush needed if at least this much space is
free
522 constexpr int64_t MAX_BLOCK_COINSDB_USAGE_BYTES{int64_t(10
_MiB)};
523 - return std::max((total_space * 9) / 10,
524 - total_space - MAX_BLOCK_COINSDB_USAGE_BYTE
-S);
523 + // The fixed 10 MiB headroom assumes cache usage grows smo
+othly with each
524 + // block. Compacting spent coins steps the measured usage
+by up to 36 bytes
525 + // per compacted entry in a single block — bounded by the
+compaction
526 + // threshold (~1/1024th of the cache in entries, so ~3.5%
+of the cache in
527 + // bytes; see CompactSpentsThreshold()). Cap the trigger a
+t 95% so the
528 + // headroom scales with the cache and always covers that s
+tep plus one
529 + // block's organic growth. (For caches below ~200 MiB the
+pre-existing
530 + // terms already provide at least this much headroom.)
531 + return std::min((total_space * 95) / 100,
532 + std::max((total_space * 9) / 10,
533 + total_space - MAX_BLOCK_COINSDB_U
+SAGE_BYTES));
534 }
535
536 //! Chainstate assumeutxo validity.
● Update(src/coins.h)
⎿  Added 4 lines
262 //! power of two.
263 static constexpr size_t BITS_PER_ENTRY{16};
264
265 + static_assert(WORDS_PER_BLOCK * 64 == 512, "a block must b
+e exactly one 512-bit cache line (bit positions are 9 bits)");
266 + static_assert(HASHES * 9 <= 64, "bit positions are drawn 9
+ bits at a time from a single 64-bit hash");
267 + static_assert(BITS_PER_ENTRY > 0);
268 +
269 //! Salts, random unless deterministic (see SaltedOutpoint
Hasher). The txid
270 //! part of an outpoint is already uniformly distributed,
but is chosen by
271 //! peers; the salt keeps an attacker from grinding txids
that concentrate
● Update(src/coins.h)
⎿  Added 3 lines
339 return true;
340 }
341
342 + //! True when the filter describes the empty set.
343 + bool Empty() const noexcept { return m_words.empty(); }
344 +
345 size_t DynamicMemoryUsage() const { return memusage::Dynam
icUsage(m_words); }
346 };
347
● Update(src/coins.cpp)
⎿  Added 4 lines
654 for (const COutPoint& outpoint : m_compact_spents) {
655 m_spents_filter.Insert(outpoint);
656 }
657 + // Cheap always-on check: the filter and vector must be in
+ lockstep (a
658 + // desync in the other direction, a false negative, is cau
+ght per-lookup in
659 + // debug builds and exhaustively by SanityCheck()).
660 + Assume(m_compact_spents.empty() == m_spents_filter.Empty()
+);
661 // Geometric growth (20% of the vector size) keeps the tot
al merge work per
662 // flush cycle linear in the final vector size (a fixed ba
tch size would be
663 // quadratic). The cap bounds how much map-resident spent
bloat (~128 bytes
● Update(src/validation.cpp)
⎿  Added 7 lines
2834 }
2835 // Flush the chainstate (which may refer to b
lock index entries).
2836 empty_cache ? CoinsTip().Flush() : CoinsTip()
.Sync();
2837 + if constexpr (G_ABORT_ON_FAILED_ASSUME) {
2838 + // Debug builds: full consistency audit o
+f the coins cache at
2839 + // flush boundaries. Placed here (not in
+coins.cpp) so unit
2840 + // tests that deliberately construct impo
+ssible cache states
2841 + // never reach it.
2842 + CoinsTip().SanityCheck();
2843 + }
2844 full_flush_completed = true;
2845 TRACEPOINT(utxocache, flush,
2846 int64_t{Ticks<std::chrono::microseconds>(
NodeClock::now() - nNow)},
Ran 1 shell command (ctrl+o to expand)
● Update(src/test/fuzz/coinscache_sim.cpp)
⎿  Added 4 lines
243 // Make sure there is always at least one CCoinsViewCa
che.
244 if (caches.empty()) {
245 caches.emplace_back(new CCoinsViewCache(&bottom, /
*deterministic=*/true));
246 + // Compact spent coins as aggressively as possible
+, so this fuzzer
247 + // exercises compaction, the bloom filter, and sha
+dowing against the
248 + // simulated ground truth.
249 + caches.back()->SetCompactSpentsThreshold(1);
250 sim_caches[caches.size()].Wipe();
251 }
252
● Update(src/test/fuzz/coinscache_sim.cpp)
⎿  Added 1 line
381 } else {
382 caches.emplace_back(new CoinsViewOverl
ay(&*caches.back(), /*deterministic=*/true));
383 }
384 + caches.back()->SetCompactSpentsThreshold(1
+);
385 // Apply to simulation data.
386 sim_caches[caches.size()].Wipe();
387 }
● Update(src/test/fuzz/coins_view.cpp) ⎿  Added 1 line
356 {
357 FuzzedDataProvider fuzzed_data_provider{buffer.data(), buf
fer.size()};
358 CCoinsViewCache coins_view_cache{&CoinsViewEmpty::Get(), /
*deterministic=*/true};
359 + coins_view_cache.SetCompactSpentsThreshold(1);
360 TestCoinsView(fuzzed_data_provider, coins_view_cache, &Coi
nsViewEmpty::Get());
361 }
362
● Update(src/test/fuzz/coins_view.cpp)
⎿  Added 1 line
370 };
371 CCoinsViewDB backend_coins_view{std::move(db_params), Coin
sViewOptions{}};
372 CCoinsViewCache coins_view_cache{&backend_coins_view, /*de
terministic=*/true};
373 + coins_view_cache.SetCompactSpentsThreshold(1);
374 TestCoinsView(fuzzed_data_provider, coins_view_cache, &bac
kend_coins_view);
375 }
376
● Update(src/test/fuzz/coins_view.cpp)
⎿  Added 4 lines
382 {
383 FuzzedDataProvider fuzzed_data_provider{buffer.data(), buf
fer.size()};
384 MutationGuardCoinsViewCache backend_cache{&CoinsViewEmpty:
:Get(), /*deterministic=*/true};
385 + // Compaction on the backend happens entirely inside Batch
+Write, which the
386 + // mutation guard permits; the overlay compacts during nor
+mal operation.
387 + backend_cache.SetCompactSpentsThreshold(1);
388 CoinsViewOverlay coins_view_cache{&backend_cache, /*determ
inistic=*/true};
389 + coins_view_cache.SetCompactSpentsThreshold(1);
390 TestCoinsView(fuzzed_data_provider, coins_view_cache, &bac
kend_cache);
391 }
● All edits in. Now the niced syntax checks across every touched TU:
Ran 1 shell command (ctrl+o to expand)
● Update(pr-notes.md)
⎿  Added 13 lines, removed 10 lines
139 silent bool→size_t misbinding at call sites).
140 - Bloom filter self-sizes from vector content (~2–4 bytes/entr
y, power-of-two
141 blocks); no configuration.
142 -- Growth rule: compact again when map spents reach 20% of the
-vector size
143 - (geometric growth keeps total merge work linear; fixed batch
-es would be
144 - quadratic). PENDING: cap the limit at the initial threshold
-—
145 - `min(size*20/100, m_initial_compact_spents)` — to bound late
--cycle map bloat
146 - (~200 MB at 8M vector) which currently causes CRITICAL flush
-es (8–12 per
147 - reindex vs 0 on master).
148 -- PENDING: LARGE flush threshold headroom should scale: `total
- − max(10MiB,
149 - 5%·total)`. The fixed 10 MiB assumes smooth per-block growth
-; compaction
150 - introduces deliberate stepwise accounting changes bounded (a
-fter the cap) at
151 - 36B × threshold ≈ 3.52% of the cache.
142 +- Growth rule: compact again when map spents reach 20% of the
+vector size,
143 + capped at the initial threshold — `min(size*20/100, m_initia
+l_compact_spents)`.
144 + Geometric growth keeps total merge work linear (fixed batche
+s would be
145 + quadratic); the cap bounds late-cycle map bloat (~200 MB at
+8M vector
146 + uncapped), which caused CRITICAL flushes (8–12 per reindex v
+s 0 on master).
147 + DONE 2026-07-26; verify CRITICAL count == 0 in the next benc
+hmark run.
148 +- LARGE flush threshold clamped at 95% of total space (was: fi
+xed 10 MiB
149 + headroom, which assumes smooth per-block growth; compaction
+steps accounting
150 + by up to 36B × threshold ≈ 3.52% of the cache in one block).
+ DONE 2026-07-26.
151 +- Fuzz coverage: all coins fuzz targets now run with compactio
+n threshold 1
152 + (coinscache_sim = differential vs ground-truth model). Debug
+ builds also
153 + run SanityCheck at every flush boundary and re-verify every
+filter-negative
154 + lookup. DONE 2026-07-26.
155
156 ## Memory spike during CompactSpents (be upfront in PR)
157
● Everything that can move without the machine has moved. When your FP run
finishes, the sequence is:
1. Full rebuild + verification — unit suite, feature_block.py, all four fuzz
targets (these now exercise compaction for the first time, so this run has
real teeth — I half-expect the sim fuzzer to find something interesting, and
if it does, that's it earning its keep).
2. A benchmark run with the cap + 95% threshold, gating on grep -c "exceeds
total space" == 0 — plus it'll produce the first freed~MiB log lines and
another timing data point.
3. If the CRITICALs are gone: delete the CLOSE/NearFull experiment (healing
validation_flush_tests), strip the instrumentation, commit, and start the
#35295 rebase.
Send word when the run's done — and I'm still curious whether a stray true=1
shows up in the filter stats around the duplicate-coinbase heights.
✻ Crunched for 3m 30s
❯ by the way I'd forgotten i added NearFull, it was for an earlier approach
where we wouldn't compact any coins until we were almost full. but i like
this current idea much better (compact as we go, exponentially increasing),
and i forgot to remove NearFull. but we probaly will want to bring that back
if and when we try implementing the other idea (keeping the cache hot after
Flush) ... i think that concept will be needed then .... okay, benchmark is
still running, i'll let you know when it's done
● Good context — that explains its shape perfectly (a trigger sitting exactly at
the "almost full" boundary). And you're right that it's conceptually
load-bearing for the retain-cache idea: in that design, "nearly full" stops
meaning "flush soon" and starts meaning "evict or compact something," so a
NearFull-style hook becomes the natural valve. Let me capture the provenance
so future-us doesn't reinvent it:
Wrote 1 memory (ctrl+o to expand)
● Noted. So the deletion queue after the benchmark is guilt-free: CLOSE, the
NearFull() comment, and the enum renumbering all go, validation_flush_tests
heals, and the concept lives on in the notes for the retain-cache project
where it actually belongs. Talk to you when the run's done.
✻ Sautéed for 16s
❯ you could remove that NearFull and CoinsCacheSizeState::CLOSE, we have a note
of it for later, definitely don't want reviewers to see that stuff
Searched for 1 pattern (ctrl+o to expand)
● Update(src/validation.h)
⎿  Added 2 lines, removed 3 lines
509 enum class CoinsCacheSizeState
510 {
511 //! The coins cache is in immediate need of a flush.
512 - CRITICAL = 3,
512 + CRITICAL = 2,
513 //! The cache is at >= 90% capacity.
514 - LARGE = 2,
515 - CLOSE = 1,
514 + LARGE = 1,
515 OK = 0
516 };
517
● Update(src/validation.cpp)
⎿  Added 1 line, removed 6 lines
2710 if (cacheSize > nTotalSpace) {
2711 LogInfo("Cache size (%s) exceeds total space (%s)\n",
cacheSize, nTotalSpace);
2712 return CoinsCacheSizeState::CRITICAL;
2713 - }
2714 - int64_t large_coins_cache_threshhold{LargeCoinsCacheThres
-hold(nTotalSpace)};
2715 - if (cacheSize > large_coins_cache_threshhold) {
2713 + } else if (cacheSize > LargeCoinsCacheThreshold(nTotalSpa
+ce)) {
2714 return CoinsCacheSizeState::LARGE;
2715 }
2718 - if (cacheSize > large_coins_cache_threshhold * 98 / 100)
-{
2719 - return CoinsCacheSizeState::CLOSE;
2720 - }
2716 return CoinsCacheSizeState::OK;
2717 }
2718
Ran 1 shell command (ctrl+o to expand)
● Update(src/validation.cpp)
⎿  Removed 2 lines
2844 (uint64_t)coins_mem_usage,
2845 (bool)fFlushForPrune);
2846 }
2847 - } else if (cache_state == CoinsCacheSizeState::CLOSE)
- {
2848 - //CoinsTip().NearFull();
2847 }
2848
2849 if (should_write || m_next_write == NodeClock::time_p
oint::max()) {
Ran 1 shell command (ctrl+o to expand)
● Gone — no references left, enum values restored to upstream (CRITICAL = 2,
LARGE = 1), and GetCoinsCacheSizeState is back to its exact upstream shape
(the only intentional remaining change in that area is the 95% clamp inside
LargeCoinsCacheThreshold, which for the small cache sizes
validation_flush_tests uses is a no-op — the 90% term dominates below ~200 MiB
— so that suite should go fully green on the next build).
That clears the last piece of dead experimental code from the diff. Current
uncommitted state is now, in its entirety: the compact-spents core, the bloom
filter + verification hardening, the cap + scaled threshold, the fuzz/test
coverage enabling, and the (deliberately temporary) instrumentation. Once your
benchmark finishes: full rebuild, complete test pass (expecting zero failures
anywhere for the first time), then the cap-verification benchmark whenever
you want to run it.
✻ Churned for 1m 4s
❯ side question about claude / opus : these diffs you're showing me as you do
them are very useful, but they're not in the transscript. is it possible,
after i exit from this session, to see those again, to recreate or reproduce
everything i'm seeing now?
● Good question — let me pull in the Claude Code documentation agent to make
sure I give you exact, current answers about transcript storage and resume
behavior:
● claude-code-guide(Check transcript/diff persistence)
⎿  Done (12 tool uses · 28.9k tokens · 1m 29s)
(ctrl+o to expand)
● Yes — everything is recoverable, at several levels of convenience:
1. The session transcript is fully preserved on disk. Every session lives at
~/.claude/projects/<project-slug>/<session-id>.jsonl (this one:
~/.claude/projects/-sd-g-w-bitcoin-spentdb/2fdc30fe-....jsonl). Every
Edit/Write call is stored with its complete old_string/new_string, so all the
diffs you've watched can be reconstructed exactly. Caveats: the JSONL format
is internal and can change between releases, and sessions are cleaned up after
30 days by default — if you want this one kept indefinitely, set
cleanupPeriodDays higher in ~/.claude/settings.json, or export it (next point)
while it's fresh.
2. /export gives you a durable, human-readable copy. Run /export <filename> in
this session (or after resuming it) and you get the rendered conversation as
a plain-text file — the most practical "keep this forever" option.
3. Resuming re-enters the session itself. claude --continue reopens the most
recent session in this directory; claude --resume gives a picker (Space
previews a session). History, context, and the conversation state come back.
Whether old diffs re-render with their original terminal formatting on resume
is (per the docs agent I just consulted) not guaranteed — the reliable
rendering path is /export.
4. But the strongest answer for this project is one you already built: git.
The diffs I show you are increments; their sum is sitting in your working tree
right now. You staged the pre-bloom state deliberately "to see the edits you
will do" — so git diff is, at this moment, the complete, canonical, replayable
record of everything from the bloom filter onward, and git diff --staged
covers the earlier phase. The moment we commit (soon, pre-rebase), that
history becomes permanent regardless of transcript retention. If you want
finer granularity than two big diffs, that's an argument for committing the
work as a series of logical commits rather than one blob — which we should do
anyway for reviewability, and which I'd propose structuring as roughly: core
compact-spents, bloom filter, threshold scaling, test/fuzz coverage,
instrumentation (to be dropped).
Practical recommendation: run /export at the end of today's session for the
discussion record, and let git carry the code record. Between those two plus
pr-notes.md and my memory files, nothing we've done here is at risk of being
unreproducible.
✻ Cooked for 2m 11s
❯ yes, actually, i thought that already, before review, this should definitely
be broken into logical commits ... i like to not worry about that too early,
because it's tedious (at least when i do this manually) to make changes to
earlier commits ... so i like to just do everything without any commits,
maybe staging once in a while, before organizing the commits. but we will
have to do that, i'm sure you can help with that
● That workflow makes total sense — and the tedious part (interactive rebase
surgery when an early commit needs a tweak) is exactly the part I'm good at
absorbing, since I can rebuild the series from scratch cheaply: with the full
diff in the working tree, I can stage hunks selectively (git add -p-style) and
construct the logical series in one pass, then verify each commit builds and
passes tests individually (reviewers and git bisect both appreciate that). If
a change to an early commit is needed later, regenerating the series is often
easier than editing it.
One sequencing note: the commit organization has to happen before the #35295
rebase (rebase moves commits, so they need to exist), so the natural order
stands as: benchmark → verification → strip instrumentation → build the commit
series → rebase. Rough series shape, refined when we get there:
1. Sentinel split (spent/unspent lists) + Merge() — mechanical foundation, no
behavior change
2. Core compact-spents: vector, CompactSpents, BatchWrite signature +
ordering, IsCompactSpent
3. Bloom filter + debug verification
4. Threshold scaling (SetCompactSpentsThreshold, CompactSpentsThreshold, cap,
95% clamp)
5. Test/fuzz coverage (unit tests, fuzz enablement, SanityCheck extensions)
Let me record the workflow preference:
Wrote 2 memories (ctrl+o to expand)
● Saved (and fixed a stale "5.6%" in the index while I was there). So when the
time comes, you review the series — commit boundaries, messages, what goes
where — and I do the assembly and any later surgery. Until then, keep working
commitless exactly as you prefer. Awaiting the benchmark.
✻ Cooked for 32s
❯ your help actually can change my workflow, it seems ... since you're good at
amending earlier commits, we can probably move to separate commits earlier
than what i've done in the past, that's great
● That's a nice second-order effect — the cost of maintaining structure just
dropped, so structure earlier becomes rational. The practical middle ground
that works well: once the code stabilizes (say, after this benchmark cycle),
we cut the series, and from then on new work goes into whichever commit it
logically belongs to via fixups I fold in — you get the "just keep working"
feel with a review-ready series at all times, plus per-commit history if you
ever want to bisect your own branch. Let me update the note so it reflects the
evolving preference rather than freezing the old one:
Wrote 1 memory (ctrl+o to expand)
● Updated. And this branch is actually the perfect first candidate: once your
benchmark confirms the cap kills the CRITICALs, the code is stable enough to
cut the series — then the remaining work (instrumentation strip, rebase
fallout, review feedback) lands as targeted amendments to the right commits
instead of one growing blob. We'll be practicing the new workflow on the PR
that motivated it.
✻ Cooked for 19s
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment