Skip to content

Instantly share code, notes, and snippets.

@LarryRuane
Last active August 1, 2026 16:00
Show Gist options
  • Select an option

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

Select an option

Save LarryRuane/3772e96e46452c96a5d5ea68aa425e9d to your computer and use it in GitHub Desktop.
1-Aug-2026 - compact spent coins

PR description working notes — compact spents (draft, not for commit)

Branch: https://github.com/LarryRuane/bitcoin/tree/2026-07-compact-spents

Scratch material for the eventual PR description / commit messages. Grows as we go.

Larry's (human) description

The next section and beyond was written by Claude Code Fable 5, but this section is by ME, an actual human! The overall design is mine, Claude was very helpful in writing the code, reasoning about designs, running benchmarks, super impressive. I only started using Claude a week ago and it's a game-changer as I'm sure you know. But I just want to be clear that the main ideas are all mine (I'm not quite ready to be replaced by a robot yet!).

I had many false starts (all pre-Claude), so this isn't my first try, I bombed many times. But I think this is finally worth considering for actual merge.

There is no PR yet.

Here's the simplest way to explain this patch: Utilize dbcache memory better by representing spent coins more space-efficiently. We call these compact spent coins. This efficiency comes from two things:

  • store just a COutPoint; CCoinsCacheEntry includes a Coin, but it's unused for spent coins (other than coin.out.nValue == -1 to indicate that it's spent)

  • store these outpoints in a std::vector. This uses the most theoretically minimal possible space, 36 bytes (32 byte txid and 4 byte index)

But there's no free lunch. When looking up a coin (PeekCoin and FetchCoin), we first check the cacheCoins map, and if not found, then (this is the new part) before asking our base to look it up, we must check this vector. If the coin (its outpoint) exists there, then it's the same as if we had found the coin as spent in the map, so we return "not found". If it's not in the vector, then we do the same as before, ask the base.

So there is an extra lookup. But, at least for most configurations, this extra lookup is more than compensated for by being able to store more coins overall in the given dbcache size. It's as if our dbcache is larger by the amount gained by the spent coin compaction.

An obvious problem is, it takes O(N) to search a vector. The solution is to keep the vector sorted and use binary search. It would be very inefficient to sort this large vector every time an outpoint is added to it. So we batch. Spent coins are created exactly as they are today. They can be found by searching cacheCoins like today, no difference. But when we accumulate a threshold number of spent coins, we allocate a vector large enough to hold them, then add each spent coin's outpoint to the vector, and delete the spent coin from the cacheCoins map (that's where the space savings happens). We immediately sort the vector. Now we can search the vector using std::binary_search.

It turns out that binary search is slow enough to wreck reindex-chainstate benchmarks, so one final refinement is to put a bloom filter in front of the vector. If the filter says an outpoint isn't present, then it's definitely not present, and we can go ahead and read from disk. If it might be present, then we revert to binary search. If it's not found there (almost always), we read from disk. This bloom filter's lookup reads a single 64-byte cache line, which is much better memory behavior than a binary search (which, if there are 1m spent coins, would reference about 20 cache lines).

Flush and Sync send this vector (along with the cursor, as today) to base->BatchWrite, which does leveldb erases (or whatever is appropriate for that cache). The cursor (linked list of coins) can also contain spent coins, but there will usually be far fewer there than in the vector. Then Flush and Sync reset the vector and recreate the bloom filter because all spent coins are destroyed, and we're ready for another "flush epoch".

One subtlety: if a compacted spent coin is later re-created (reorg), the new map entry shadows the vector for lookups, and just before BatchWrite we remove any vector entries that also appear in the map — so the erases can never clobber a live coin.

Given that we can't add each spent coin as it's created to the sorted vector, we have to have some way of staging things. We reset on every flush epoch. The sequence within one flush epoch is: (as described earlier) the number of spents reaches some number (about 1m for a 1gb cache), so we allocate the vector of exactly the right size (and create an empty bloom filter), move the spents from the map to the vector, freeing up space overall. Each CCoinsCacheEntry we delete frees about 136 bytes, and each outpoint we store in the vector uses about 38 bytes (36 for the outpoint plus around 2 for the bloom filter). Given that roughly (by observation) about 40% of the coins in the cache are spent (depends on many factors), that's a pretty big savings.

So we've done our first compaction (after a Flush), what happens next? Suppose the vector size is 1m (again, with a 1000 mb dbcache, which is the default on typical systems — 64-bit with at least 4 GB of RAM; smaller systems default to 450 mb). When the number of cacheCoins spents reaches 20% of the current number of compact coins (20% being a somewhat arbitrary fraction), then we:

  • allocate a temp vector of size 200'000 (20% of 1m)
  • move the spents from the map to this 0.2m vector (freeing space)
  • sort the temp 0.2m vector
  • allocate a new big vector of size 1.2m
  • std::merge the existing vector (1m) and the temp vector (0.2m) into the big vector
  • delete the 0.2m temp vector
  • std::move the big vector onto the existing vector
  • reset the bloom filter and create it again to hold 1.2m elements

In case you're unfamiliar, std::merge combines two sorted vectors into a new (separate) sorted vector whose size is the sum of the sizes of the two input vectors in O(N) time.

So we continue in this way, with 20% exponential growth. So when the spent coin count (in the map) reaches 0.24m (20% larger than the previous time, which was 0.2m), then we allocate a temp vector of size 0.24m, move the spents to it, sort, allocate a new big vector of size 1.44m, merge. This tries to strike a balance between the work to compact the coins and the compaction savings. That initial 1m value is just a linear function of the total cache size, nothing fancy. By the time the Flush arrives, we've compacted about 3m to 4m coins (more as we get later in the reindex) out of around 8m cached coins. Another 0.3m to 0.4m spents are still in the map — ones created after the last compaction — and those go out via the cursor as usual.

