Repository navigation
Stop on a coin read error instead of treating the coin as missing - #945
Merged
Merged
Conversation
check_block tested for an empty transaction list before comparing the merkle root, so an empty body sent for a real header failed bad-blk-length rather than Core's bad-txnmrklroot. accept_block then marked the header's block Invalid for that and for every other non-mutation check_block failure. Core runs CheckMerkleRoot first (validation.cpp CheckBlock), and ProcessNewBlock never marks a block for a CheckBlock failure; of the rules in satd's check_block, Core marks only bad-blk-weight, from ContextualCheckBlock. A block that extends the tip and arrived without its header first had no index entry until it connected, so a failed connect marked nothing and each copy sent again was validated and written again. accept_block now writes the HeaderOnly row a header announcement would leave before connecting, as Core's AcceptBlockHeader creates the index entry before ConnectTip. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A block whose parent had no index entry was parked in the block processor's buffer keyed by its prev hash: up to 8192 entries with no byte bound, no per-peer bound and no expiry, whether or not this node had asked for the block, before any proof-of-work or size check. Each entry kept its sender's in-flight guard, so that peer's pongs waited for the 60-second bound until the block's parent connected, and the block went through the per-input fee pass before accept_block refused it. Bitcoin Core keeps no such block: AcceptBlockHeader runs CheckBlockHeader (validation.cpp:4206) and then refuses an unknown parent as prev-blk-not-found (validation.cpp:4216). satd answers a block inv with a getdata, so a block it asked for can arrive before the parent it is still learning about, and keeping that one saves a second download. Now: - Only a block this node asked for (Core's IsBlockRequested, read in handle_block before the request record is cleared) may wait. - A waiting block must pass check_proof_of_work_bounded and be at most MAX_BLOCK_SERIALIZED_SIZE (4,000,000) bytes. - At most 4 per peer, 64 blocks and 32 MB in all; the sender's oldest goes first when it is at its share, then the oldest of all. Each block expires after 5 minutes. - The in-flight guard is counted out when the block is set aside, as Core is done with it once refused. - A block whose parent is unknown skips the fee pass and accept_block. - The sender is asked for headers, as satd already does for a cmpctblock on an unknown parent. - A block on a parent marked invalid is not kept. - Two blocks on the same parent can both wait; when one fails to connect the next is tried. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d block The comment on the HeaderOnly row written before connecting said every outcome matched the headers-first path. A failure that is not a verdict on the block does not move the best-header pointer, which an announced header would have done. Say so, and say what reaches the block then. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…orphan-block-buffer-bounds
RocksDbStore turned a failed coin read into "no such coin": get_coin did `.ok()??`, get_coins_batch `.ok().flatten()` and has_coin matched only `Ok(Some(_))`. A row that did not decode was logged and returned as `None` too. connect_block takes a missing coin as a verdict on the block that spends it (bad-txns-inputs-missingorspent), and accept_block and the reorg path then record that block as invalid, a record that survives a restart. Bitcoin Core reads coins through CCoinsViewErrorCatcher, which logs "Error reading from database" and calls std::abort() on any read error (coins.cpp:398-412), because returning would be "interpreted as 'entry not found'". Each coin read now goes through one helper: a missing row is `None`, a read error or an undecodable row stops the node with abort(), as Core does and as satd's other fail-stops do. A test build panics instead so the path can be tested. The Operator Manual says what the stop looks like and what to do after it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
satd-chainstate-audit looks coins up through the same store, so a coin the database cannot read now stops it with the outpoint and the RocksDB error instead of listing the coin as missing. Say so beside its exit statuses, and say where the stop applies. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
bkeroack
changed the base branch from
fix/orphan-block-buffer-bounds
to
release/0.6
October 8, 2026 18:11
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
RocksDbStoreanswered a failed coin read with "no such coin":get_coindidself.db.get_cf(&cf, key).ok()??get_coins_batchdidresult.ok().flatten()for eachmulti_get_cfresulthas_coinmatched onlyOk(Some(_)), so anErrread asfalseNoneCoinCachepasses the inner store's answer straight through, andconnect_blocktakes a missing coin as a verdict on the block that spends it:MissingOrSpentInput,bad-txns-inputs-missingorspent(chain/connect.rs).is_verdict_on_block()is true for it, soaccept_blockand the reorg path (reorg_to_naming_invalid) record the block asInvalid. That record outlives a restart. An I/O error or a checksum mismatch while connecting a valid block therefore left the node refusing that block, and every block built on it, until an operator ranreconsiderblock.Bitcoin Core reads coins through
CCoinsViewErrorCatcher.CDBWrapper::ReadImplthrows on any status other than NotFound (dbwrapper.cpp:305-316,HandleError), andExecuteBackedWrappercatches it, logs "Error reading from database", runs the registered callback ("Error reading from database, shutting down.",init.cpp:1393) and callsstd::abort()(coins.cpp:398-412). Core's comment gives the reason: returning to the caller "would be interpreted as 'entry not found' (as opposed to unable to read data), and could lead to invalid interpretation".GetCoin,HaveCoinandPeekCoinall go through it (coins.cpp:415-428).Fix
All three coin reads in
RocksDbStore(get_coin,get_coins_batch,has_coin) now go through one rule:None(orfalse), as before;stop_on_unreadable_coin, which logsError reading from database, shutting downwith the outpoint and the error (to the log and to stderr), then callsstd::process::abort().It stops with
abort()as Core does, and as satd's other fail-stops do (fail_stop_on_unsafe_discardinchain/state.rs, the stall watchdog). No shutdown flush runs. The chainstate on disk is the one the last flush left, and the next start replays the blocks after it from the block files, as after any crash. As infail_stop_on_unsafe_discard, a test build panics instead of aborting so the path can be tested.The fix sits at the lowest layer, so every reader is covered: block connect, reorg, the IBD connector, prefetch, the AssumeUTXO background chainstate, the mempool, and the RPC, Electrum, Esplora and silent-payment lookups. In Core those all read through the same error catcher too.
CoinCacheandSplitStoredelegate to this store, so they need no change.The Operator Manual (
ibd.md) gets a short section on what the stop looks like and what to do if it repeats after a restart.Tests
New file
node/src/storage/rocksdb_store_storefault_tests.rs. The read errors are real: the coin's SST file is flushed, then damaged in place on disk, and RocksDB fails the read withCorruption: block checksum mismatch.a_coin_read_error_stops_the_node_and_is_never_read_as_missing: on a damaged store,get_coin,get_coins_batchandhas_coineach stop. An undamaged copy of the same store returns the coin from all three, so the reads do reach the damaged block.Fails without the fix: yes ("get_coin: a coin that cannot be read must stop the node, but the read returned None").
an_undecodable_coin_row_stops_the_node_and_is_never_read_as_missing: a present row that does not decode stopsget_coinandget_coins_batch. The coin next to it still reads.Fails without the fix: yes (same assertion,
None).a_block_spending_a_coin_that_cannot_be_read_is_not_marked_invalid:ChainStateoverCoinCacheoverRocksDbStore, as the node runs. A block at height 1 spends the coin. Over an undamaged database it connects. Over the damaged one,accept_blockstops, the block's index entry is notInvalid, and the tip does not move.Fails without the fix: yes (
left: Some(Invalid), "a read error is not a verdict on the block (accept_block: Err(Connect(MissingOrSpentInput { .. })))").Perturbations, one guard at a time, each restored afterwards:
get_coinback to.ok()??:a_coin_read_error_…andan_undecodable_coin_row_…fail ("get_coin: … returned None").has_coinback tofalseonErr:a_coin_read_error_…fails ("has_coin: … returned false").get_coins_batchback to.ok().flatten(): both store-level tests fail ("get_coins_batch: … returned [None]").None:an_undecodable_coin_row_…fails.The chain-level test fails only when both
get_coins_batchandget_coinare reverted (the unfixed tree, above).connect_blockfalls back toget_coinfor a coin the batch did not return, so either guard alone still stops the node there.Notes
CDBWrapper::Readreturns "not found" when a value fails to deserialize (dbwrapper.h:207-224). In Core that cannot come from disk damage, though, because LevelDB verifies block checksums on every read (dbwrapper.cpp:221) and damage arrives as aCorruptionstatus, which aborts. satd's own decoder already calls a short coin row corruption, not absence (storage/coinview.rs,deserialize_compact_stream), and the schema check at open refuses a datadir in a layout this binary cannot read. Reading such a row as "spent" would bring back the invalid mark this PR removes.RocksDbStorestill turn an error intoNone(get_block_index,get_undo,get_tip,get_block_hash_by_heightand others). None of them feeds a missing-coin verdict. They are listed in the report for a follow-up and not changed here.satd-chainstate-auditlooks coins up through the same store. It now stops at the first unreadable coin, naming the outpoint and the RocksDB error, where it used to list that coin as missing. The manual says so beside the tool's exit statuses. No live-node path runs that audit: theverifychainRPC reads block bodies, not coins.handle_block_ibd's peer scoring of store errors is not touched (a separate PR covers it).Release notes
satd now stops, as Bitcoin Core does, when its chainstate database cannot read a coin: an I/O error, a checksum mismatch, or a coin record that does not decode. It logs
Error reading from database, shutting down. Before, satd treated such a coin as spent. If that coin was being spent by a valid block, satd refused the block and recorded it as invalid, and the record survived restarts. After a stop, the next start replays the blocks after the last flush. If the error comes back, check the disk and rebuild with-reindex-chainstate.🤖 Generated with Claude Code