From 35719143fe8fa07d3b21a2fe3bdec492a14f332e Mon Sep 17 00:00:00 2001 From: Brice Dobry <232827048+brice-stacks@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:22:57 -0400 Subject: [PATCH 1/5] fix: improve miner thread robustness Fixed the miner thread exiting (and stalling the chain for the remainder of the tenure) on transient error. The miner now retries when it hits DB contention, a parent block that has not been processed yet, a new parent block discovered mid-mining, or a mempool cache reset failure, instead of giving up on the tenure. --- .../miner-thread-retry-transient-errors.fixed | 1 + stacks-node/src/nakamoto_node/miner.rs | 85 ++++++++++++++----- 2 files changed, 66 insertions(+), 20 deletions(-) create mode 100644 changelog.d/miner-thread-retry-transient-errors.fixed diff --git a/changelog.d/miner-thread-retry-transient-errors.fixed b/changelog.d/miner-thread-retry-transient-errors.fixed new file mode 100644 index 00000000000..ba60048232e --- /dev/null +++ b/changelog.d/miner-thread-retry-transient-errors.fixed @@ -0,0 +1 @@ +Fixed the miner thread exiting (and stalling for the remainder of the tenure) on transient errors. The miner now retries when it hits DB contention, a parent block that has not been processed yet, a new parent block discovered mid-mining, or a mempool cache reset failure, instead of giving up on the tenure. diff --git a/stacks-node/src/nakamoto_node/miner.rs b/stacks-node/src/nakamoto_node/miner.rs index be948d94599..93041581909 100644 --- a/stacks-node/src/nakamoto_node/miner.rs +++ b/stacks-node/src/nakamoto_node/miner.rs @@ -613,6 +613,16 @@ impl BlockMinerThread { reward_set: &RewardSet, ) -> Result<(), NakamotoNodeError> { Self::fault_injection_miner_stall(); + // Check the abort flag first: every retryable error path loops back + // through this function, so this check guarantees that a retry loop + // can always be stopped by the relayer, even if the failing step + // comes before the block builder's own abort checks. + if self.abort_flag.load(Ordering::SeqCst) { + info!("Miner interrupted while mining in order to shut down"); + self.globals + .raise_initiative("MiningFailure: aborted by node".to_string()); + return Err(ChainstateError::MinerAborted.into()); + } let mut chain_state = neon_node::open_chainstate_with_faults(&self.config).map_err(|e| { NakamotoNodeError::SigningCoordinatorFailure(format!( @@ -652,13 +662,21 @@ impl BlockMinerThread { .connect_mempool_db() .expect("Database failure opening mempool"); - if self.reset_mempool_caches || self.config.node.mock_mining { - mem_pool.reset_mempool_caches()?; + let reset_result = if self.reset_mempool_caches || self.config.node.mock_mining { + mem_pool.reset_mempool_caches() } else { // Even if the nonce cache is still valid, NextNonceWithHighestFeeRate strategy // needs to reset this cache after each block. This prevents skipping transactions // that were previously considered, but not included in previous blocks. - mem_pool.reset_considered_txs_cache()?; + mem_pool.reset_considered_txs_cache() + }; + if let Err(e) = reset_result { + // A mempool DB error here is likely transient (e.g. lock + // contention); sleep and retry rather than exiting the miner + // thread. + warn!("Miner: failed to reset mempool caches, will try again: {e:?}"); + thread::sleep(Duration::from_millis(ABORT_TRY_AGAIN_MS)); + return Ok(()); } } @@ -719,15 +737,24 @@ impl BlockMinerThread { coordinator: &mut SignerCoordinator, ) -> Result, NakamotoNodeError> { match self.mine_block(coordinator) { - Ok(x) => { - if !self.validate_timestamp(&x)? { + Ok(x) => match self.validate_timestamp(&x) { + Ok(true) => Ok(Some(x)), + Ok(false) => { info!("Block mined too quickly. Will try again."; "block_timestamp" => x.header.timestamp, ); - return Ok(None); + Ok(None) } - Ok(Some(x)) - } + Err(e) => { + // We just mined this block atop its parent, so a failure to + // query the parent header here is almost certainly transient + // DB contention with the chains coordinator. Retry rather + // than exiting the miner thread. + warn!("Failed to validate timestamp of mined block, will try again: {e:?}"); + thread::sleep(Duration::from_millis(ABORT_TRY_AGAIN_MS)); + Ok(None) + } + }, Err(NakamotoNodeError::MiningFailure(ChainstateError::MinerAborted)) => { if self.abort_flag.load(Ordering::SeqCst) { info!("Miner interrupted while mining in order to shut down"); @@ -778,10 +805,30 @@ impl BlockMinerThread { } Ok(None) } - Err(NakamotoNodeError::ParentNotFound) if self.config.node.mock_mining => { - info!( - "Mock miner could not load parent tenure info yet. Will try again."; - ); + Err(NakamotoNodeError::ParentNotFound) => { + // The parent block may not have been processed yet (e.g. this + // tenure just started), or a transient DB error was mapped to + // `ParentNotFound` while loading parent info. Sleep and retry; + // if the burnchain tip has changed, the next `mine_block()` + // call will error and exit the miner thread. + info!("Miner: could not load parent info yet. Will try again."); + thread::sleep(Duration::from_millis(ABORT_TRY_AGAIN_MS)); + Ok(None) + } + Err(NakamotoNodeError::NewParentDiscovered) => { + // A new block in the parent tenure was processed while we were + // loading parent info. Retry to build atop the new tip. + info!("Miner: new parent block discovered while mining. Will try again."); + Ok(None) + } + Err( + ref e @ (NakamotoNodeError::MiningFailure(ChainstateError::DBError(_)) + | NakamotoNodeError::DBError(_)), + ) => { + // Transient DB errors (e.g. lock contention with the chains + // coordinator) are expected occasionally. Retry rather than + // exiting the miner thread. + warn!("Miner: transient DB error while mining, will try again: {e:?}"); thread::sleep(Duration::from_millis(ABORT_TRY_AGAIN_MS)); Ok(None) } @@ -1589,8 +1636,7 @@ impl BlockMinerThread { } let target_epoch_id = - SortitionDB::get_stacks_epoch(burn_db.conn(), self.burn_block.block_height + 1) - .map_err(|_| NakamotoNodeError::SnapshotNotFoundForChainTip)? + SortitionDB::get_stacks_epoch(burn_db.conn(), self.burn_block.block_height + 1)? .expect("FATAL: no epoch defined") .epoch_id; let mut parent_block_info = self.load_block_parent_info(&mut burn_db, &mut chain_state)?; @@ -1675,9 +1721,7 @@ impl BlockMinerThread { vec![] }; // build the block itself - let mining_burn_handle = burn_db - .index_handle_at_ch(&self.burn_block.consensus_hash) - .map_err(|_| NakamotoNodeError::UnexpectedChainState)?; + let mining_burn_handle = burn_db.index_handle_at_ch(&self.burn_block.consensus_hash)?; if let Some(parent_burn_view) = &parent_block_info.stacks_parent_header.burn_view { if !mining_burn_handle.processed_block(parent_burn_view)? { error!( @@ -2145,9 +2189,10 @@ impl ParentStacksBlockInfo { let principal = miner_address.into(); let account = chain_state .with_read_only_clarity_tx( - &burn_db - .index_handle_at_block(chain_state, &stacks_tip_header.index_block_hash()) - .map_err(|_| NakamotoNodeError::UnexpectedChainState)?, + &burn_db.index_handle_at_block( + chain_state, + &stacks_tip_header.index_block_hash(), + )?, &stacks_tip_header.index_block_hash(), |conn| StacksChainState::get_account(conn, &principal), ) From 869e811d482a2aa1e8105294a2b85b493e7ed0d2 Mon Sep 17 00:00:00 2001 From: Brice Dobry <232827048+brice-stacks@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:32:57 -0400 Subject: [PATCH 2/5] refactor: make mempool cache resets less manual Replace a boolean flag that was set manually with an optional block id to ensure that we reset the caches when appropriate. --- changelog.d/mempool-cache-flag.changed | 1 + stacks-node/src/nakamoto_node/miner.rs | 154 +++++++++++++----- .../src/tests/nakamoto_integrations.rs | 80 ++++++++- 3 files changed, 191 insertions(+), 44 deletions(-) create mode 100644 changelog.d/mempool-cache-flag.changed diff --git a/changelog.d/mempool-cache-flag.changed b/changelog.d/mempool-cache-flag.changed new file mode 100644 index 00000000000..e6cdc3cbea8 --- /dev/null +++ b/changelog.d/mempool-cache-flag.changed @@ -0,0 +1 @@ +The miner now ties mempool nonce-cache validity to the specific parent block it builds on, automatically resetting the caches whenever the parent changes (e.g. across mining retries), instead of relying on a manually-maintained flag. diff --git a/stacks-node/src/nakamoto_node/miner.rs b/stacks-node/src/nakamoto_node/miner.rs index 93041581909..8b769345e94 100644 --- a/stacks-node/src/nakamoto_node/miner.rs +++ b/stacks-node/src/nakamoto_node/miner.rs @@ -107,6 +107,20 @@ pub static TEST_BLOCK_PUSH_SKIP: LazyLock> = LazyLock::new(TestFl // Test flag to indicate the block that the miner most recently tried to broadcast pub static TEST_MINER_BROADCASTING_BLOCK: LazyLock> = LazyLock::new(TestFlag::default); +#[cfg(test)] +#[derive(Clone, Copy, Debug, PartialEq)] +/// Kinds of transient errors that tests can inject into `mine_block()` via +/// `TEST_MINE_TRANSIENT_ERRORS` to exercise the miner thread's retry paths. +pub enum TestTransientError { + ParentNotFound, + NewParentDiscovered, + DBError, +} +#[cfg(test)] +/// Test flag holding transient errors to inject into `mine_block()`. Each +/// mining attempt pops and returns one error until the list is empty. +pub static TEST_MINE_TRANSIENT_ERRORS: LazyLock>> = + LazyLock::new(TestFlag::default); #[cfg(test)] /// Set the `TEST_MINE_STALL` flag to `Pending` and block until the miner is stalled. @@ -297,8 +311,11 @@ pub struct BlockMinerThread { burn_tip_at_start: ConsensusHash, /// flag to indicate an abort driven from the relayer abort_flag: Arc, - /// Should the nonce and considered transactions cache be reset before mining the next block? - reset_mempool_caches: bool, + /// The Stacks block ID that the mempool nonce and considered-transaction + /// caches are known to be consistent with, if any. Before each mempool + /// walk, the caches are reset unless this matches the parent block being + /// built upon. + mempool_caches_valid_for: Option, /// Storage for persisting non-confidential miner information miner_db: MinerDB, /// Transaction IDs to exclude from the next block proposal only. @@ -354,7 +371,7 @@ impl BlockMinerThread { abort_flag: Arc::new(AtomicBool::new(false)), tenure_cost: ExecutionCost::ZERO, tenure_budget: ExecutionCost::ZERO, - reset_mempool_caches: true, + mempool_caches_valid_for: None, miner_db: MinerDB::open_with_config(&rt.config)?, temporarily_excluded_txids: HashSet::new(), permanently_excluded_txids: HashSet::new(), @@ -415,6 +432,29 @@ impl BlockMinerThread { false } + #[cfg(test)] + fn fault_injection_transient_mining_error() -> Option { + let mut errors = TEST_MINE_TRANSIENT_ERRORS.get(); + if errors.is_empty() { + return None; + } + let injected = errors.remove(0); + TEST_MINE_TRANSIENT_ERRORS.set(errors); + warn!("Fault injection: injecting transient mining error {injected:?}"); + Some(match injected { + TestTransientError::ParentNotFound => NakamotoNodeError::ParentNotFound, + TestTransientError::NewParentDiscovered => NakamotoNodeError::NewParentDiscovered, + TestTransientError::DBError => NakamotoNodeError::DBError( + stacks::util_lib::db::Error::Other("Fault injection: transient DB error".into()), + ), + }) + } + + #[cfg(not(test))] + fn fault_injection_transient_mining_error() -> Option { + None + } + #[cfg(test)] fn fault_injection_block_announce_stall(new_block: &NakamotoBlock) { if TEST_BLOCK_ANNOUNCE_STALL.get() { @@ -649,37 +689,6 @@ impl BlockMinerThread { return Ok(()); } - // Reset the mempool caches if needed. When mock-mining, we always - // reset the caches, because the blocks we mine are not actually - // processed, so the mempool caches are not valid. - if self.reset_mempool_caches - || self.config.miner.mempool_walk_strategy - == MemPoolWalkStrategy::NextNonceWithHighestFeeRate - || self.config.node.mock_mining - { - let mut mem_pool = self - .config - .connect_mempool_db() - .expect("Database failure opening mempool"); - - let reset_result = if self.reset_mempool_caches || self.config.node.mock_mining { - mem_pool.reset_mempool_caches() - } else { - // Even if the nonce cache is still valid, NextNonceWithHighestFeeRate strategy - // needs to reset this cache after each block. This prevents skipping transactions - // that were previously considered, but not included in previous blocks. - mem_pool.reset_considered_txs_cache() - }; - if let Err(e) = reset_result { - // A mempool DB error here is likely transient (e.g. lock - // contention); sleep and retry rather than exiting the miner - // thread. - warn!("Miner: failed to reset mempool caches, will try again: {e:?}"); - thread::sleep(Duration::from_millis(ABORT_TRY_AGAIN_MS)); - return Ok(()); - } - } - let Some(new_block) = self.mine_block_and_handle_result(coordinator)? else { // We should reattempt to mine return Ok(()); @@ -775,7 +784,6 @@ impl BlockMinerThread { "Miner did not find any transactions to mine, sleeping for {:?}", self.config.miner.empty_mempool_sleep_time ); - self.reset_mempool_caches = false; // Pause the miner to wait for transactions to arrive let now = Instant::now(); @@ -953,8 +961,11 @@ impl BlockMinerThread { "consensus_hash" => %new_block.header.consensus_hash, ); - // We successfully mined, so the mempool caches are valid. - self.reset_mempool_caches = false; + // The mempool walk advanced the caches past the transactions + // included in this block, so they are consistent with the + // chainstate as of the block we just broadcast — which is the + // parent we expect to build on next. + self.mempool_caches_valid_for = Some(new_block.header.block_id()); // Block was accepted — clear any single-block exclusions self.temporarily_excluded_txids.clear(); } @@ -1586,6 +1597,20 @@ impl BlockMinerThread { Ok(self.validate_timestamp_info(x.header.timestamp, &stacks_parent_header)) } + /// Decide whether the mempool nonce and considered-transaction caches + /// must be fully reset before walking the mempool to build a block on + /// `parent_tip`. The caches are usable only if they are known to be + /// consistent with `parent_tip`'s chainstate. When mock-mining, they + /// never are: mock-mined blocks are never processed, so the cache + /// updates made during a walk never match the chainstate. + fn mempool_caches_need_reset( + caches_valid_for: Option<&StacksBlockId>, + parent_tip: &StacksBlockId, + mock_mining: bool, + ) -> bool { + mock_mining || caches_valid_for != Some(parent_tip) + } + // TODO: add tests from mutation testing results #4869 #[cfg_attr(test, mutants::skip)] /// Try to mine a Stacks block by assembling one from mempool transactions and sending a @@ -1617,6 +1642,9 @@ impl BlockMinerThread { if Self::fault_injection_block_mining_skip() { return Err(ChainstateError::MinerAborted.into()); } + if let Some(e) = Self::fault_injection_transient_mining_error() { + return Err(e); + } neon_node::fault_injection_long_tenure(); let mut mem_pool = self @@ -1707,10 +1735,26 @@ impl BlockMinerThread { return Err(ChainstateError::MinerAborted.into()); } - // If we attempt to build a block, we should reset the nonce cache. - // In the special case where no transactions are found, this flag will - // be reset to false. - self.reset_mempool_caches = true; + let parent_tip = parent_block_info.stacks_parent_header.index_block_hash(); + if Self::mempool_caches_need_reset( + self.mempool_caches_valid_for.as_ref(), + &parent_tip, + self.config.node.mock_mining, + ) { + mem_pool.reset_mempool_caches()?; + } else if self.config.miner.mempool_walk_strategy + == MemPoolWalkStrategy::NextNonceWithHighestFeeRate + { + // Even if the nonce cache is still valid, NextNonceWithHighestFeeRate strategy + // needs to reset this cache after each block. This prevents skipping transactions + // that were previously considered, but not included in previous blocks. + mem_pool.reset_considered_txs_cache()?; + } + // The mempool walk mutates the caches, so consider them invalid until + // we know which chainstate the walk's result is consistent with: the + // parent tip if no transactions were selected, or the new block once + // it is signed and broadcast. + self.mempool_caches_valid_for = None; let replay_transactions = if self.config.miner.replay_transactions { coordinator @@ -1770,6 +1814,9 @@ impl BlockMinerThread { })?; if block_metadata.block.tx_count() == 0 { + // The walk selected nothing, so the caches still reflect the + // chainstate as of the parent tip. + self.mempool_caches_valid_for = Some(parent_tip); return Err(ChainstateError::NoTransactionsToMine.into()); } let mining_key = self.keychain.get_nakamoto_sk(); @@ -2270,7 +2317,7 @@ fn should_read_count_extend_units() { tenure_change_time: Instant::now(), burn_tip_at_start: ConsensusHash([0; 20]), abort_flag: Arc::new(AtomicBool::new(false)), - reset_mempool_caches: false, + mempool_caches_valid_for: None, miner_db: MinerDB::open("/tmp/should_read_count_extend_units.db").unwrap(), temporarily_excluded_txids: HashSet::new(), permanently_excluded_txids: HashSet::new(), @@ -2321,3 +2368,26 @@ fn should_read_count_extend_units() { "When read_count is at the configured threshhold, we should try to extend" ); } + +#[test] +fn mempool_caches_need_reset_units() { + let tip_a = StacksBlockId([1; 32]); + let tip_b = StacksBlockId([2; 32]); + + assert!( + BlockMinerThread::mempool_caches_need_reset(None, &tip_a, false), + "Caches of unknown validity must be reset" + ); + assert!( + !BlockMinerThread::mempool_caches_need_reset(Some(&tip_a), &tip_a, false), + "Caches consistent with the parent tip must be preserved" + ); + assert!( + BlockMinerThread::mempool_caches_need_reset(Some(&tip_a), &tip_b, false), + "Caches consistent with a different tip must be reset when the parent changes" + ); + assert!( + BlockMinerThread::mempool_caches_need_reset(Some(&tip_a), &tip_a, true), + "Mock miners must always reset, because their blocks are never processed" + ); +} diff --git a/stacks-node/src/tests/nakamoto_integrations.rs b/stacks-node/src/tests/nakamoto_integrations.rs index eff48ab0e1d..62417e3cbb4 100644 --- a/stacks-node/src/tests/nakamoto_integrations.rs +++ b/stacks-node/src/tests/nakamoto_integrations.rs @@ -123,8 +123,8 @@ use stacks_signer::v0::SpawnedSigner; use crate::burnchains::bitcoin::core_controller::BitcoinCoreController; use crate::nakamoto_node::miner::{ fault_injection_stall_miner, fault_injection_try_stall_miner, fault_injection_unstall_miner, - TEST_BLOCK_ANNOUNCE_STALL, TEST_BROADCAST_PROPOSAL_STALL, TEST_P2P_BROADCAST_SKIP, - TEST_P2P_BROADCAST_STALL, + TestTransientError, TEST_BLOCK_ANNOUNCE_STALL, TEST_BROADCAST_PROPOSAL_STALL, + TEST_MINE_TRANSIENT_ERRORS, TEST_P2P_BROADCAST_SKIP, TEST_P2P_BROADCAST_STALL, }; use crate::nakamoto_node::relayer::TEST_MINER_THREAD_STALL; use crate::neon::Counters; @@ -19740,3 +19740,79 @@ fn tenure_extend_no_commits() { run_loop_thread.join().unwrap(); } + +#[test] +#[ignore] +/// Verify that the miner thread survives transient mining errors +/// (`ParentNotFound`, `NewParentDiscovered`, and DB errors) by retrying, +/// rather than exiting and stalling for the remainder of the tenure. +/// +/// Each injected error causes one `mine_block()` attempt to fail. The miner +/// must consume all of them and still mine a submitted transfer within the +/// same tenure (i.e. without a new burnchain block arriving). +fn miner_recovers_from_transient_mining_errors() { + if env::var("BITCOIND_TEST") != Ok("1".into()) { + return; + } + + let send_amt = 100; + let send_fee = 180; + let sender_sk = Secp256k1PrivateKey::random(); + let sender_addr = tests::to_addr(&sender_sk); + let recipient = PrincipalData::from(StacksAddress::burn_address(false)); + let signer_test: SignerTest = + SignerTest::new(1, vec![(sender_addr.clone(), send_amt + send_fee)]); + let mined_blocks = signer_test.running_nodes.counters.naka_mined_blocks.clone(); + let blocks_before = mined_blocks.load(Ordering::SeqCst); + signer_test.boot_to_epoch_3(); + let naka_conf = signer_test.running_nodes.conf.clone(); + let http_origin = format!("http://{}", &naka_conf.node.rpc_bind); + + // Give the miner a chance to mine the tenure-start block, so that the + // test starts from a quiescent, mid-tenure state. + wait_for(30, || { + Ok(mined_blocks.load(Ordering::SeqCst) > blocks_before) + }) + .expect("Timed out waiting for the tenure-start block to be mined"); + + info!("------------------------- Injecting transient mining errors -------------------------"); + // Each subsequent mining attempt pops and returns one of these errors. + TEST_MINE_TRANSIENT_ERRORS.set(vec![ + TestTransientError::ParentNotFound, + TestTransientError::NewParentDiscovered, + TestTransientError::DBError, + ]); + + let info_before = get_chain_info(&naka_conf); + + // Submit a transfer. The miner must retry through the injected errors and + // mine it within the current tenure. + let transfer_tx = make_stacks_transfer_serialized( + &sender_sk, + 0, + send_fee, + naka_conf.burnchain.chain_id, + &recipient, + send_amt, + ); + submit_tx(&http_origin, &transfer_tx); + + wait_for(60, || { + let info = get_chain_info(&naka_conf); + assert_eq!( + info.burn_block_height, info_before.burn_block_height, + "The burnchain tip must not change during this test" + ); + Ok(get_account(&http_origin, &sender_addr).nonce > 0) + }) + .expect( + "Timed out waiting for the miner to recover from injected transient errors and mine the transfer", + ); + + assert!( + TEST_MINE_TRANSIENT_ERRORS.get().is_empty(), + "All injected transient errors should have been consumed by mining attempts" + ); + + signer_test.shutdown(); +} From 612f6c9a1b6ceeaff1e21c033069358aefba3b4e Mon Sep 17 00:00:00 2001 From: Brice Dobry <232827048+brice-stacks@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:11:07 -0400 Subject: [PATCH 3/5] refactor: add `is_aborted` and `set_aborted` methods --- stacks-node/src/nakamoto_node/miner.rs | 13 +++++++++---- stacks-node/src/nakamoto_node/relayer.rs | 7 ++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/stacks-node/src/nakamoto_node/miner.rs b/stacks-node/src/nakamoto_node/miner.rs index 8b769345e94..93e777c5372 100644 --- a/stacks-node/src/nakamoto_node/miner.rs +++ b/stacks-node/src/nakamoto_node/miner.rs @@ -382,6 +382,11 @@ impl BlockMinerThread { self.abort_flag.clone() } + /// Has the relayer asked this miner thread to abort? + fn is_aborted(&self) -> bool { + self.abort_flag.load(Ordering::SeqCst) + } + #[cfg(test)] fn fault_injection_block_proposal_stall(new_block: &NakamotoBlock) { if TEST_BROADCAST_PROPOSAL_STALL.get().iter().any(|key| { @@ -657,7 +662,7 @@ impl BlockMinerThread { // through this function, so this check guarantees that a retry loop // can always be stopped by the relayer, even if the failing step // comes before the block builder's own abort checks. - if self.abort_flag.load(Ordering::SeqCst) { + if self.is_aborted() { info!("Miner interrupted while mining in order to shut down"); self.globals .raise_initiative("MiningFailure: aborted by node".to_string()); @@ -765,7 +770,7 @@ impl BlockMinerThread { } }, Err(NakamotoNodeError::MiningFailure(ChainstateError::MinerAborted)) => { - if self.abort_flag.load(Ordering::SeqCst) { + if self.is_aborted() { info!("Miner interrupted while mining in order to shut down"); self.globals .raise_initiative(format!("MiningFailure: aborted by node")); @@ -788,7 +793,7 @@ impl BlockMinerThread { // Pause the miner to wait for transactions to arrive let now = Instant::now(); while now.elapsed() < self.config.miner.empty_mempool_sleep_time { - if self.abort_flag.load(Ordering::SeqCst) { + if self.is_aborted() { info!("Miner interrupted while mining in order to shut down"); self.globals .raise_initiative(format!("MiningFailure: aborted by node")); @@ -1033,7 +1038,7 @@ impl BlockMinerThread { thread::sleep(Duration::from_millis(ABORT_TRY_AGAIN_MS)); - if self.abort_flag.load(Ordering::SeqCst) { + if self.is_aborted() { info!("Miner interrupted while mining in order to shut down"); self.globals .raise_initiative(format!("MiningFailure: aborted by node")); diff --git a/stacks-node/src/nakamoto_node/relayer.rs b/stacks-node/src/nakamoto_node/relayer.rs index f313dc8a3ea..e69b86b2851 100644 --- a/stacks-node/src/nakamoto_node/relayer.rs +++ b/stacks-node/src/nakamoto_node/relayer.rs @@ -301,6 +301,11 @@ impl MinerStopHandle { self.join_handle } + /// Signal the miner thread that it should abort + pub fn set_aborted(&self) { + self.abort_flag.store(true, Ordering::SeqCst); + } + /// Stop the inner miner thread. /// Blocks the miner, and sets the abort flag so that a blocked miner will error out. pub fn stop(self, globals: &Globals) -> Result<(), NakamotoNodeError> { @@ -311,7 +316,7 @@ impl MinerStopHandle { &my_id, &prior_thread_id ); - self.abort_flag.store(true, Ordering::SeqCst); + self.set_aborted(); globals.block_miner(); let prior_miner = self.into_inner(); From ed6f97beca2f5c1f8ed557804b3aca51acdc44ba Mon Sep 17 00:00:00 2001 From: Brice Dobry <232827048+brice-stacks@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:08:36 -0400 Subject: [PATCH 4/5] fix: handle transient DB errors from `get_block_processed_and_signed_weight` --- stacks-node/src/nakamoto_node/miner.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/stacks-node/src/nakamoto_node/miner.rs b/stacks-node/src/nakamoto_node/miner.rs index 9d6464c70a6..e915cf6630e 100644 --- a/stacks-node/src/nakamoto_node/miner.rs +++ b/stacks-node/src/nakamoto_node/miner.rs @@ -1018,10 +1018,21 @@ impl BlockMinerThread { } loop { - let (_, processed, _, _) = chain_state + let processed = match chain_state .nakamoto_blocks_db() - .get_block_processed_and_signed_weight(last_consensus_hash, &last_bhh)? - .ok_or_else(|| NakamotoNodeError::UnexpectedChainState)?; + .get_block_processed_and_signed_weight(last_consensus_hash, &last_bhh) + { + Ok(Some((_, processed, _, _))) => processed, + Ok(None) => return Err(NakamotoNodeError::UnexpectedChainState), + Err(e) => { + // Transient DB errors (e.g. lock contention with the chains + // coordinator) are expected occasionally. Fall through to + // the abort and burn-tip checks below, then retry rather + // than exiting the miner thread. + warn!("Miner: transient DB error while checking block processed status, will try again: {e:?}"); + false + } + }; // Once the block has been processed and the miner is no longer // blocked, we can continue mining. From 73bfe20f6028e6271628c555788aa98d6041a6cb Mon Sep 17 00:00:00 2001 From: Brice Dobry <232827048+brice-stacks@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:23:57 -0400 Subject: [PATCH 5/5] feat: don't clear mempool cache when there are no transactions --- stacks-node/src/nakamoto_node/miner.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/stacks-node/src/nakamoto_node/miner.rs b/stacks-node/src/nakamoto_node/miner.rs index e915cf6630e..5899068bec5 100644 --- a/stacks-node/src/nakamoto_node/miner.rs +++ b/stacks-node/src/nakamoto_node/miner.rs @@ -1811,18 +1811,23 @@ impl BlockMinerThread { &replay_transactions, ) .map_err(|e| { - if !matches!( - e, - ChainstateError::MinerAborted | ChainstateError::NoTransactionsToMine - ) { - error!("Relayer: Failure mining anchored block: {e}"); + match e { + ChainstateError::NoTransactionsToMine => { + // The walk selected nothing, so the caches still reflect + // the chainstate as of the parent tip. Keeping them valid + // means an idle miner does not reset its caches on every + // empty-mempool retry. + self.mempool_caches_valid_for = Some(parent_tip.clone()); + } + ChainstateError::MinerAborted => {} + ref e => error!("Relayer: Failure mining anchored block: {e}"), } e })?; if block_metadata.block.tx_count() == 0 { - // The walk selected nothing, so the caches still reflect the - // chainstate as of the parent tip. + // Defensive check: an empty block exits above through the + // `NoTransactionsToMine` error, so this should be unreachable. self.mempool_caches_valid_for = Some(parent_tip); return Err(ChainstateError::NoTransactionsToMine.into()); }