Skip to content

Commit ad9d9c4

Browse files
committed
feat(node): drop the RPC block record on disconnect
Connection pushes a BlockRecord that RPC serves getblock from. Leaving it after a disconnect means answering for a block the chain no longer contains. Removal is opportunistic on purpose. The vector is a best-effort in-process cache, not authoritative state: it starts empty on every boot while applied_tip resumes from a checkpoint at height N, and the prune service removes records from it. An earlier version of the comment called a missing tail a bug; making it one would refuse the first disconnect after any restart. The hash check is what stops the pop from taking a record that is not ours, and the tail can only be ours or gone, since disconnect runs on the applied tip and connection pushed that tip's record last. Two entries leave the owed list. The transactions map never needed one: connection does not populate it, which an existing test already pins. Also records that retry is not a recovery strategy for the partial failure window. Each UTXO operation looks idempotent on the set, but undo_block runs through commit_adds_and_removes, which fires UtxoSet's listener, and coin_stats is one. A second pass re-emits callbacks for operations that changed nothing, so a cumulative listener double-counts even where the set converges.
1 parent ad8cc59 commit ad9d9c4

2 files changed

Lines changed: 79 additions & 6 deletions

File tree

crates/node/src/apply.rs

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -541,7 +541,8 @@ impl ApplyHandles {
541541
/// | `utxo`, `tx_index`, `applied_tip` | restored here |
542542
/// | `coin_stats` | **owed** — cumulative, so it drifts on every disconnect |
543543
/// | `filter_index`, `filter_header_cache` | **owed** — BIP157 headers chain, so a stale link corrupts the chain |
544-
/// | `blocks`, `transactions` | **owed** — RPC would keep serving the disconnected block |
544+
/// | `blocks` | restored here — RPC would otherwise keep serving the disconnected block |
545+
/// | `transactions` | nothing owed: connection never populates it |
545546
/// | `mempool` | **owed** once transaction relay exists; disconnected transactions belong back in it |
546547
/// | `block_tree` | retained deliberately — the header stays valid and known |
547548
/// | `block_body_store` | retained deliberately — the body is still a real block |
@@ -557,10 +558,14 @@ impl ApplyHandles {
557558
/// path can return an error after other shards already committed, so the set
558559
/// can be left partly undone.
559560
///
560-
/// Retrying converges only if both rollbacks are idempotent. Each individual
561-
/// UTXO operation is (restoring a live output and removing an absent one are
562-
/// both no-ops), but neither that nor index rollback idempotence is proven, so
563-
/// the window is listed with the owed work above rather than claimed away.
561+
/// Retrying does not obviously converge. Each individual UTXO operation looks
562+
/// idempotent, since restoring a live output and removing an absent one are
563+
/// both no-ops on the set. The set is not the whole contract: `undo_block`
564+
/// runs through `commit_adds_and_removes`, which fires `UtxoSet`'s listener,
565+
/// and `coin_stats` is one. A second pass can re-emit callbacks for operations
566+
/// that changed nothing, so a cumulative listener double-counts even though the
567+
/// set converged. Retry is therefore not a recovery strategy until listener
568+
/// behaviour is settled, and the window stays with the owed work above.
564569
///
565570
/// 1. Read and decode the undo record. Nothing is mutated until this succeeds,
566571
/// so a missing or corrupt record costs nothing.
@@ -655,6 +660,28 @@ pub fn disconnect_block(
655660
.undo_block(&undo)
656661
.map_err(ApplyError::UtxoCommit)?;
657662

663+
// RPC serves blocks from this vector, so the disconnected block's record
664+
// must go or `getblock` keeps answering for it.
665+
//
666+
// Absence is legitimate and must never be an error. This is a best-effort
667+
// in-process cache, not authoritative state: it starts empty on every boot
668+
// while `applied_tip` resumes from a checkpoint at height N, and pruning
669+
// removes records from it. Failing a consensus rollback because an optional
670+
// cache is empty would refuse the first disconnect after any restart.
671+
//
672+
// The hash check is what stops the pop from truncating a record that is not
673+
// ours. The tail can only be ours or gone, because disconnect runs on the
674+
// applied tip and connection pushed that tip's record last.
675+
{
676+
let mut blocks = handles.blocks.write();
677+
if blocks
678+
.last()
679+
.is_some_and(|record| record.hash == block_hash)
680+
{
681+
blocks.pop();
682+
}
683+
}
684+
658685
handles
659686
.applied_tip
660687
.store(Some(Arc::new(parent_tip.clone())));
@@ -4626,6 +4653,52 @@ mod consensus_rule_tests {
46264653
Ok(())
46274654
}
46284655

4656+
/// RPC reads blocks from `handles.blocks`. Leaving the entry there would let
4657+
/// `getblock` keep answering for a block the chain no longer contains.
4658+
#[test]
4659+
#[allow(clippy::arc_with_non_send_sync)]
4660+
fn disconnect_drops_the_rpc_block_record() -> Result<(), Box<dyn std::error::Error>> {
4661+
let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest);
4662+
let utxo = Arc::new(UtxoSet::new());
4663+
let handles = apply_handles_without_tx_index(Network::Regtest, Arc::clone(&utxo));
4664+
let genesis_hash = Hash256::from_le_bytes(genesis.block_hash().as_byte_array());
4665+
let genesis_tip = applied_header_tip(&handles, genesis_hash, &genesis, 0)?;
4666+
handles.applied_tip.store(Some(Arc::new(genesis_tip)));
4667+
let records_before = handles.blocks.read().len();
4668+
4669+
let block = mined_block_with_prev_hash_and_transactions(
4670+
genesis.block_hash(),
4671+
vec![coinbase_transaction(1)],
4672+
)?;
4673+
let block_hash = Hash256::from_le_bytes(block.block_hash().as_byte_array());
4674+
apply_block(&handles, &block)?;
4675+
assert!(
4676+
handles
4677+
.blocks
4678+
.read()
4679+
.iter()
4680+
.any(|record| record.hash == block_hash),
4681+
"connection must publish the record this test then removes"
4682+
);
4683+
4684+
disconnect_block(&handles, &block)?;
4685+
4686+
assert!(
4687+
!handles
4688+
.blocks
4689+
.read()
4690+
.iter()
4691+
.any(|record| record.hash == block_hash),
4692+
"RPC must not keep serving a disconnected block"
4693+
);
4694+
assert_eq!(
4695+
handles.blocks.read().len(),
4696+
records_before,
4697+
"exactly the one record must go"
4698+
);
4699+
Ok(())
4700+
}
4701+
46294702
/// An indexer that cannot roll back must stop the disconnect, not be
46304703
/// skipped. Finishing would leave index rows describing a block the chain
46314704
/// no longer contains, and queries would answer from them.

docs/solutions/architecture-patterns/node-reorg-execution-design.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ Open, and prerequisites for giving `disconnect_block` a caller:
153153
| `coin_stats` inverse feed | cumulative, so it drifts on every disconnect |
154154
| `filter_index` rollback | BIP157 headers chain, so a stale link corrupts the chain; `filter_header_cache` must be reset with it |
155155
| RPC caches | `blocks` and `transactions` would keep serving the disconnected block |
156-
| Rollback idempotence, both stores | `undo_block` is fallible and runs after the index rolls back. Worse, it is not all-or-nothing: `commit_adds_and_removes` walks shards and both its serial and parallel paths can fail after other shards committed, so the UTXO set can be left partly undone. Retry converges only if both rollbacks are idempotent. Prove it, make the UTXO commit atomic, or add compensation |
156+
| Rollback idempotence, both stores | `undo_block` is fallible and runs after the index rolls back. Worse, it is not all-or-nothing: `commit_adds_and_removes` walks shards and both its serial and parallel paths can fail after other shards committed, so the UTXO set can be left partly undone. Retry is not obviously a fix: each UTXO operation looks idempotent on the set, but `commit_adds_and_removes` fires `UtxoSet`'s listener, and `coin_stats` is one, so a second pass can re-emit callbacks for operations that changed nothing and a cumulative listener double-counts. Prove listener-level idempotence, make the UTXO commit atomic, or add compensation |
157157

158158
Open, layer 4:
159159

0 commit comments

Comments
 (0)