This entire mechanism is disabled for all caches (views) except the main one (we don't want any extra overhead while processing a block). The new stuff must be enabled explicitly; by default it's not there, including zero dynamic memory allocation.

Okay, the rest of this is Claude Code, but much of it is documenting things Claude and I discussed (so a lot of ideas from both of us). It's not a PR draft, it's more of a time-ordered log of decisions, successes and failures, minor observations that we didn't want to forget, etc. It will be a good source to write a PR, but that's about it.

BEGIN CLAUDE Core framing: spentness is monotonic within a flush epoch

Once a coin becomes spent, it stays spent until the next reset event (Flush or Sync). The set of spent coins is therefore append-only within an epoch, and that maps perfectly onto data structures that don't support deletion — a sorted vector (36 bytes/entry, zero per-entry allocation overhead) and a bloom filter (no false negatives, one cache line per negative probe). A full CCoinsMap entry (~128 bytes via the pool allocator) pays for flexibility — arbitrary insert/erase/mutate — that a spent coin never uses. That flexibility has a natural price; this patch stops paying it.

The one scenario that looks like "deleting a spent coin" — a chain reorg re-adding a previously spent output — never actually deletes from the vector. The re-added coin lives in the map, and the map always shadows the vector (lookups check the map first). The vector entry is resolved at the epoch boundary by ordering:

Hard requirement on every CCoinsView::BatchWrite implementation: the spents list must be applied (erased) before the cursor entries are written. Map/cursor entries are always newer than vector entries; processing spents first makes the newer state win. Both CCoinsViewDB::BatchWrite and CCoinsViewCache::BatchWrite do this; the base-class doc comment states it.

The shadow invariant (why shadowing can never be lost mid-epoch)

The scary scenario: coin spent (non-FRESH) → compacted into the vector → coin re-created unspent (a DIRTY map entry now shadows the stale vector entry) → the map entry somehow disappears → the vector would wrongly report the coin spent. This cannot happen; enumerate every path that removes or cleans a map entry:

  1. Uncache() — the mempool's anti-pollution eviction — refuses DIRTY entries, and a shadowing entry is always DIRTY (AddCoin marks DIRTY unconditionally). Safe.
  2. SpendCoin of a FRESH re-added coin erases the map entry — but that is itself a semantic spend: the coin is now spent again, so the surviving vector entry states the truth once more, and its pending leveldb erase is exactly right. Safe.
  3. SetClean transitions occur only while BatchWrite consumes the cursor — i.e. during Flush/Sync, the epoch reset that drains and clears the vector at the same moment (ReallocCompactSpents). Safe.
  4. Reset() clears map and vector together. Safe.

Invariant: a map entry shadowing a vector entry is always DIRTY, and every path that removes or cleans a map entry is either itself a semantic state change that restores the vector entry's truth, or an epoch reset that empties the vector simultaneously. Worth stating in a code comment near IsCompactSpent.

Background: the coin lifecycle, and where the savings actually come from

(Labeled background for experienced reviewers to skim — but the measured data below makes it more than intuition.)

Spends skew heavily toward recently created coins — that's just how bitcoin is used. A coin's cost therefore depends on whether its create→spend window fits inside one flush cycle:

  • In-cache lifecycle (FRESH annihilation): created in cache, spent while still FRESH → the entry is simply erased. Zero leveldb operations, ever.
  • Flushed lifecycle: the coin gets flushed before being spent (1 leveldb write); the later spend faults it back in (1 read); the spend makes it a non-FRESH spent entry whose spentness must be flushed (1 more write — a DELETE). Three leveldb operations.

Compacting spent coins makes the same dbcache hold ~3.5× more spent state, so flush cycles stretch (−36% flushes), which widens the window during which a young coin can complete its lifecycle in cache. Every coin that crosses from the 3-op path to the 0-op path saves exactly 1 read + 2 writes.

The benchmark data confirms this signature quantitatively: the branch saved 105.4M leveldb lookups and 210.7M batch operations — a 1:2 ratio to within a few percent, exactly what ~105M converted lifecycles predict. In other words, the patch's benefit is not primarily "spent coins are smaller" as a static fact; it is that smaller spent state lengthens the cycle, and the lengthened cycle converts ~105M coin lifecycles into pure in-memory events.

Alternatives considered and rejected (design-space walk)

Larry's earlier attempts, in order — each failure isolates a constraint the final design respects:

  1. Compacting only at block boundaries (early versions: compaction ran in the per-block-cache Flush into the main cache): worked, but the restriction was accidental — compaction changes only the internal representation (map entry → vector entry), never observable or durable state, so it needs no sanctioned moment. Dropping the restriction (MaybeCompactSpents callable anytime a spent entry may exist) decoupled the cache's internals from validation's rhythm and simplified the design. Lesson: only persistence needs scheduled moments; representation changes don't — the distinction that also frames the reorg-bracketing discussion in the DESIGN section below.

  2. Early-flush spent batches to leveldb mid-cycle (naive first try): batch up spent coins and push the erases to leveldb before the epoch flush. Breaks crash consistency: the on-disk chainstate must always represent the UTXO set at its recorded best block (or the bounded HEAD_BLOCKS replay window); erasing coins from later blocks early leaves a state that crash replay cannot reconstruct (replay of the intervening blocks would spend coins that no longer exist on disk). Lesson: spentness must stay resident until the atomic epoch boundary — which is exactly why spent coins occupy cache memory, and why making them cheap matters. Worth a sentence in the PR for readers who'd reinvent this.

  3. std::unordered_set of spent outpoints, populated instantly on spend (never reached a working state; abandoned after much effort): move every spent coin into a set the moment it is spent, eliminating spent map entries entirely. Lookup order map → set → leveldb was fast (O(1)), but this was hugely disruptive to both production code and tests, because much of the codebase depends on there being such a thing as a spent coin in the map — FRESH-spend erasure, spent propagation through BatchWrite, dirty accounting, the test state matrix. Also weaker on memory than the final design: ~64–80 bytes per set node (pointer chains + hash-table overhead) vs 36 in the vector. Lesson: don't remove the spent-entry concept; bound its population. The final design's lazy, batched compaction means spent entries still exist transiently with unchanged semantics, and migration to the compact form is invisible to every existing interface — the batching, introduced for efficiency, is what buys compatibility.

  4. Ephemeral on-disk 'S' table of spent outpoints (worked, consensus maintained, but slow). Design: when enough spents accumulate (large batches being efficient), write them to a separate leveldb keyspace ('S' prefix; keys = outpoints, no values — a set) and delete them from cacheCoins, reclaiming the memory mid-cycle. Lookups: map miss → txdb checks the S set first (present → not_found; absent, the 99.999% case → normal 'C' read), so the extra check lived in txdb, not coins.cpp. At flush, BatchWrite iterates the S table emitting a DELETE per entry (with the usual partial-batch writes), then runs the normal cursor loop. Crash safety by ephemerality: the S table is an extension of the memory cache, so it is wiped on every startup — the durable 'C' state is never mutated early and stays consistent at every instant (which is what the naive attempt (1) got wrong). Semantically this is already the final design — a shadowed, epoch-ephemeral spent overlay — but in the wrong tier. Costs: most spent coins ended up as ~3 leveldb ops (S write when space was needed, S read at the flush iteration, C delete) versus zero mid-cycle ops for a RAM overlay; and observed performance started fast then degraded as the sync progressed — most plausibly because the growing S table's filter/index metadata competes for the shared leveldb block cache (and OS page cache) with the C table's, so even bloom-cheap negative S lookups paid rent by evicting C metadata and slowing the real lookups. The startup wipe also fights leveldb's own delete-tombstone mechanics. The final design keeps the identical semantics with RAM as the medium: ~36 bytes and an amortized memcpy per spent, and one cache-line probe — against a filter in dedicated, accounted memory rather than a shared cache — instead of a leveldb negative lookup. The deeper mismatch (a useful design smell): the S table paid leveldb's entire durability machinery — WAL, SST construction, compaction — for data whose defining property is that it must NOT survive a restart: every entry made crash-proof so that recovery could delete it. When a design systematically negates a tool's core guarantee, the data belongs in a different tier. Epoch-ephemeral data wants volatility; RAM's "weakness" is exactly its fit, and the crash story stops being a startup procedure and becomes physics.

  5. Coin in CCoinsCacheEntry (nullptr = spent)* — see below.

The final design is (3) moved into RAM: the compact outpoint-only spent structure, but memory-resident behind a one-cache-line filter, paying leveldb nothing until the epoch boundary it was always going to pay anyway.

Alternative considered and rejected: shrink the entry instead

An earlier (never-PR'd) attempt by Larry changed CCoinsCacheEntry to hold a Coin* (nullptr = spent) instead of an inline Coin. Clean code, but only minor gains, and it clarifies why this patch's approach is the right one: a spent entry's dominant cost is the map node itself (key + links + unordered_map node + pool overhead ≈ 80 of the 128 bytes remain even with a null pointer), so no within-the-entry representation change can recover most of the memory — only removing spent coins from the map can, which is what the epoch/ monotonicity structure enables. The pointer variant also taxed the unspent majority: even with the Coin allocated from the pool resource (cheap alloc/free, no fragmentation), every AccessCoin gained a pointer dereference to a different cache line — a locality tax on the hottest read path in validation.

Strictly opt-in; default construction is unchanged behavior

Constructing a CCoinsViewCache invokes none of the new machinery. The default compaction threshold is 0 (disabled): the vector and filter are empty and never populated, no compaction ever runs, and IsCompactSpent() short-circuits on the empty filter (a single always-false branch on the cache-miss path). The only per-instance costs for a non-opted-in cache are two salt words drawn at construction (the same pattern SaltedOutpointHasher already uses) and ~100 bytes of empty members. Every existing CCoinsViewCache user — mempool validation views, the per-block ConnectBlock overlay, RPC helpers, tests — behaves exactly as before.

The single opt-in site is Chainstate (InitCoinsCache/ResizeCoinsCaches), which enables compaction only for the main coins-tip cache via SetCompactSpentsThreshold(), sized from the actual dbcache. Enabling is a method call, deliberately not a constructor parameter, so no call-site churn and no silent positional-argument hazards.

For the first PR comment: note on comment density

Comment density in coins.{h,cpp} is deliberate: most comments document invariants introduced by this patch (map-shadows-vector, spents-erased-before- cursor-writes, FRESH preservation across list moves, the spent-list/count contract) — the reasoning a reviewer would otherwise have to reconstruct or ask about. That said, happy to trim or shorten wherever reviewers find the density noisy; suggestions welcome. (Framing note: state intent + openness, don't preemptively apologize — an apology invites a hunt.)

Test coverage / verification of the lookup correctness

  • Mutation coverage: disabling the IsCompactSpent() lookups (the mutant that would silently accept double-spends) fails both feature_block.py (functional) and coins_tests (unit). Believed to be the only tests that catch this mutant — good that they exist; worth saying so in the PR.
  • No-false-negative guarantee, verified two ways:
    1. SanityCheck() asserts MayContain(v) for every element of the vector — by contrapositive this proves any filter-negative outpoint is not in the vector, exhaustively for the current state. Runs throughout unit/fuzz tests.
    2. Debug builds additionally verify per lookup: on every filter-negative answer, an if constexpr (G_ABORT_ON_FAILED_ASSUME) block runs the binary search inside Assume. Compiles to nothing in release. (NB: a bare Assume would NOT work — Assume always evaluates its expression in all build types; only the abort is debug-gated.)
  • assert vs Assume policy (if asked): both evaluate in all builds; they differ only in release-abort. The patch uses assert where a violation could corrupt what reaches disk (CompactSpents' construction of the erase set; SanityCheck by charter) and Assume where it only corrupts bookkeeping (counter/flag accounting, filter-vector lockstep) — matching upstream's own distribution in this file.
  • compact_spents_filter_basic unit test: no false negatives over 10k inserts, FP rate bound, empty/Reset behavior. compact_spents_lookup unit test: end-to-end spend→compact→lookup-shadowing→flush against a base cache.

QUEUED: BatchWrite disjointness asserts (Larry's idea, refined 2026-07-31)

Post-purge, spents and cursor keys are disjoint, so BatchWrite implementations have no ordering constraint. Given disjoint keys, order-independence is a theorem (distinct-key ops commute), so no shuffle-test is needed — assert the precondition instead, with ONE idiom on both sides (the vector is sorted, so zero-allocation): assert(!std::binary_search(spents, outpoint)) per cursor entry — (a) in the test/fuzz base-view BatchWrite (every suite flush checks the contract; catches purge regressions instantly); (b) debug-only (G_ABORT-gated Assume) in the real consumers. Set-cardinality variant considered and rejected: legal duplicate spents break the size-sum form; the insert-success repair works but allocates and localizes worse. Original shuffle idea dropped: it would require draining the cursor (a different mechanical shape from the streaming production paths) to re-prove commutativity empirically. Implement after the USB benchmark frees the machine.

The one-sided-error principle (Larry, 2026-07-31 — unifying reviewer-comfort argument)

FRESH has asymmetric failure modes: under-setting it costs one redundant (no-op) erase in the batch; over-setting it annihilates spentness the base still holds — a spendable coin that shouldn't exist. The IsCompactSpent guards on FRESH-setting (AddCoin, BatchWrite merge) therefore fail SAFE: any imperfection in the guard suppresses an optimization, never invents a coin. This is the same shape as the filter's contract (false positive = one binary search; false negative = forbidden, verified exhaustively) and upstream's own original FRESH caution in AddCoin. PR-description sentence: "wherever this patch approximates, an error costs a few nanoseconds, never a coin."

Anticipated objection: why not SipHash for the filter's hash?

Four-part defense (anticipated 2026-07-29):

  1. Threat model: SipHash prevents hash-flooding DoS (attacker-forced O(n) chains). The filter's worst case under a fully successful adversary — every probe a false positive — is one extra binary search per lookup, i.e. exactly the no-filter configuration (benchmarked: −7.6% instead of −10.3%). Graceful degradation, no amplification. The map, where flooding does matter, uses SipHash (#35215).
  2. Input is already a cryptographic hash: outpoint txids are SHA-256 output, uniformly distributed by construction; the mixer scrambles them with n and a per-instance random 64-bit salt (positions unpredictable without a memory-read primitive anyway).
  3. Not an invented hash: the mixer is splitmix64's published, well-analyzed finalizer. Filter correctness (no false negatives) holds for ANY mixing function by construction; hash quality only moves the FP rate, measured chain-wide at 0.021% ≈ theoretical optimum for the bits spent.
  4. Upstream precedent: CRollingBloomFilter (addr/mempool relay) and BIP37 CBloomFilter use MurmurHash3 with random tweaks — non-cryptographic mixers over probabilistic filters, in more adversarial settings.
  5. Insertion is confirmation-gated (Larry's point): the filter only ever contains spends from connected blocks, so poisoning its contents costs fees and block space — and txid grinding is blind against the unknown per-instance salt. Attacker-crafted mempool transactions can only query (mempool validation probes CoinsTip), and queries are stateless. This is the least adversarial input stream of any filter in the tree — BIP37's is fully client-supplied and the rolling filter's is unconfirmed relay traffic, yet both use Murmur3. Backstop: swapping Mix() for SipHash-1-3 is a five-line change costing minutes per IBD (billions of probes × ~30 ns) — cheap to concede if a reviewer insists.

Incidental benefit: erases are applied in leveldb key order

COutPoint's operator< is a memcmp over the raw txid bytes — the same ordering as leveldb's BytewiseComparator over the serialized coin keys. So the sorted m_compact_spents vector hands its erases to the write batch in the database's native key order (good memtable/SST locality), unlike map-iteration-order writes which poke keys in salted-hash order. (Debugging note that cost real time pre-PR: ToString() displays hashes byte-reversed, so sorted-by-memcmp data looks shuffled in truncated log output; HexStr over the raw bytes is the sort-order-faithful display.)

Filter sizing: no "how many items?" problem, and fragmentation-benign

Classic bloom filters must be sized for a predicted item count, because they are filled online and cannot be resized (a filter can't enumerate its own contents). This filter never faces that problem: it is only ever built inside CompactSpents(), at the moment the merged vector — the enumerable ground truth — is complete, so Reset(m_compact_spents.size()) sizes it for the exact count every time. Between rebuilds the set is frozen (new spents accumulate in the map, not the vector), so no insert ever hits a filter that wasn't sized for it. Resizing is just "throw away, rebuild", and the rebuild pass hides behind the sort/merge work CompactSpents already does. No oversizing, no scalable-bloom chaining.

Hash count is fixed (HASHES=7) rather than size-derived: optimal k tracks the bits-per-entry ratio (k ≈ 0.69·bits/entry), which the sizing pins to 16–32, so a constant k is appropriate; 7 fits the one-hash-word bit budget (7×9=63) and is robust at the rounding band's low end (blocked filters saturate if k is tuned for the high end). Bonus verified in generated code: the constant trip count fully unrolls the probe loops with immediate shift amounts — Insert() is branchless straight-line ALU; MayContain() keeps early exits (deliberate: at ~1/3 fill, negatives exit after ~1.5 probes on average).

Allocation behavior is also deliberately friendly:

  • The compaction size ladder is deterministic and seeded by the threshold (start at m_initial_compact_spents, +20% per batch, capped at the same value), so every flush cycle requests the identical sequence of vector and filter sizes — the allocator can reuse the same chunks cycle after cycle.
  • The filter additionally rounds up to power-of-two block counts, quantizing its byte sizes to a short menu (2 MiB, 4 MiB, 8 MiB, ...): several consecutive rebuilds share one size, and allocations this large are mmap'd page-aligned regions outside the heap arena — about the most fragmentation-benign pattern available. Reset() frees the old buffer before allocating the new one, so same-size requests can reuse the same region.

Why the new structures do NOT use the pool resource

A question reviewers (and we ourselves, initially) will ask: should m_compact_spents and the bloom filter allocate from CCoinsMapMemoryResource? No, deliberately:

  1. The pool resource's advantage is amortizing high-frequency alloc/free churn of small, node-sized blocks — which is exactly the dbcache map's workload. The spents structures have no churn: roughly a dozen large allocations per flush cycle, each long-lived. Plain malloc is the right tool.
  2. The pool only serves allocations up to a small fixed block size (node-sized); the vector and filter want few, huge, contiguous buffers — the opposite shape.
  3. Keeping them out of the pool keeps the accounting honest and independent: they are counted explicitly in DynamicMemoryUsage() via their own usage, and freed at reset without interacting with the pool arena lifecycle.

Log-reading note: flat cache= plateaus after CompactSpents

After each CompactSpents event, the per-block cache= value plateaus: the compaction returns ~a million map nodes to the pool freelist (allocated chunks, which is what DynamicUsage counts, don't change), and subsequent new coins consume freelist nodes instead of new chunks. The plateau ends when the freelist drains. This is expected — it is the memory saving made visible — but can confuse a log watcher ("why isn't the cache growing?"). Master already has a milder version of this (FRESH-spend erases feed the freelist). Explain in a code comment near CompactSpents and/or one line in the PR description; keep the log line itself short.

Related work (prior-art sweep 2026-07-26; nothing anticipates this design)

  • #10195 (sipa, 2017): per-txout model — set today's entry granularity; did not address spent-entry representation.
  • #17487: Sync() without cache wipe (merged) — the epoch/reset machinery this patch builds on.
  • #28280: retain cache on prune-driven flushes (merged) — retention special case.
  • #16957 / #35195 (l0rinc): hash-code caching tradeoffs in CCoinsMap — same map, orthogonal cost (speed vs per-node memory; complementary).
  • The andrewtoth cache-retention series: #28280 (merged: don't wipe on prune flushes), #30611 (merged: hourly chainstate write), #28233 (merged: periodic writes became non-wiping Sync — today's Flush/Sync distinction), and #31102 (closed 2024: the remaining piece — serve memory pressure without wiping, via LRU eviction of clean entries). #31102 measured only ~2% full-IBD gain and the author closed it ("likelihood of one of those entries being spent before evicted is not high enough"), favoring #31132 (parallel prevout fetching, merged as #35295). Note the trigger logic: Sync is timer-driven because it cannot relieve memory pressure (it keeps all entries resident); only the wiping Flush frees memory — which is exactly the cost this patch reduces by making the cache hold more before pressure hits. Key differentiation from #31102: it kept clean unspent coins to avoid re-reads; this patch shrinks spent coins to enlarge effective capacity — 7.6% alone, +4.0% with the filter, orthogonal to and compatible with both the retention series and prefetching. Its clean-list mechanics independently validate this patch's sentinel-list approach.
  • No PR, issue, mailing-list, or Delving/IRC discussion found proposing a compact representation for spent cache entries (searched: tombstone, compact spent, spent outpoints only, spentness memory, deletion list).

Benchmark results (mainnet -reindex-chainstate to height 938343, SSD, dbcache=1000)

Wall clock (bash time):

  • master: 232m53.381s
  • branch, binary-search only: 215m14.842s (−7.6%)
  • branch + bloom filter: 206m41.976s (−11.2%) [matched pair with the no-bloom run: identical compaction threshold and identical leveldb lookup counts (610,255,639 in both logs) — the reindex cache behavior is fully deterministic, so this A/B isolates the filter exactly: 8m33s / 4.0%]
  • (earlier bloom run at threshold 1.0M: 207m33.056s; run-to-run wall-clock variance measured at ~0.4%, so all effects above are 10x+ noise)
  • Shipping configuration (cap + 95% LARGE clamp): 208m58.528s — −10.3% vs master, CPU −13.6%, and zero CRITICAL flushes (vs 12 uncapped, 0 on master). The +1.1% vs the unclamped run is mechanical: the earlier LARGE trigger costs 6 extra flushes (118 vs 112) → +8.4M lookups. Interesting detail: the batch cap never engaged (max batch 911,360 < 1,024,000) — the clamp's shorter cycles prevent the ladder from reaching it, so the clamp alone eliminated the criticals; the cap remains as the worst-case bound that justifies the 5% headroom derivation (and may engage under other flush dynamics). Clamp at 96% could recover ~half the cost if ever needed. Max vector this run: 11.08M entries; FP avg 0.021%; true=0 again.

Total CPU (user+sys):

  • master: 294m18s
  • no-bloom: 260m13s
  • bloom: 254m42s → −13.5% vs master (storage −11.6%, bloom −1.9 pts) CPU savings exceed wall savings: the patch removes work, not just wait — important because it should survive rebase onto #35295 (parallel prevout fetching hides latency but doesn't remove leveldb work).

Mechanism evidence (debug logs, branch runs did identical cache work — lookups within 0.17%, flushes 112 vs 112):

  • leveldb lookups: 714.6M → 609.2M (−14.7%)
  • cache flushes: 184 → 112 (−39%)
  • leveldb batch ops: 1.526B → 1.315B (−13.8%)

Bloom filter effectiveness (full-run counters, 111 cycles):

  • false positive rate avg 0.021%, max 0.033%; true positives = 0 in every cycle of a full mainnet sync (nothing on a valid chain references an already-spent outpoint — not even the pre-BIP34 duplicate-coinbase era produced a hit). The binary search runs ~once per 5000 lookups, only on false positives.

SSD wear: ~659 GB device writes per reindex measured via SMART deltas (~10× write amplification over logical batch data). The patch's −13.8% batch ops should save on the order of ~100 GB flash writes per full sync — a real SSD-longevity and energy benefit for constrained nodes.

HDD benchmark (mainnet -reindex-chainstate to height 600000, SATA HDD, dbcache=1000)

bench.sh protocol (cold page cache, idle disk verified, iostat logged):

  • master: 2:32:06 wall, 4750.8s CPU, 216.3M lookups, 53 flushes
  • branch (shipping config): 2:14:43 wall, 4590.5s CPU, 194.3M lookups, 37 flushes
  • −11.4% wall (exceeds the SSD's −10.3% despite the lighter sub-600k workload — slow storage amplifies the win), CPU only −3.4% (on HDD the benefit is avoided disk time, the mirror image of the SSD case), flushes −30%, criticals 0 on both.

USB HDD benchmark + the layout-age insight (study completed 2026-07-28)

USB study (same protocol, height 600000, freshly rsync'd datadir on a 5TB USB/SMR Seagate): settled pair (fairness-gated on matching iostat r_await profiles) = master 3:14:01 vs branch 3:05:27 → −4.4% (lookups −9.7%, flushes −29%, criticals 0). Two first attempts were discarded: the drive's post-copy SMR destaging inflated per-read latency 63% during the first branch run (protocol lesson: after large writes to an SMR drive, wait hours or gate on iostat before benchmarking).

Key insight from the three-device comparison: the wall-clock benefit tracks the datadir's effective per-I/O cost, not the device class. The aged, fragmented SATA datadir ran 35–42ms reads (benefit −11.4%); the freshly-laid USB copy ran ~25ms (benefit −4.4%; 20.4M avoided lookups x 25ms ≈ the measured 514s to within 1%). Real long-running nodes have aged datadirs, so the SATA row is the representative spinning-disk case; fresh-layout USB is the lower bound. Also observed (SATA pair): master's extra flush write-storms measurably slow its own concurrent reads (w_await 83 vs 66ms) — flush avoidance also de-pollutes the read queue, a second-order mechanism beyond the saved writes. Determinism held throughout: every same-binary run reproduced lookup counts digit-for-digit (e.g. 190,589,875 twice, 210,987,556 twice).

Re-benchmark on current master (2026-07-29, post-rebase, post-crash-fix — THE definitive numbers)

Both pairs same-night, same layout, bench.sh protocol, chained back-to-back (no compilation between runs), -networkactive=0.

SSD (to 938343, dbcache=1000): branch 2:15:51 vs master 2:13:53 → wall +1.5% (slower); CPU −5.3% (17952 vs 18964 s); device writes −15% (669 vs 787 GB per reindex, SMART-measured); flushes −31% (118 vs 171); criticals 0/0; master lookups 681.2M.

HDD (SATA, to 600000): branch 2:08:52 vs master 2:30:27 → wall −14.3% (old base: −11.4%); CPU −2.8%; flushes 37 vs 53; criticals 0/0; master lookups 211.0M.

Interpretation (be upfront in the PR): #35295 (parallel prevout fetch) hides miss latency behind worker threads, which consumed the SSD wall-clock win — master now burns more CPU to fetch in parallel what we avoid fetching at all. One spindle can't be parallelized, so on spinning media miss-elimination still converts to wall time (and improved vs old base). The honest framing: a wall-clock win exactly where IBD hurts most (HDD, Pi-class devices), an efficiency win everywhere (writes/CPU/flushes/flash endurance), and the SSD wall-clock cost is +1.5%. The old-base −10.3% SSD number is superseded and must not be quoted.

Sizing / scaling

  • Compaction threshold: coinstip_cache_bytes / 8 / 128 — compact when spent map entries (~128 B each) would occupy ~1/8 of the cache. ~1M entries at dbcache=1000; scales down for e.g. Raspberry Pi so the feature activates at any cache size. Set via SetCompactSpentsThreshold() from InitCoinsCache/ResizeCoinsCaches (never a constructor parameter — avoids silent bool→size_t misbinding at call sites).
  • Bloom filter self-sizes from vector content (~2–4 bytes/entry, power-of-two blocks); no configuration.
  • Growth rule: compact again when map spents reach 20% of the vector size, capped at the initial threshold — min(size*20/100, m_initial_compact_spents). Geometric growth keeps total merge work linear (fixed batches would be quadratic); the cap bounds late-cycle map bloat (~200 MB at 8M vector uncapped), which caused CRITICAL flushes (8–12 per reindex vs 0 on master). DONE 2026-07-26; verify CRITICAL count == 0 in the next benchmark run.
  • LARGE flush threshold clamped at 95% of total space (was: fixed 10 MiB headroom, which assumes smooth per-block growth; compaction steps accounting by up to 36B × threshold ≈ 3.52% of the cache in one block). DONE 2026-07-26.
  • Fuzz coverage: all coins fuzz targets now run with compaction threshold 1 (coinscache_sim = differential vs ground-truth model). Debug builds also run SanityCheck at every flush boundary and re-verify every filter-negative lookup. DONE 2026-07-26.
  • Deep fuzz session (2026-07-26): 1 hour x 4 targets, clang libFuzzer + ASan/UBSan, qa-assets seed corpora, all Assumes fatal: ~13.1M executions, zero findings. Corpora grew (e.g. coinscache_sim 305→1570 inputs) — kept in fuzz_corpus_work/, candidate qa-assets contribution with the PR.

Compaction cost is storage-independent (and tiny)

CompactSpents() never touches disk — it is sorting, one linear merge, map-node erases, and a filter rebuild, all at memory speed. Measured identically across NVMe, SATA HDD, and USB HDD via microsecond log timestamps: e.g. merging 378k new spents into a 1.89M vector (2.27M total, ~82MB streamed) completed inside a 274ms window that also included connecting a block. ~100–120ns/element upper bound on every device. The exchange rate is the patch in miniature: ~0.3s of DRAM-speed work reclaims ~33MiB of cache capacity net (378k coins x ~92 bytes: each frees a ~128B map node, gains a 36B vector element), and on an HDD a single avoided leveldb miss (~10–40ms) repays a thousand elements of compaction.

Memory spike during CompactSpents (be upfront in PR)

During the merge, old vector + new batch + merged target coexist: peak ≈ 2× the final vector size for a sub-second linear pass (~70–130 ns/element measured; 9.75M-entry merge fits in a 1.4s window including a block connect). Precedent: the map's bucket-array rehash also transiently exceeds the budget (smaller magnitude, 8 B/bucket). The dbcache scaling bounds the spike proportionally on small machines. Steady-state accounting is honest (vector + filter counted in DynamicMemoryUsage); only the merge instant escapes it.

Crash safety: the shadow invariant was crash-unsafe (found 2026-07-29 by feature_dbcrash)

The original design let a stale vector entry (coin spent, then compacted, then re-created by a reorg's undo restore) ride to flush time, relying on ordering: erase first, cursor rewrite second. That is atomic only if the whole batch stream commits. feature_dbcrash.py found the hole within ~10 iterations, twice, with two distinct symptoms:

  • crash mid-reorg-flush → recovery replay commits a UTXO set missing a coin → VerifyDB rejects the tip on every subsequent restart ("coin database inconsistencies found"); confirmed real corruption — master's bitcoind rejects the same datadir;
  • when the lost coin is deeper than VerifyDB's 6-block window, startup passes and the damage surfaces later as a gettxoutsetinfo hash divergence.

Root cause: crash recovery (ReplayBlocks) regenerates only operations derivable from disconnecting the old durable chain and connecting the new one. A stale vector erase records a spend on an abandoned branch — on neither path — so a batch prefix containing the erase but not the shadowing rewrite is unrecoverable. Upstream cannot hit this: its batch holds only map entries, and the map always reflects net-epoch state.

Fix, three parts, same root cause (compaction moved unflushed spentness out of the map, breaking invariants that assumed it lives there — the unifying principle: any code that concludes "the base lacks this coin" must also consult m_compact_spents):

  1. RemoveShadowedSpents(): before every BatchWrite, drop vector entries whose outpoint has a cacheCoins entry. The dirty cursor entry carries the truth and is replay-derivable. This also makes spents/cursor keys disjoint, dissolving the erase-before-write "hard requirement" on BatchWrite implementations into a mere convention.
  2. AddCoin FRESH computation: a re-creation over a compact-spent outpoint must not be FRESH (the base has the coin, erase pending). The stale-DIRTY guard in AddCoin has always protected exactly this state when it lived in the map; IsCompactSpent() extends it to the vector. Without this, the parent-cache BatchWrite consistency check ("FRESH flag misapplied") fires — caught by the new compact_spents_shadowed_purged unit test before any functional test ran.
  3. Cache-to-cache BatchWrite FRESH adoption (not-found branch): the parent may hold the outpoint in its own vector even when its map lacks it, so adopting the child's FRESH flag needs the same IsCompactSpent() guard. Only reachable with 3+ stacked caches — caught by coinscache_sim seed corpus replay (libFuzzer, fatal Assumes), not by bitcoind's 2-level stack.

Performance note: parts 2 and 3 add one bloom-filter probe per newly created output (fast negative path, ~ns); part 1 is one linear pass per flush. The post-fix re-benchmark validates the headline numbers still hold.

Concurrency audit vs #35295 (parallel prevout fetch; done 2026-07-29 at stage-1 rebase)

#35295's worker threads concurrently call PeekCoin on CoinsTip during ConnectBlock (its documented contract: PeekCoin paths must be read-only for concurrent readers). Audit result: compact-spents adds zero new cross-thread mutation surface. Our PeekCoin additions (filter probe, vector binary search) are read-only; every mutation site of the vector/filter is reachable only via CoinsTip's AddCoin/SpendCoin/Flush/Sync/BatchWrite, none of which run during the fetch window — validation writes go to the ephemeral CoinsViewOverlay, and CoinsTip mutations begin at overlay Flush(), which calls StopFetching() (joining all workers) first. The overlay's own compaction is disabled in production (threshold is set only on CoinsTip). Concurrent readers therefore observe an immutable vector+filter for the entire fetch window.

Verification: unit test compact_spents_shadowed_purged pins the purge at the BatchWrite interface; feature_dbcrash.py (seeded and unseeded) passes with the fix; it fails within ~10 iterations without it. Worth stating in the PR: this test is the only coverage in the tree that exercises crash-recovery replay against partial batch writes, and it caught what unit tests, fuzzing (13M+ execs), and full mainnet syncs all missed.

CI on the rebased series (fork push 2026-07-29) + the coinsfilter.h extraction

Full matrix green (incl. MSan fuzz, all cross-platform) except:

  • riscv32 bare metal: infra flake (HTTP 429 rate-limit cloning the GCC toolchain inside the CI container) — not our code, clears on re-run.
  • Both integer-sanitizer jobs: real finding — the splitmix64 mixer's deliberate uint64 wraparound trips -fsanitize=integer's unsigned-integer-overflow check (coins.h:334).

riscv32 flake is PERSISTENT on the fork (3 attempts, all HTTP 429 from sourceware.org cloning toolchain submodules): the fork's cold Docker layer cache forces a toolchain rebuild upstream's warm cache skips, and sourceware throttles GH runner IPs. Not retried further (job compiles libbitcoin_consensus only — none of our code); expect it green on upstream CI where the cached image avoids sourceware entirely.

Fix (2026-07-30): CompactSpentsFilter (+ CompactSpentsList) extracted to its own header src/coinsfilter.h, with function-granular suppressions CompactSpentsFilter::{Mix,Hash} added to test/sanitizer_suppressions/ubsan — the exact pattern of the existing CBloomFilter::Hash / RollingBloomHash entries (upstream's own bloom filters wrap deliberately too). Function-level rather than file-level keeps the rest of the filter (and coins.h's usage accounting) fully sanitized. The extraction also serves review directly: Larry's point — coins.h's great virtue is being small enough to scroll and find things visually, and the filter had made it unwieldy; the separate header restores that, and makes the filter's self-containedness structural (reviewable, and droppable, in isolation).

DESIGN (not yet implemented): making the crash fixes ~free — reconciliation at flush

Status 2026-07-30: design written before code, deliberately. The 07-29 SSD pair (+1.5% wall vs master) is the first benchmark ever taken WITH the crash fixes; the two hot costs they added are the prime suspects for the regression.

Where the new cost actually is

The three crash fixes each answer the same question — "is this outpoint in the compact-spents vector?" — at different places:

  1. RemoveShadowedSpents() (flush): one hash-map lookup per vector entry. ~5–9M entries × 118 flushes ≈ 0.6–1B random DRAM touches ≈ 1–1.5 min per SSD reindex.
  2. AddCoin FRESH guard: probes the cache's OWN filter. In production the per-output calls hit the ephemeral per-block view, whose filter is empty (threshold set only on CoinsTip) → early-out, ~free. Not a suspect.
  3. Cache-to-cache BatchWrite merge, not-found+FRESH branch: probes CoinsTip's filter once per adopted entry ≈ once per created output ≈ ~3.3B probes × 30–80 ns (each a random line in a filter tens of MB — bigger than L2) ≈ 2–4 min. Prime suspect.

Sum ≈ the observed ~2-minute gap. Falsifiable: implement, re-run the pair.

The key insight: detection can move to the flush boundary

Fix #3's probe compensates for compaction moving spent-dirty entries out of the map: upstream's merge would FIND the spent map entry and handle FRESH correctly; with the entry migrated to the vector, the merge takes the not-found branch and would adopt a bogus FRESH. But nothing observes that bogus FRESH until the adopting cache itself flushes:

  • Flushing to leveldb: FRESH only changes behavior for SPENT entries (skip/annihilate). A bogus-FRESH entry that stays UNSPENT flushes as a write regardless — harmless.
  • Flushing to another CCoinsViewCache: the "FRESH flag misapplied" check fires — but that flush runs RemoveShadowedSpents() FIRST, which already visits exactly the vector∩map intersection. Clearing the bogus FRESH bit there (same pass, same entries) fixes every escape path before the cursor walks.
  • The dangerous middle case — bogus-FRESH entry re-SPENT before flush — annihilates the map entry, leaving the old vector erase to land. Case analysis says that is correct and crash-derivable: the re-spend is by definition on the currently-connected chain (disconnects only SpendCoin outputs created by the disconnected block itself, and this coin is older), so block replay after a crash regenerates the erase. This is the "lucky-but-correct" corner: MUST be re-verified by dbcrash + sim fuzzer, and deserves its own unit test.

So: per-output probes (fix #3) can be deleted entirely; reconciliation at flush (purge + FRESH-clear in one pass) preserves every invariant.

Making the reconciliation itself ~free: the epoch flag

During IBD/reindex, shadow state NEVER arises (it needs a re-creation over a compact-spent outpoint = reorg undo-restore, or a BIP30-era duplicate coinbase). Track m_maybe_shadowed (cleared in ResetCompactSpents):

  • Set explicitly on the reorg path (DisconnectTip → one call on CoinsTip; undo-restores are otherwise indistinguishable from new outputs without a probe — that's WHY the flag must come from validation, not be inferred).
  • Set in the merge not-found branch when adopting a NON-fresh unspent entry while the vector is non-empty (covers BIP30 overwrite-adds; rare, no probe — just a flag store).
  • RemoveShadowedSpents(): if (!m_maybe_shadowed) return;zero cost for the entire IBD; full O(vector) reconciliation only in reorg epochs (rare, small, and worth it).

Larry's SHADOWED flag-bit idea (spare bits exist next to DIRTY/FRESH) is the exact-tracking refinement of this: detection sites mark the entry, and reconciliation walks only marked entries, O(k) instead of O(vector). Worth doing if reorg-epoch flush cost ever shows up in a benchmark; the epoch flag alone probably suffices (reorgs are rare and the purge is one linear pass).

Alternative B (Larry's, 2026-07-30): direction-pure epochs

Bracket reorgs with Sync() in ActivateBestChainStep: once before the disconnect loop, once after it. Every epoch is then direction-pure, and shadow state becomes structurally impossible: in a pure-disconnect epoch an output's undo-restore always precedes its removal (reverse height order), so nothing is re-created after compaction; in a pure-connect epoch re-creating a spent outpoint means creating it twice, forbidden by BIP30/BIP34 (the two historical duplicates overwrote UNSPENT coins — no shadow). The purge, the FRESH extensions, and the crash-bug class all evaporate. Cost is not performance (reorgs are rare, shallow, tip-resident; Sync keeps the cache warm) but layering: the correctness invariant moves from the cache (self-defending) into a validation contract, and the diff moves into the heavily-scrutinized reorg path. Larry's counterpoint (with merit): validation already holds persistence knowledge — it calls Flush/Sync only at block boundaries, and crash recovery depends on that — so B merely extends "valid checkpoint" to include direction changes; "a direction change IS a boundary." The distinction that survives: under the existing contract, omitting a call costs durability/performance only; under B, a future reorg-shaped caller omitting the bracket silently corrupts. Mitigation making the contract enforceable: Assume(!IsCompactSpent(outpoint)) at the top of AddCoin — fatal in debug/fuzz builds (which hammer reorgs constantly), ~free in release — turns silent corruption into a loud CI abort. B also restarts verification on an already-validated area.

PROTOTYPED 2026-07-30 (alternative-b.patch in repo root, uncommitted). Results: production diff net −4 lines (probes and purge deleted; brackets added); coins suites + feature_block pass. feature_dbcrash then caught a REAL bug in the naive bracket: a bare CoinsTip().Sync() skips the block index write, so a crash left HEAD_BLOCKS naming a just-connected block the on-disk index had never seen — "reorganization to unknown block requested", unrecoverable. Lesson: the bracket must be FlushStateToDisk(FlushStateMode::FORCE_SYNC) (index written before chainstate — an ordering invariant a bare cache call silently violates). Corrected prototype re-validated. Second finding: both fuzz corpora abort instantly on the contract Assume — under B the harnesses must become contract-aware, permanently narrowing adversarial coverage of exactly the state-space region where the three crash-fix bugs lived (the region becomes unreachable only if validation keeps its promise, and nothing would fuzz the promise). These two findings are the honest case against B: its simplicity is real but its correctness leans on more of the persistence protocol than it appears to.

Third finding (Larry, 2026-07-31, post-decision, by architectural reasoning alone): B's bracket doesn't CASCADE. The top cache's Sync delivers its compacted spents into its BASE — if that base is another compacting cache, the parent's own vector absorbs them, the reorg's re-creations arrive on the next child flush, and the shadow state abolished at the top reappears one layer down where B deleted all the machinery and nothing triggers a flush ("the parent will NOW have to flush — but nothing triggers it"). Production's 2-level stack masks this (leveldb has no vector), which is exactly why B's dbcrash passed; the fuzz stacks would have caught it, and under B they were never running. A third instance of B's failure genus: an invariant that holds where we looked and quietly doesn't where we didn't. Alt A is structurally sound here (every layer keeps reconciliation; fuzz builds reconcile at every layer) with one documented release-mode obligation, now stated in SetCompactSpentsThreshold's doc. Decision criterion: if the annihilation case-analysis below is judged too subtle to trust, Alternative B's structural guarantee is worth its layering cost — verifiable simplicity beats cleverness. Otherwise the epoch flag gets ~90% of the same effect cache-internally (IBD behavior is identical under both).

BIP30 resurrection: a hole in BOTH alternatives (found by Larry's question, 2026-07-30)

The two historical duplicates (91842/91880) are safe under any design: they overwrite UNSPENT coins (map/base, never the vector). But BIP30's rule PERMITS a duplicate txid when the predecessor's outputs are all spent, and past height BIP34_IMPLIES_BIP30_LIMIT (1983702, validation.cpp:2460) the re-enabled checks make that reachable again for colliding coinbases. If the predecessor was spent AND COMPACTED in the current epoch, the duplicate legally re-creates a compacted outpoint in a pure-connect epoch — no reorg anywhere. This violates Alt B's premise (forward motion can't re-create) AND slips past Alt A's conservative flag triggers (it arrives as an ordinary FRESH new output). Only the shipped unconditional-probe design handles it natively. Fix for either alternative, cheap and localized: the fEnforceBIP30 path already probes each output's existence; add an IsCompactSpent probe there (runs only at the special heights) and on a hit Sync() first (B) or set the shadow flag (A). Astronomically unlikely, decades away, but consensus code doesn't round that down — and it belongs in the PR discussion as evidence the design space was mapped completely.

FINAL DECISION (2026-07-31, superseding the below): Alt A BACKED OUT — shipped design retained

The benchmark refuted A's performance leg (probes ≈ free), and with that gone the shipped design dominates on every reviewer-priced axis: zero caller obligations, validation.cpp untouched, BIP30 resurrection and stacked caches handled natively, fewer concepts. Larry concurred; A's content was surgically reversed as fixups (verified: diff vs the pre-A tree shows only the four polish items). Both alternative patches remain archived as evidence of diligence. PR narrative: "we prototyped both simplifications, adversarially reviewed them (three latent defects found), benchmarked, and measured the overhead they would remove at ~zero — so we kept the obligation-free design."

SUPERSEDED — DECISION (2026-07-31): Alt A ADOPTED and integrated into the series

Larry's call after reading both patches ("cleaner than I thought"). Renamed NoteMaybeShadowed → NoteMayBeShadowed / m_may_be_shadowed (Larry: "maybe" = adverb on an action, MaybeCompactSpents-style; this flag records a STATE that "may be" shadowed — modal verb, two words). Integrated as three targeted fixups (coins → vector-core commit, validation → enable commit, test → test commit), autosquashed, all per-commit gates green. alternative-b.patch retained in repo root as the road-not-taken receipt. The PR description should summarize the three-design comparison; the first comment can carry the full dossier from this section.

Alt A PROTOTYPED (2026-07-31, alternative-a.patch in repo root, uncommitted)

Results: +48/−13 across coins.{h,cpp}, validation.cpp (two one-line notifications: NoteMaybeShadowed() in DisconnectTip, NoteShadowCandidate() in ConnectBlock's BIP30 loop — the latter closes the resurrection hole), coins_tests (one line). Both hot probes deleted; RemoveShadowedSpents gated on the flag and extended to repair bogus FRESH as it purges. Key mechanism: if (!m_maybe_shadowed && !G_FUZZING_BUILD) return; — fuzz builds ALWAYS reconcile, so both fuzz harnesses run COMPLETELY UNCHANGED with full adversarial coverage (validated: corpus replay + mutation, zero findings — the reconciliation pass itself gets fuzzed on every run, which neither the shipped design nor Alt B achieves). All green: coins suites, feature_block, seeded feature_dbcrash. Fast path during IBD: one untaken branch per flush.

Side-by-side (both patches in repo root; tree holds the shipped design):

  • Shipped: correct, verified, unconditional probes ≈ +1.5% SSD wall.
  • Alt A (144-line patch): probes gone; cache still self-defending given two validation notifications; fuzzers untouched and omnipotent; BIP30 resurrection handled explicitly.
  • Alt B (198-line patch): probes AND purge gone, net-negative LOC; simplest invariant ("direction-pure epochs") but weakest verification story (contract-aware fuzzers required — unimplemented; needs FORCE_SYNC brackets; BIP30 resurrection needs an additional guard — unimplemented).

Open questions to resolve while implementing

  1. RESOLVED 2026-07-31 (Larry's question): duplicate vector entries ARE possible (spend → compact → re-create → re-spend → compact, i.e. a reorg within one epoch) and every consumer is verified duplicate-tolerant: SanityCheck's sortedness assert is deliberately non-strict; binary search and filter double-insert are fine; leveldb double-erase is idempotent and crash-derivable (unpurged duplicate ⇒ no map entry ⇒ final spend's block still connected); the purge removes all copies; the cache-parent spents loop is idempotent per outpoint (verified by code walk — symmetric un-count/re-count). Stale "ordering requirement" comment in that loop updated to the disjointness contract + a duplicates note (fixup queued).
  2. The BatchWrite doc contract ("spents and cursor keys are disjoint") only holds when the purge runs. With the purge gated by the flag, restore the erase-before-cursor ordering in txdb as normative belt-and-braces for any undetected shadow (it costs nothing — the implementation already does it) and re-derive the crash-prefix argument per case (BIP30-shadow erases are replay-derivable: the duplicate coinbase re-creating the coin is on the current chain).
  3. Does fix #2's probe stay? Yes — it's free in production (empty overlay filter) and load-bearing for direct-use caches (tests, future callers).

RESULT (2026-07-31): probe-cost hypothesis REFUTED by the Alt A benchmark

Alt A (probes and unconditional purge deleted) ran 2:15:21 — 30 s faster than the probed version (within noise), still ~1.1% behind master. The crash-fix probes were nearly free; the earlier DRAM-miss arithmetic overestimated them ~10×. The residual ~1% SSD wall gap is design-inherent on a post-#35295 base (lookup-path checks, compaction passes, flush-batch dynamics — the costs the old base's lookup savings used to bury). CPU −4.7%, writes −15% (668 GB), flushes 118, criticals 0 all hold. Consequence: SSD wall parity is not achievable by trimming fix overhead; the two-device framing (HDD wall win + universal efficiency win + ~1% SSD wall cost, disclosed) is final. Alt A remains the right design on simplicity/verification grounds — chosen on those grounds before this result — just not a speedup.

Verification plan for the change

Full battery re-run: seeded feature_dbcrash (plus a few unseeded runs, since the annihilation corner is stochastic), coinscache_sim + coins_view corpus replay and soak (the sim's 3+-deep stacks are what caught fix #3's bug), coins unit suites incl. a new test for the re-spent-bogus-FRESH annihilation case, per-commit gates, then the SSD pair again. Prediction to test: wall moves from +1.5% back toward ~0% while −15% writes / −5% CPU hold.

USB branch run interrupted by system freeze (2026-07-31)

The overnight chain's branch run died at 08:24:27 (~2h13m in, height 550214): the whole desktop froze and Larry had to hard-reboot. Evidence says the machine, not the patch: sar 4 minutes before the freeze shows 35 GB available / 93% idle / 4% iowait (no OOM, no I/O errors, journal clean), and the freeze signature is a gnome-shell gbm front-buffer failure burst on top of a morning-long history of nvidia-drm "Failed to allocate NVKMS memory" errors — an NVIDIA driver wedge, likely triggered by display wake (it hit the minute Larry sat down). One more suspect retired by the reboot: the previous boot ran kernel 7.0.0-27 with a pending 7.0.0-28 update; a kernel/nvidia-module version skew is a known instability source, and the reboot landed on -28 with matching modules. Re-run (07-31-usb-branch-take2) launched after a 2.5h SMR settle using the archived binary from the interrupted run's results dir (bench.sh's archived-binary path exists for exactly this), so the re-run measures commit e13725c725 regardless of subsequent tree changes.

IMPLEMENTED: BatchWrite disjointness asserts (2026-07-31)

As specced in QUEUED above; one fixup (2b67c7fed2 → e87af8f768), all four spents-loop sites (git -S confirms all originated in that commit):

  • Real consumers (CCoinsViewDB::BatchWrite cursor loop, cache-parent CCoinsViewCache::BatchWrite cursor loop): debug-only if constexpr (G_ABORT_ON_FAILED_ASSUME) Assume(!std::binary_search(...)) per cursor entry — zero cost in release, live under fuzz/debug.
  • Test/fuzz base views (coins_tests CCoinsViewTest, coinscache_sim CoinsViewBottom): unconditional assert, same idiom. Bonus find: txdb.cpp's spents-loop comment still made the pre-contract claim that spents "must be erased before the cursor entries are written" (an outpoint "can appear in both") — now false and contradicting the CCoinsView::BatchWrite doc; rewritten to the disjointness language. Note the fuzz coins_view harness's direct BatchWrite call passes an always-empty spents, so the new Assume cannot false-trip under fuzzing.

Verification: coins unit suites pass; all four coins corpora replay clean in debug_fuzz. Negative control (RemoveShadowedSpents calls commented out): coins_tests catches it behaviorally (compact_spents_shadowed_purged recorder check), and the coinscache_sim corpus aborts on the first inputs at the new CoinsViewBottom assert — "catches purge regressions instantly" demonstrated, not just claimed. Both builds restored and re-verified clean. (The unit-suite recorder test routes its flush into a cache-backed base, so in release builds the behavioral check is the catcher there; the hard-assert catch is the fuzz base view's job.)

Larry's spin-off idea (separate upstream PR, queued in memory): an Assume_debug(...) helper for the if constexpr (G_ABORT_ON_FAILED_ASSUME) Assume(...) idiom — this branch alone now has four sites that would collapse to one-liners.

Accounting correction: bucket array share (Larry, 2026-07-31)

The "~128 vs 36 bytes" comparison omits the map's bucket array, which memusage charges for real (memusage.h DynamicUsage: MallocUsage(8 * bucket_count()) on top of the pool chunks). Per element that is 8/load_factor bytes: 8 at load factor 1 (Larry's estimate, the floor), ~11-12 typical for libstdc++ (max_load_factor 1.0, prime ~2x growth, so f sawtooths 0.5-1.0, average ~0.7). Erase-never-shrinks is not an objection: the PR-relevant number is marginal cost at peak (cache fills to a byte budget; buckets are sized by peak count), where the share is real. Symmetric honesty on the vector side: DynamicUsage charges vector capacity (growth slack) and the filter adds ~2-4 B/entry when built. PR-description arithmetic: ~136-140 vs ~40 effective bytes per spent coin, ratio ~3.5x (basically unchanged), absolute saving per compacted coin improves ~92 -> ~100 bytes. Measured results unaffected (benchmarks run on real DynamicUsage, which always counted buckets both sides). Code comments quoting ~128 stay: they describe the pool node itself, which is exactly 128 B; the threshold heuristic (cache/8/128) lands within ~7% of its stated intent.

USB PAIR ON NEW BASE COMPLETE (2026-07-31, take2 after the freeze)

master 3:01:29 vs branch 2:47:00 -> WALL -8.0% (869s); CPU -5.0% (5614->5331s); flushes 49->35 (-29%); criticals 0/0; max RSS equal (~6.92 GB both). Fairness gate PASSED: early-window r_await 6.9 vs 6.1 ms (no destage-poisoning signature; previous discard-worthy runs showed multi-x asymmetry). Full-run r_await 29.1 vs 24.9 ms — the gap is the treatment effect (flush avoidance de-pollutes the read queue, the same second-order mechanism the old-base study documented), not a confound. Cross-base pattern CONFIRMS the story: on spinning/seek-bound media the new base (#35295 parallel fetch) helps master less, so the branch's edge GREW old-base->new-base on both devices: HDD -11.4% -> -14.3%, USB -4.4% -> -8.0% (fresh-layout USB remains the lower bound vs aged-datadir HDD, per the effective-per-I/O-cost framing). Final three-device framing for the PR: HDD -14.3%, USB -8.0%, SSD +1.5% wall but -15% writes / -5% CPU / -31% flushes (disclosed plainly). Results in bench-results/07-31-usb-{master-newbase,branch-take2}/.

Correction to the accounting correction (Larry, 2026-07-31)

The previous section's "vector growth slack" claim was WRONG: CompactSpents builds every vector exactly-sized (reserve(m_spent_count) for the batch, reserve(existing+new) for the merge target; nothing pushes past it — the in-loop assert(size < capacity) pins the discipline), so capacity == size by construction; the design never grows in place. Only honest residuals: post-purge capacity gap (erase_if in RemoveShadowedSpents shrinks size not capacity; bounded by shadowed-entry count, reset to exact at the next compaction rebuild) and malloc rounding of one big allocation (~nothing per entry). Corrected per-entry figure: 36 + ~2-4 filter = ~38-40 effective; the ~40 conclusion stands via the filter alone, not slack. Map-side ~136-140 unaffected.

Addendum (Larry): third residual for completeness — duplicate entries (spend -> re-create -> re-spend within one epoch) each cost 36 B without representing a distinct coin; exceedingly rare, consumers idempotent, cleared at flush. Doesn't move the ~38-40 figure.

CI on the final-candidate push: GREEN (2026-08-01)

Run 30660860030 on the autosquashed series (tip 4514316ed8): 19/20 jobs pass, including the full sanitizer family (ASan+LSan+UBSan+integer, MSan, TSan, both sanitizer-fuzz jobs — the suppressions hold) and both platform fuzz jobs. Sole failure: riscv32 bare metal, 4th consecutive occurrence of the sourceware.org HTTP 429 rate-limit on the fork's cold-cache toolchain clone (confirmed in log: "unable to access 'https://sourceware.org/git/newlib-cygwin.git/': error: 429"). Expected green on upstream CI where the Docker layer cache is warm. Branch is CI-clean for PR purposes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment