diff --git a/crates/consensus/primary/src/consensus/tests/bullshark_tests.rs b/crates/consensus/primary/src/consensus/tests/bullshark_tests.rs index a41cdf5..e47c44b 100644 --- a/crates/consensus/primary/src/consensus/tests/bullshark_tests.rs +++ b/crates/consensus/primary/src/consensus/tests/bullshark_tests.rs @@ -442,7 +442,7 @@ async fn commit_one() { Consensus::spawn(config, &cb, bullshark, &task_manager); let cb_clone = cb.clone(); let dummy_parent = SealedHeader::new(ExecHeader::default(), B256::default()); - cb.recent_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); + cb.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); tokio::spawn(async move { let mut rx_primary = cb_clone.committed_certificates().subscribe(); while rx_primary.recv().await.is_some() {} @@ -501,7 +501,7 @@ async fn dead_node() { let cb = ConsensusBus::new(); let dummy_parent = SealedHeader::new(ExecHeader::default(), B256::default()); - cb.recent_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); + cb.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); let mut rx_output = cb.sequence().subscribe(); let task_manager = TaskManager::default(); Consensus::spawn(config, &cb, bullshark, &task_manager); @@ -635,7 +635,7 @@ async fn not_enough_support() { let cb = ConsensusBus::new(); let dummy_parent = SealedHeader::new(ExecHeader::default(), B256::default()); - cb.recent_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); + cb.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); let mut rx_output = cb.sequence().subscribe(); let task_manager = TaskManager::default(); Consensus::spawn(config, &cb, bullshark, &task_manager); @@ -735,7 +735,7 @@ async fn missing_leader() { let cb = ConsensusBus::new(); let dummy_parent = SealedHeader::new(ExecHeader::default(), B256::default()); - cb.recent_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); + cb.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); let mut rx_output = cb.sequence().subscribe(); let task_manager = TaskManager::default(); Consensus::spawn(config, &cb, bullshark, &task_manager); @@ -803,7 +803,7 @@ async fn committed_round_after_restart() { let cb = ConsensusBus::new(); let dummy_parent = SealedHeader::new(ExecHeader::default(), B256::default()); - cb.recent_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); + cb.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); let mut rx_primary = cb.committed_certificates().subscribe(); let mut rx_output = cb.sequence().subscribe(); let mut task_manager = TaskManager::default(); @@ -1042,7 +1042,7 @@ async fn restart_with_new_committee() { let cb = ConsensusBus::new(); let dummy_parent = SealedHeader::new(ExecHeader::default(), B256::default()); - cb.recent_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); + cb.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); let mut rx_output = cb.sequence().subscribe(); let mut task_manager = TaskManager::default(); Consensus::spawn(config.clone(), &cb, bullshark, &task_manager); diff --git a/crates/consensus/primary/src/consensus/tests/consensus_tests.rs b/crates/consensus/primary/src/consensus/tests/consensus_tests.rs index 3d4d1a4..7e6b834 100644 --- a/crates/consensus/primary/src/consensus/tests/consensus_tests.rs +++ b/crates/consensus/primary/src/consensus/tests/consensus_tests.rs @@ -63,7 +63,7 @@ async fn test_consensus_recovery_with_bullshark() { let cb = ConsensusBus::new(); let dummy_parent = SealedHeader::new(ExecHeader::default(), B256::default()); - cb.recent_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); + cb.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); let mut rx_output = cb.sequence().subscribe(); let task_manager = TaskManager::default(); Consensus::spawn(config.clone(), &cb, bullshark, &task_manager); @@ -148,7 +148,7 @@ async fn test_consensus_recovery_with_bullshark() { let cb = ConsensusBus::new(); let dummy_parent = SealedHeader::new(ExecHeader::default(), B256::default()); - cb.recent_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); + cb.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); let mut rx_output = cb.sequence().subscribe(); let task_manager = TaskManager::default(); Consensus::spawn(config.clone(), &cb, bullshark, &task_manager); @@ -206,7 +206,7 @@ async fn test_consensus_recovery_with_bullshark() { let cb = ConsensusBus::new(); let dummy_parent = SealedHeader::new(ExecHeader::default(), B256::default()); - cb.recent_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); + cb.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); let mut rx_output = cb.sequence().subscribe(); let task_manager = TaskManager::default(); Consensus::spawn(config, &cb, bullshark, &task_manager); @@ -311,7 +311,7 @@ async fn single_validator_commits_a_subdag() { let cb = ConsensusBus::new(); let dummy_parent = SealedHeader::new(ExecHeader::default(), B256::default()); - cb.recent_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); + cb.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); let mut rx_output = cb.sequence().subscribe(); let task_manager = TaskManager::default(); Consensus::spawn(config.clone(), &cb, bullshark, &task_manager); diff --git a/crates/consensus/primary/src/consensus_bus.rs b/crates/consensus/primary/src/consensus_bus.rs index d0c6dff..c8d4a5d 100644 --- a/crates/consensus/primary/src/consensus_bus.rs +++ b/crates/consensus/primary/src/consensus_bus.rs @@ -4,7 +4,7 @@ use crate::{ certificate_fetcher::CertificateFetcherCommand, consensus::ConsensusRound, - proposer::OurDigestMessage, state_sync::CertificateManagerCommand, RecentBlocks, + proposer::OurDigestMessage, state_sync::CertificateManagerCommand, RecentlyExecutedBlocks, }; use consensus_metrics::metered_channel::{self, channel_with_total_sender, MeteredMpscChannel}; use parking_lot::Mutex; @@ -195,12 +195,13 @@ struct ConsensusBusAppInner { /// Signals a new round tx_primary_round_updates: watch::Sender, - /// Watch tracking most recent blocks - tx_recent_blocks: watch::Sender, + /// Watch tracking the most recently executed blocks + tx_recently_executed_blocks: watch::Sender, /// The EVM-execution anchor: the consensus header the highest executed block commits to. /// Single source of truth for the restart/replay watermark, seeded once at boot from the - /// chain and advanced live by the engine, so consumers never re-derive it from recent_blocks. + /// chain and advanced live by the engine, so consumers never re-derive it from + /// recently_executed_blocks. tx_executed_anchor: watch::Sender, /// True when the node-scoped engine has executed everything it admitted (its queue is empty @@ -238,7 +239,8 @@ struct ConsensusBusAppInner { executor_metrics: Arc, /// Signal that the subscriber has finished replaying missed consensus outputs on startup. /// The proposer waits for this before creating headers, preventing stale exec_digest - /// from recent_blocks that was populated from MDBX before execution replay completed. + /// from recently_executed_blocks that was populated from MDBX before execution replay + /// completed. tx_execution_replay_complete: watch::Sender, /// Batch lifecycle tracker (app-lifetime, survives epoch transitions). batch_tracker: Arc, @@ -254,7 +256,7 @@ struct ConsensusBusAppInner { } impl ConsensusBusAppInner { - fn new(initial_mode: NodeMode, recent_blocks: u32) -> Self { + fn new(initial_mode: NodeMode, recently_executed_blocks: u32) -> Self { let network_metrics = Arc::new(NetworkMetrics::default()); let consensus_metrics = Arc::new(ConsensusMetrics::default()); let primary_metrics = Arc::new(Metrics::default()); // Initialize the metrics @@ -266,7 +268,8 @@ impl ConsensusBusAppInner { let (tx_primary_round_updates, _) = watch::channel(0u32); let (tx_last_consensus_header, _) = watch::channel(ConsensusHeader::default()); let (tx_last_published_consensus_num_hash, _) = watch::channel((0, BlockHash::default())); - let (tx_recent_blocks, _) = watch::channel(RecentBlocks::new(recent_blocks as usize)); + let (tx_recently_executed_blocks, _) = + watch::channel(RecentlyExecutedBlocks::new(recently_executed_blocks as usize)); let (tx_executed_anchor, _) = watch::channel(ConsensusHeader::default()); let (tx_engine_idle, _) = watch::channel(false); let (tx_execution_replay_complete, _) = watch::channel(false); @@ -285,7 +288,7 @@ impl ConsensusBusAppInner { tx_gc_round_updates, tx_requested_missing_epoch, tx_primary_round_updates, - tx_recent_blocks, + tx_recently_executed_blocks, tx_executed_anchor, tx_engine_idle, tx_last_consensus_header, @@ -319,12 +322,13 @@ impl ConsensusBusAppInner { // NOTE: promotion_barrier is intentionally NOT reset here. It is node-lifetime intent // that must survive the same-epoch mode-change restart the demotion itself triggers; it // self-invalidates via its epoch tag and is cleared lazily in try_rejoin_consensus. - let recent_blocks = self.tx_recent_blocks.borrow().block_capacity(); + let recently_executed_blocks = self.tx_recently_executed_blocks.borrow().block_capacity(); // Hang onto the last block of the previous epoch, clear the rest. - let latest = self.tx_recent_blocks.borrow().latest_block(); - let mut recent_blocks = RecentBlocks::new(recent_blocks as usize); - recent_blocks.push_latest(latest); - self.tx_recent_blocks.send_replace(recent_blocks); + let latest = self.tx_recently_executed_blocks.borrow().latest_block(); + let mut recently_executed_blocks = + RecentlyExecutedBlocks::new(recently_executed_blocks as usize); + recently_executed_blocks.push_latest(latest.into_header()); + self.tx_recently_executed_blocks.send_replace(recently_executed_blocks); } } @@ -478,8 +482,8 @@ impl ConsensusBus { } /// Create a bus with an explicit initial mode. - pub fn new_with_args(initial_mode: NodeMode, recent_blocks: u32) -> Self { - let inner_app = Arc::new(ConsensusBusAppInner::new(initial_mode, recent_blocks)); + pub fn new_with_args(initial_mode: NodeMode, recently_executed_blocks: u32) -> Self { + let inner_app = Arc::new(ConsensusBusAppInner::new(initial_mode, recently_executed_blocks)); let inner_epoch = Arc::new(ConsensusBusEpochInner::new(&inner_app)); Self { inner_app, inner_epoch } } @@ -619,17 +623,18 @@ impl ConsensusBus { /// /// For the frontier epoch/round read the monotonic [`Self::executed_anchor`] instead, or scan /// this window for the max-nonce block. - pub fn recent_blocks(&self) -> &watch::Sender { - &self.inner_app.tx_recent_blocks + pub fn recently_executed_blocks(&self) -> &watch::Sender { + &self.inner_app.tx_recently_executed_blocks } /// The EVM-execution anchor: the consensus header the highest executed block commits to. /// /// Single source of truth for the restart/replay watermark. Seeded once at boot from the /// chain (highest-nonce recent block) and advanced live by the engine on each executed output; - /// replay/catch-up consumers read this instead of re-deriving it from `recent_blocks` (whose - /// tip can lag behind after a drained parked batch). Distinct from the peer-derived - /// [`Self::last_consensus_header`], which is for header numbering only. + /// replay/catch-up consumers read this instead of re-deriving it from + /// `recently_executed_blocks` (whose tip can lag behind after a drained parked batch). + /// Distinct from the peer-derived [`Self::last_consensus_header`], which is for header + /// numbering only. pub fn executed_anchor(&self) -> &watch::Sender { &self.inner_app.tx_executed_anchor } @@ -782,7 +787,8 @@ impl ConsensusBus { /// Resolves once the target block has executed, or returns a [`WaitForExecutionError`]. /// /// The unbounded form returns `Forked` on a divergent hash at the target number or `Closed` if - /// `recent_blocks` closes; never `Stalled` (use [`Self::wait_for_execution_bounded`] for that). + /// `recently_executed_blocks` closes; never `Stalled` (use [`Self::wait_for_execution_bounded`] + /// for that). pub async fn wait_for_execution( &self, block: BlockNumHash, @@ -807,26 +813,28 @@ impl ConsensusBus { block: BlockNumHash, idle_timeout: Option, ) -> Result<(), WaitForExecutionError> { - let mut watch_execution_result = self.recent_blocks().subscribe(); + let mut watch_execution_result = self.recently_executed_blocks().subscribe(); let target_number = block.number; let target_hash = block.hash; - // Make sure that our recent blocks is not empty. If it is we can have a race around block - // 0. - while self.recent_blocks().borrow().is_empty() { + // Make sure that our recently-executed window is not empty. If it is we can have a race + // around block 0. + while self.recently_executed_blocks().borrow().is_empty() { await_execution_tick(&mut watch_execution_result, idle_timeout).await?; } - let mut current_number = self.recent_blocks().borrow().latest_block_num_hash().number; + let mut current_number = + self.recently_executed_blocks().borrow().latest_block_num_hash().number; while current_number < target_number { await_execution_tick(&mut watch_execution_result, idle_timeout).await?; - current_number = self.recent_blocks().borrow().latest_block_num_hash().number; + current_number = + self.recently_executed_blocks().borrow().latest_block_num_hash().number; } - let recent = self.recent_blocks().borrow(); + let recent = self.recently_executed_blocks().borrow(); let oldest_number = recent.oldest_block_number(); let latest_number = recent.latest_block_num_hash().number; - // block older than recent_blocks window, assume already executed + // block older than recently_executed_blocks window, assume already executed if target_number < oldest_number { tracing::debug!( target: "consensus-bus", @@ -834,7 +842,7 @@ impl ConsensusBus { oldest_number, latest_number, ?target_hash, - "wait_for_execution: block older than recent_blocks window, assuming already executed" + "wait_for_execution: block older than recently_executed_blocks window, assuming already executed" ); return Ok(()); } @@ -854,7 +862,7 @@ impl ConsensusBus { latest_number, block_at_target_hash = ?block_at_target.map(|b| b.hash()), "wait_for_execution: HASH MISMATCH - potential fork detected! \ - Expected hash not found in recent_blocks at target number." + Expected hash not found in recently_executed_blocks at target number." ); Err(WaitForExecutionError::Forked) } @@ -865,12 +873,12 @@ impl ConsensusBus { /// a progressing chain resets it every block. pub const EXECUTION_STALL_TIMEOUT: Duration = Duration::from_secs(30); -/// Await the next `recent_blocks` change, optionally bounded by `idle_timeout`. +/// Await the next `recently_executed_blocks` change, optionally bounded by `idle_timeout`. /// /// Returns `Closed` if the receiver closes, or `Stalled` when `idle_timeout` is set and no change /// arrives within that window. async fn await_execution_tick( - rx: &mut watch::Receiver, + rx: &mut watch::Receiver, idle_timeout: Option, ) -> Result<(), WaitForExecutionError> { match idle_timeout { @@ -890,10 +898,10 @@ pub enum WaitForExecutionError { /// Execution made no progress within the bounded idle window (a stalled chain). #[error("execution made no progress within the idle timeout")] Stalled, - /// `recent_blocks` reported its sender closed. Defensive only: the sole sender is owned for - /// the app lifetime by the same `ConsensusBus` the wait borrows, so this is not expected - /// to fire. - #[error("recent_blocks closed before the target executed")] + /// `recently_executed_blocks` reported its sender closed. Defensive only: the sole sender is + /// owned for the app lifetime by the same `ConsensusBus` the wait borrows, so this is not + /// expected to fire. + #[error("recently_executed_blocks closed before the target executed")] Closed, /// Execution reached the target number but the block hash there differs: a fork. #[error("execution reached the target number with a divergent block hash")] diff --git a/crates/consensus/primary/src/lib.rs b/crates/consensus/primary/src/lib.rs index b8a6586..b1048ce 100644 --- a/crates/consensus/primary/src/lib.rs +++ b/crates/consensus/primary/src/lib.rs @@ -24,8 +24,8 @@ pub use crate::primary::Primary; mod consensus_bus; pub use consensus_bus::*; -mod recent_blocks; -pub use recent_blocks::*; +mod recently_executed_blocks; +pub use recently_executed_blocks::*; #[cfg(feature = "test-utils")] pub mod test_utils; diff --git a/crates/consensus/primary/src/network/handler.rs b/crates/consensus/primary/src/network/handler.rs index 8880a6e..3eb2697 100644 --- a/crates/consensus/primary/src/network/handler.rs +++ b/crates/consensus/primary/src/network/handler.rs @@ -11,7 +11,7 @@ use crate::{ state::{AuthVoteState, InFlightGuard, IN_FLIGHT_TTL}, }, state_sync::{CertificateCollector, StateSynchronizer}, - ConsensusBus, NodeMode, RecentBlocks, + ConsensusBus, NodeMode, RecentlyExecutedBlocks, }; use parking_lot::Mutex; use rayls_consensus_network::GossipMessage; @@ -518,8 +518,10 @@ where // one kept check is a non-blocking breaker: refuse to vote if our own executed block at the // anchor already diverges, so a quorum cannot certify a forked anchor. let target = header.latest_execution_block; - let anchor_forked = - anchor_diverges_from_executed(&self.consensus_bus.recent_blocks().borrow(), target); + let anchor_forked = anchor_diverges_from_executed( + &self.consensus_bus.recently_executed_blocks().borrow(), + target, + ); if anchor_forked { warn!( target: "primary::handler", @@ -949,7 +951,7 @@ fn collect_missing_certs_blocking( /// /// Evidence-only: a target the node has not executed returns false, so a behind node still votes; /// only a node whose own executed state contradicts the anchor rejects. -fn anchor_diverges_from_executed(recent: &RecentBlocks, target: BlockNumHash) -> bool { +fn anchor_diverges_from_executed(recent: &RecentlyExecutedBlocks, target: BlockNumHash) -> bool { recent.block_at_number(target.number).is_some_and(|block| block.hash() != target.hash) } @@ -994,7 +996,7 @@ fn evict_oldest_completed(cache: &mut AuthEquivocationMap, max_entries: usize) { #[cfg(test)] mod tests { use super::{anchor_diverges_from_executed, BehindTracker}; - use crate::RecentBlocks; + use crate::RecentlyExecutedBlocks; use rayls_infrastructure_types::{BlockNumHash, ExecHeader, SealedHeader, B256}; use std::time::Duration; @@ -1010,7 +1012,7 @@ mod tests { /// votes on data availability and structure rather than waiting for execution to catch up. #[test] fn anchor_divergence_is_evidence_only() { - let mut recent = RecentBlocks::new(8); + let mut recent = RecentlyExecutedBlocks::new(8); recent.push_latest(sealed_at(5)); // executed block 5, hash repeat_byte(5) // matching anchor at an executed height: no divergence, vote. diff --git a/crates/consensus/primary/src/proposer/header_builder.rs b/crates/consensus/primary/src/proposer/header_builder.rs index 3960343..d20ca70 100644 --- a/crates/consensus/primary/src/proposer/header_builder.rs +++ b/crates/consensus/primary/src/proposer/header_builder.rs @@ -77,7 +77,7 @@ impl Proposer { current_epoch, payload_vec.into_iter().collect(), parents.iter().map(|x| x.digest()).collect(), - consensus_bus.recent_blocks().borrow().latest_block_num_hash(), + consensus_bus.recently_executed_blocks().borrow().latest_block_num_hash(), ); // update metrics before sending/storing header diff --git a/crates/consensus/primary/src/proposer/run_loop.rs b/crates/consensus/primary/src/proposer/run_loop.rs index 81f4bb2..0a1c0ee 100644 --- a/crates/consensus/primary/src/proposer/run_loop.rs +++ b/crates/consensus/primary/src/proposer/run_loop.rs @@ -16,8 +16,9 @@ impl Proposer { /// `Some`. /// /// Reads the monotonic execution anchor - the leader round of the highest executed output - NOT - /// `recent_blocks().latest_block()`, whose tip regresses below the true frontier after a - /// drained parked (out-of-order seq) batch and would wedge the proposer permanently. + /// `recently_executed_blocks().latest_block()`, whose tip regresses below the true frontier + /// after a drained parked (out-of-order seq) batch and would wedge the proposer + /// permanently. pub(crate) fn execution_lag(&self) -> Option { let exec_round = self.consensus_bus.executed_anchor().borrow().sub_dag.leader_round() as u64; @@ -28,7 +29,7 @@ impl Proposer { /// Returns Ok on shutdown or an error to indicate a fatal condition. pub(super) async fn run(&mut self) -> ProposerResult<()> { // Wait for execution replay to complete before proposing headers. - // On restart, recent_blocks may be stale; replaying ensures we don't embed + // On restart, recently_executed_blocks may be stale; replaying ensures we don't embed // outdated exec_digest values that cause validator divergence. // Subscribe before borrowing to avoid TOCTOU deadlock where the signal is marked "seen" // before we check it. diff --git a/crates/consensus/primary/src/recent_blocks.rs b/crates/consensus/primary/src/recent_blocks.rs deleted file mode 100644 index 3ddbf6a..0000000 --- a/crates/consensus/primary/src/recent_blocks.rs +++ /dev/null @@ -1,90 +0,0 @@ -//! Track the most recent execution blocks for the consensus layer. - -use rayls_infrastructure_types::{BlockHash, BlockNumHash, SealedHeader}; -use std::collections::VecDeque; - -/// Tracks 'num_blocks' most recently executed block hashes and numbers. -#[derive(Clone, Debug)] -pub struct RecentBlocks { - num_blocks: usize, - blocks: VecDeque, -} - -impl RecentBlocks { - /// Create a RecentBlocks that will hold 'num_blocks' most recent blocks. - pub fn new(num_blocks: usize) -> Self { - Self { num_blocks, blocks: VecDeque::new() } - } - - /// Max number of blocks that can be held in RecentBlocks. - pub fn block_capacity(&self) -> u64 { - self.num_blocks as u64 - } - - /// Push the latest block onto RecentBlocks, will remove the oldest if needed to make room. - pub fn push_latest(&mut self, latest: SealedHeader) { - if self.blocks.len() >= self.num_blocks { - self.blocks.pop_front(); - } - self.blocks.push_back(latest); - } - - /// Return the hash and number of the last executed block. - /// This will return a default BlockNumHash if recents blocks are empty. - /// This should only happen on node startup before any execution has taken - /// place. Most callsites will be fine with this, call is_empty() if this - /// matters to you. - pub fn latest_block_num_hash(&self) -> BlockNumHash { - self.blocks.back().cloned().unwrap_or_else(Default::default).num_hash() - } - - /// Return the number of the oldest block, or 0 if empty. - pub fn oldest_block_number(&self) -> u64 { - self.blocks.front().map(|h| h.number).unwrap_or(0) - } - - /// Return the most recently pushed (highest block-number) executed block. - /// - /// WARNING: the tip's block *number* is monotonic, but the nonce it carries - /// (`epoch << 32 | round`) is NOT - neither half. Draining a parked (out-of-order seq) batch - /// executes a block that belongs to an OLDER output yet lands here as the newest height, so the - /// tip's nonce reflects that origin output, not the frontier: its round can sit far below the - /// true frontier round, and - when the drained batch was carried over from a previous epoch - - /// its epoch can sit below the current epoch too. - /// - /// Example: execution has genuinely reached round 498. A batch for an earlier seq, mapping to - /// round 200, was parked; the gap then fills and it is drained and executed now. That fresh - /// block gets the next (highest) block number and becomes this tip, but its nonce encodes round - /// 200. A caller reading the round here sees 200, not 498. The proposer throttle did exactly - /// this: with consensus at round 500 it computed lag `500 - 200 = 300 > threshold` and - /// throttled forever, wedging proposals - when the real lag was `500 - 498 = 2`. The epoch half - /// regresses the same way: a batch drained from the previous epoch tags this tip with the old - /// epoch. - /// - /// So never derive the execution frontier's epoch or round from this tip. Use the monotonic - /// `executed_anchor` channel for the frontier, or scan the window for the max-nonce block when - /// you must work from recent blocks. - pub fn latest_block(&self) -> SealedHeader { - self.blocks.back().cloned().unwrap_or_else(Default::default) - } - - /// Is hash a recent block we have executed? - pub fn contains_hash(&self, hash: BlockHash) -> bool { - self.blocks.iter().any(|block| block.hash() == hash) - } - - /// Get the block at a specific block number, if it exists in recent blocks. - pub fn block_at_number(&self, number: u64) -> Option<&SealedHeader> { - self.blocks.iter().find(|block| block.number == number) - } - - /// Number of blocks actually stored. - pub fn len(&self) -> usize { - self.blocks.len() - } - - /// Do we have any blocks? - pub fn is_empty(&self) -> bool { - self.blocks.is_empty() - } -} diff --git a/crates/consensus/primary/src/recently_executed_blocks.rs b/crates/consensus/primary/src/recently_executed_blocks.rs new file mode 100644 index 0000000..8d1b5d2 --- /dev/null +++ b/crates/consensus/primary/src/recently_executed_blocks.rs @@ -0,0 +1,323 @@ +//! Track the most recent execution blocks for the consensus layer. + +use rayls_infrastructure_types::{ + nonce::unpack_nonce, BlockHash, BlockNumHash, SealedHeader, B256, +}; +use std::{collections::VecDeque, fmt}; + +/// The epoch of the committed sub-DAG leader that ordered a [`RecentlyExecutedBlock`]. +/// +/// A distinct newtype — deliberately NOT the bare `Epoch`/`u32` alias — so the compiler rejects +/// mixing dimensions: you cannot compare a `SubDagLeaderEpoch` against a frontier epoch or against +/// a [`SubDagLeaderRound`] without explicitly unwrapping via [`get`](Self::get). Only this module +/// mints one (from a block nonce), so a value of this type always means "the leader epoch this +/// block was ordered under", never the frontier. +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SubDagLeaderEpoch(u32); + +/// The round of the committed sub-DAG leader that ordered a [`RecentlyExecutedBlock`]. +/// +/// A distinct newtype — deliberately NOT the bare `Round`/`u32` alias — so the compiler rejects +/// comparing it against a frontier round (the exact mistake that once wedged the proposer throttle: +/// `frontier_round - tip_round` where the tip carried an old leader round) or against a +/// [`SubDagLeaderEpoch`]. Unwrap via [`get`](Self::get) only when you genuinely need the raw value. +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SubDagLeaderRound(u32); + +impl SubDagLeaderEpoch { + /// The raw epoch value. Prefer keeping the newtype; unwrap only at a genuine boundary. + pub fn get(self) -> u32 { + self.0 + } +} + +impl SubDagLeaderRound { + /// The raw round value. Prefer keeping the newtype; unwrap only at a genuine boundary. + pub fn get(self) -> u32 { + self.0 + } +} + +impl fmt::Display for SubDagLeaderEpoch { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl fmt::Display for SubDagLeaderRound { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +/// The digest of the committed sub-DAG's `ConsensusHeader` that a [`RecentlyExecutedBlock`] was +/// ordered under (carried on the block as its `parent_beacon_block_root`). +/// +/// A distinct newtype — deliberately NOT a bare `B256` — because this carries the same provenance +/// hazard as [`SubDagLeaderRound`]: a block drained from an OLD parked output lands as the tip yet +/// commits to that old output's consensus header, so comparing this digest against the *frontier* +/// consensus tip (`some_digest == frontier_digest`) would falsely conclude "the tip is caught up". +/// +/// It intentionally does NOT derive `PartialEq`/`Eq`: a frontier digest is a bare `B256`, so that +/// comparison already fails to compile, and there is no legitimate reason to equality-check two of +/// these against each other either. Use [`get`](Self::get) only to feed a genuine lookup (e.g. +/// `get_consensus_by_hash`); if you find yourself unwrapping to compare, that is the bug this type +/// exists to surface. +#[derive(Copy, Clone, Debug)] +pub struct SubDagConsensusDigest(B256); + +impl SubDagConsensusDigest { + /// The raw digest. Unwrap only to look the header up, never to compare against a frontier + /// digest. + pub fn get(self) -> B256 { + self.0 + } +} + +impl fmt::Display for SubDagConsensusDigest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +/// A block returned from [`RecentlyExecutedBlocks`] (e.g. by +/// [`RecentlyExecutedBlocks::latest_block`]). +/// +/// This wraps the underlying [`SealedHeader`] for one reason: the epoch and round packed into a +/// block's nonce are the epoch and round of the committed sub-DAG **leader** that ordered it +/// (`ConsensusOutput::nonce()` == `sub_dag.leader.nonce()`), NOT the execution frontier. Draining +/// a parked (out-of-order seq) batch executes a block belonging to an OLDER output yet lands it as +/// the newest height, so the tip's nonce can encode a leader epoch/round far below the true +/// frontier. See the warning on [`RecentlyExecutedBlocks::latest_block`] for the concrete wedge +/// this caused. +/// +/// To make that impossible to stumble into, the provenance accessors are named +/// [`subdag_leader_epoch`](Self::subdag_leader_epoch) / +/// [`subdag_leader_round`](Self::subdag_leader_round) / +/// [`subdag_consensus_digest`](Self::subdag_consensus_digest) rather than the tempting `epoch()` / +/// `round()` / raw beacon root, and each returns a distinct newtype so the compiler rejects +/// comparing it to a frontier value. There is deliberately NO `Deref` to [`SealedHeader`]: safe +/// fields are delegated explicitly ([`number`](Self::number), [`hash`](Self::hash), +/// [`num_hash`](Self::num_hash)), and raw header access is only via [`as_header`](Self::as_header) +/// / [`into_header`](Self::into_header) — a deliberate, greppable exit from the guard (e.g. to +/// carry the header over across an epoch reset). +#[derive(Clone, Debug)] +pub struct RecentlyExecutedBlock(SealedHeader); + +impl RecentlyExecutedBlock { + /// The epoch of the committed sub-DAG leader that ordered this block, decoded from the block + /// nonce (`epoch << 32 | round`). Equals the committing `ConsensusOutput::leader_epoch()`. + /// + /// This is NOT the execution frontier epoch: a batch drained from a previous epoch tags this + /// block with that leader's old epoch even though execution has moved on. Never use this as + /// "the current epoch". + pub fn subdag_leader_epoch(&self) -> SubDagLeaderEpoch { + SubDagLeaderEpoch(unpack_nonce(u64::from(self.0.nonce)).0) + } + + /// The round of the committed sub-DAG leader that ordered this block, decoded from the block + /// nonce (`epoch << 32 | round`). Equals the committing `ConsensusOutput::leader_round()`. + /// + /// This is NOT the execution frontier round: a drained parked batch can carry a leader round + /// far below the true frontier (this is exactly what wedged the proposer throttle — see + /// [`RecentlyExecutedBlocks::latest_block`]). Never derive the frontier round from this. + pub fn subdag_leader_round(&self) -> SubDagLeaderRound { + SubDagLeaderRound(unpack_nonce(u64::from(self.0.nonce)).1) + } + + /// The block number (height). Monotonic — always the true tip, safe to compare. + pub fn number(&self) -> u64 { + self.0.number + } + + /// The block hash (block identity — equality checks are legitimate fork detection). + pub fn hash(&self) -> BlockHash { + self.0.hash() + } + + /// The block number and hash together. + pub fn num_hash(&self) -> BlockNumHash { + self.0.num_hash() + } + + /// The consensus-header digest this block was ordered under (its `parent_beacon_block_root`), + /// wrapped in [`SubDagConsensusDigest`] so it can't be silently compared to a frontier digest. + /// `None` for a genesis/unset root. + pub fn subdag_consensus_digest(&self) -> Option { + self.0.parent_beacon_block_root.map(SubDagConsensusDigest) + } + + /// Consume the wrapper and return the owned underlying [`SealedHeader`]. + /// + /// This is a deliberate exit from the guard: the raw header exposes `.nonce` and + /// `.parent_beacon_block_root` as bare values, from which the leader epoch/round/consensus + /// digest can be read and compared against a frontier value by mistake. Reach for it only when + /// you genuinely need the whole header (e.g. to carry it over across an epoch reset), not to + /// read provenance — use [`subdag_leader_epoch`], [`subdag_leader_round`], or + /// [`subdag_consensus_digest`] for those. + /// + /// [`subdag_leader_epoch`]: Self::subdag_leader_epoch + /// [`subdag_leader_round`]: Self::subdag_leader_round + /// [`subdag_consensus_digest`]: Self::subdag_consensus_digest + pub fn into_header(self) -> SealedHeader { + self.0 + } + + /// Borrow the underlying [`SealedHeader`]. Same caveat as [`into_header`](Self::into_header): + /// a deliberate exit from the guard, not the way to read epoch/round/consensus digest. + pub fn as_header(&self) -> &SealedHeader { + &self.0 + } +} + +/// Tracks 'num_blocks' most recently executed block hashes and numbers. +#[derive(Clone, Debug)] +pub struct RecentlyExecutedBlocks { + num_blocks: usize, + blocks: VecDeque, +} + +impl RecentlyExecutedBlocks { + /// Create a RecentlyExecutedBlocks that will hold the 'num_blocks' most recently executed + /// blocks. + pub fn new(num_blocks: usize) -> Self { + Self { num_blocks, blocks: VecDeque::new() } + } + + /// Max number of blocks that can be held in RecentlyExecutedBlocks. + pub fn block_capacity(&self) -> u64 { + self.num_blocks as u64 + } + + /// Push the latest block onto RecentlyExecutedBlocks, will remove the oldest if needed to make + /// room. + pub fn push_latest(&mut self, latest: SealedHeader) { + if self.blocks.len() >= self.num_blocks { + self.blocks.pop_front(); + } + self.blocks.push_back(latest); + } + + /// Return the hash and number of the last executed block. + /// This will return a default BlockNumHash if recents blocks are empty. + /// This should only happen on node startup before any execution has taken + /// place. Most callsites will be fine with this, call is_empty() if this + /// matters to you. + pub fn latest_block_num_hash(&self) -> BlockNumHash { + self.blocks.back().cloned().unwrap_or_else(Default::default).num_hash() + } + + /// Return the number of the oldest block, or 0 if empty. + pub fn oldest_block_number(&self) -> u64 { + self.blocks.front().map(|h| h.number).unwrap_or(0) + } + + /// Return the most recently pushed (highest block-number) executed block. + /// + /// WARNING: the tip's block *number* is monotonic, but the nonce it carries + /// (`epoch << 32 | round`) is NOT - neither half. Draining a parked (out-of-order seq) batch + /// executes a block that belongs to an OLDER output yet lands here as the newest height, so the + /// tip's nonce reflects that origin output, not the frontier: its round can sit far below the + /// true frontier round, and - when the drained batch was carried over from a previous epoch - + /// its epoch can sit below the current epoch too. + /// + /// Example: execution has genuinely reached round 498. A batch for an earlier seq, mapping to + /// round 200, was parked; the gap then fills and it is drained and executed now. That fresh + /// block gets the next (highest) block number and becomes this tip, but its nonce encodes round + /// 200. A caller reading the round here sees 200, not 498. The proposer throttle did exactly + /// this: with consensus at round 500 it computed lag `500 - 200 = 300 > threshold` and + /// throttled forever, wedging proposals - when the real lag was `500 - 498 = 2`. The epoch half + /// regresses the same way: a batch drained from the previous epoch tags this tip with the old + /// epoch. + /// + /// So never derive the execution frontier's epoch or round from this tip. Use the monotonic + /// `executed_anchor` channel for the frontier, or scan the window for the max-nonce block when + /// you must work from this window. The return type is [`RecentlyExecutedBlock`], whose only + /// epoch/round accessors are + /// [`subdag_leader_epoch`](RecentlyExecutedBlock::subdag_leader_epoch) / + /// [`subdag_leader_round`](RecentlyExecutedBlock::subdag_leader_round) precisely so this trap + /// is named at the callsite rather than silently reachable via `.epoch()` / `.round()`. + /// + /// On an empty window this returns a `RecentlyExecutedBlock` wrapping a default `SealedHeader` + /// (number 0, zero hash, nonce 0) rather than `None` — same convention as + /// [`latest_block_num_hash`](Self::latest_block_num_hash). Call [`is_empty`](Self::is_empty) + /// first if a synthetic zero block would be mistaken for real execution history. + pub fn latest_block(&self) -> RecentlyExecutedBlock { + RecentlyExecutedBlock(self.blocks.back().cloned().unwrap_or_else(Default::default)) + } + + /// Is hash a block we have recently executed? + pub fn contains_hash(&self, hash: BlockHash) -> bool { + self.blocks.iter().any(|block| block.hash() == hash) + } + + /// Get the block at a specific block number, if it exists in the recently-executed window. + /// + /// Returns a [`RecentlyExecutedBlock`] for the same reason [`latest_block`](Self::latest_block) + /// does: a block found by number can still be one drained from a parked batch, so its + /// epoch/round are creation-time values, not the frontier. The guarded accessors keep that + /// from being read by accident. + pub fn block_at_number(&self, number: u64) -> Option { + self.blocks.iter().find(|block| block.number == number).cloned().map(RecentlyExecutedBlock) + } + + /// Number of blocks actually stored. + pub fn len(&self) -> usize { + self.blocks.len() + } + + /// Do we have any blocks? + pub fn is_empty(&self) -> bool { + self.blocks.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rayls_infrastructure_types::{nonce::pack_nonce, ExecHeader}; + + fn header_with_nonce(number: u64, epoch: u32, round: u32) -> SealedHeader { + let header = + ExecHeader { number, nonce: pack_nonce(epoch, round).into(), ..Default::default() }; + SealedHeader::seal_slow(header) + } + + /// The full chain the prod bug lived on: push a block whose nonce encodes a specific + /// (epoch, round), then read it back off the tip via the guarded accessors. + #[test] + fn subdag_leader_fields_decoded_from_tip_nonce() { + let mut blocks = RecentlyExecutedBlocks::new(10); + blocks.push_latest(header_with_nonce(42, 7, 498)); + + let tip = blocks.latest_block(); + assert_eq!(tip.subdag_leader_epoch().get(), 7); + assert_eq!(tip.subdag_leader_round().get(), 498); + assert_eq!(tip.number(), 42); + } + + /// The tip's leader round is NOT the frontier: a later-arriving block from an OLDER output + /// (lower nonce round) becomes the tip by height and regresses the round read off it. + #[test] + fn drained_old_batch_regresses_tip_leader_round() { + let mut blocks = RecentlyExecutedBlocks::new(10); + blocks.push_latest(header_with_nonce(100, 3, 498)); // frontier round 498 + blocks.push_latest(header_with_nonce(101, 3, 200)); // drained parked batch, round 200 + + let tip = blocks.latest_block(); + assert_eq!(tip.number(), 101, "tip is the newest height"); + assert_eq!(tip.subdag_leader_round().get(), 200, "…but its leader round regressed"); + } + + /// `block_at_number` yields the guarded wrapper for the same reason. + #[test] + fn block_at_number_exposes_guarded_accessors() { + let mut blocks = RecentlyExecutedBlocks::new(10); + blocks.push_latest(header_with_nonce(7, 2, 9)); + + let found = blocks.block_at_number(7).expect("block present"); + assert_eq!(found.subdag_leader_epoch().get(), 2); + assert_eq!(found.subdag_leader_round().get(), 9); + assert!(blocks.block_at_number(999).is_none()); + } +} diff --git a/crates/consensus/primary/src/tests/network_tests.rs b/crates/consensus/primary/src/tests/network_tests.rs index 8e0daaf..d705d3d 100644 --- a/crates/consensus/primary/src/tests/network_tests.rs +++ b/crates/consensus/primary/src/tests/network_tests.rs @@ -78,7 +78,7 @@ fn create_test_types() -> TestTypes { let parent = SealedHeader::seal_slow(ExecHeader::default()); // set the latest execution result to genesis - test headers are proposed for round 1 - cb.recent_blocks().send_modify(|blocks| blocks.push_latest(parent.clone())); + cb.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(parent.clone())); let handler = RequestHandler::new(config.clone(), cb.clone(), synchronizer); TestTypes { committee, handler, parent, task_manager, consensus_bus: cb } diff --git a/crates/consensus/primary/src/tests/primary_tests.rs b/crates/consensus/primary/src/tests/primary_tests.rs index 15dd56c..9d47b71 100644 --- a/crates/consensus/primary/src/tests/primary_tests.rs +++ b/crates/consensus/primary/src/tests/primary_tests.rs @@ -39,7 +39,7 @@ async fn test_request_vote_too_new() { // Need a dummy parent so we can request a vote. let dummy_parent = SealedHeader::seal_slow(ExecHeader::default()); let dummy_parent_num_hash = dummy_parent.num_hash(); - cb.recent_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); + cb.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); let task_manager = TaskManager::default(); let synchronizer = StateSynchronizer::new(target.consensus_config(), cb.clone(), task_manager.get_spawner()); @@ -97,7 +97,7 @@ async fn test_request_vote_has_missing_execution_block() { let cb = ConsensusBus::new(); // Need a dummy parent so we can request a vote. let dummy_parent = SealedHeader::seal_slow(ExecHeader::default()); - cb.recent_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); + cb.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); let task_manager = TaskManager::default(); let synchronizer = StateSynchronizer::new(target.consensus_config(), cb.clone(), task_manager.get_spawner()); @@ -162,12 +162,14 @@ async fn test_request_vote_older_execution_block() { let dummy_parent = SealedHeader::seal_slow(ExecHeader::default()); let dummy_hash = dummy_parent.hash(); // This will be an "older" execution block, test this still works. - cb.recent_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); + cb.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); let mut dummy = ExecHeader { nonce: 110_u64.into(), ..Default::default() }; dummy.nonce = 110_u64.into(); - cb.recent_blocks().send_modify(|blocks| blocks.push_latest(SealedHeader::seal_slow(dummy))); + cb.recently_executed_blocks() + .send_modify(|blocks| blocks.push_latest(SealedHeader::seal_slow(dummy))); dummy = ExecHeader { nonce: 120_u64.into(), ..Default::default() }; - cb.recent_blocks().send_modify(|blocks| blocks.push_latest(SealedHeader::seal_slow(dummy))); + cb.recently_executed_blocks() + .send_modify(|blocks| blocks.push_latest(SealedHeader::seal_slow(dummy))); let task_manager = TaskManager::default(); let synchronizer = StateSynchronizer::new(target.consensus_config(), cb.clone(), task_manager.get_spawner()); @@ -232,7 +234,7 @@ async fn test_request_vote_has_missing_parents() { // Need a dummy parent so we can request a vote. let dummy_parent = SealedHeader::seal_slow(ExecHeader::default()); let dummy_hash = dummy_parent.hash(); - cb.recent_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); + cb.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); let task_manager = TaskManager::default(); let synchronizer = StateSynchronizer::new(target.consensus_config(), cb.clone(), task_manager.get_spawner()); @@ -323,7 +325,7 @@ async fn test_request_vote_accept_missing_parents() { // Need a dummy parent so we can request a vote. let dummy_parent = SealedHeader::seal_slow(ExecHeader::default()); let dummy_hash = dummy_parent.hash(); - cb.recent_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); + cb.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); let task_manager = TaskManager::default(); let synchronizer = StateSynchronizer::new(target.consensus_config(), cb.clone(), task_manager.get_spawner()); @@ -415,7 +417,7 @@ async fn test_request_vote_missing_batches() { // Need a dummy parent so we can request a vote. let dummy_parent = SealedHeader::seal_slow(ExecHeader::default()); let dummy_hash = dummy_parent.hash(); - cb.recent_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); + cb.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); let task_manager = TaskManager::default(); let synchronizer = StateSynchronizer::new(primary.consensus_config(), cb.clone(), task_manager.get_spawner()); @@ -478,7 +480,7 @@ async fn test_request_vote_missing_batches_withheld_payload_does_not_vote() { // Need a dummy parent so we can request a vote. let dummy_parent = SealedHeader::seal_slow(ExecHeader::default()); let dummy_hash = dummy_parent.hash(); - cb.recent_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); + cb.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); let task_manager = TaskManager::default(); let synchronizer = StateSynchronizer::new(primary.consensus_config(), cb.clone(), task_manager.get_spawner()); @@ -555,7 +557,7 @@ async fn test_request_vote_already_voted() { // Need a dummy parent so we can request a vote. let dummy_parent = SealedHeader::seal_slow(ExecHeader::default()); let dummy_hash = dummy_parent.hash(); - cb.recent_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); + cb.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); let task_manager = TaskManager::default(); let synchronizer = StateSynchronizer::new(primary.consensus_config(), cb.clone(), task_manager.get_spawner()); @@ -770,7 +772,7 @@ async fn test_request_vote_created_at_in_future() { // Need a dummy parent so we can request a vote. let dummy_parent = SealedHeader::seal_slow(ExecHeader::default()); let dummy_hash = dummy_parent.hash(); - cb.recent_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); + cb.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); let task_manager = TaskManager::default(); let synchronizer = StateSynchronizer::new(primary.consensus_config(), cb.clone(), task_manager.get_spawner()); diff --git a/crates/consensus/primary/src/tests/proposer_tests.rs b/crates/consensus/primary/src/tests/proposer_tests.rs index d8c8cdb..3881b66 100644 --- a/crates/consensus/primary/src/tests/proposer_tests.rs +++ b/crates/consensus/primary/src/tests/proposer_tests.rs @@ -51,16 +51,17 @@ fn anchor_at_round(round: u32) -> ConsensusHeader { ConsensusHeader { parent_hash: B256::default(), sub_dag, number: 1, extra: B256::default() } } -/// Build a `recent_blocks` tip whose nonce encodes `round` (the EVM nonce packs `epoch << 32 | -/// round`). +/// Build a `recently_executed_blocks` tip whose nonce encodes `round` (the EVM nonce packs `epoch +/// << 32 | round`). fn tip_at_round(round: u32) -> SealedHeader { let exec_header = ExecHeader { nonce: (round as u64).into(), ..Default::default() }; SealedHeader::new(exec_header, B256::default()) } /// Regression: the execution-lag throttle reads the monotonic execution anchor, NOT the -/// `recent_blocks` tip. A drained parked batch regresses the tip's round far below the true -/// execution frontier; reading it would compute a huge lag and wedge proposals forever (the halt). +/// `recently_executed_blocks` tip. A drained parked batch regresses the tip's round far below the +/// true execution frontier; reading it would compute a huge lag and wedge proposals forever (the +/// halt). #[tokio::test] async fn execution_lag_reads_anchor_not_regressed_tip() { let fixture = CommitteeFixture::builder(MemDatabase::default).build(); @@ -79,9 +80,9 @@ async fn execution_lag_reads_anchor_not_regressed_tip() { TaskManager::default().get_spawner(), ); - // Drained-parked-batch regression: the recent_blocks tip carries a stale, low round (200) while - // execution has actually reached the frontier (498). - cb.recent_blocks().send_modify(|b| b.push_latest(tip_at_round(200))); + // Drained-parked-batch regression: the recently_executed_blocks tip carries a stale, low round + // (200) while execution has actually reached the frontier (498). + cb.recently_executed_blocks().send_modify(|b| b.push_latest(tip_at_round(200))); cb.executed_anchor().send_replace(anchor_at_round(498)); // Consensus round 500, anchor 498 -> lag 2 (< 100): the proposer MUST NOT throttle. Reading the diff --git a/crates/consensus/state-sync/src/lib.rs b/crates/consensus/state-sync/src/lib.rs index a744aa8..a8c0ae7 100644 --- a/crates/consensus/state-sync/src/lib.rs +++ b/crates/consensus/state-sync/src/lib.rs @@ -290,8 +290,8 @@ pub fn consensus_chain_tip(config: &ConsensusConfig) -> Option /// Resolve the consensus header an executed EL block anchors to, via its /// `parent_beacon_block_root`. Returns `None` for a genesis/zero anchor or an absent header. /// -/// Use at startup, before `recent_blocks` is primed, when the EL canonical tip is the only -/// source of the last executed consensus header. +/// Use at startup, before `recently_executed_blocks` is primed, when the EL canonical tip is the +/// only source of the last executed consensus header. pub fn last_executed_consensus_from_anchor( parent_beacon_block_root: Option, db: &DB, @@ -449,7 +449,7 @@ fn collect_replayable_headers( /// The manager's `recover_partial_transition()` runs BEFORE this function is called. /// That method checks for incomplete epoch transitions (via a DB checkpoint) and either /// completes the remaining phases or clears a stale checkpoint. By the time this -/// function executes, the checkpoint system guarantees that `recent_blocks` accurately +/// function executes, the checkpoint system guarantees that `recently_executed_blocks` accurately /// reflects the last executed block. /// /// If a checkpoint still exists when this function runs, it indicates an unexpected @@ -746,7 +746,8 @@ async fn catch_up_consensus_from_to( let base_execution_block = consensus_header.sub_dag.leader.header().latest_execution_block; if streamed.is_multiple_of(100) { - let recent_latest = consensus_bus.recent_blocks().borrow().latest_block_num_hash(); + let recent_latest = + consensus_bus.recently_executed_blocks().borrow().latest_block_num_hash(); info!( target: "rayls-consensus-state-sync", consensus_number = number, @@ -765,7 +766,7 @@ async fn catch_up_consensus_from_to( { // A genuine `Forked` recurs and needs an operator DB restore: catch-up cannot rebuild // forked committed history. - let recent = consensus_bus.recent_blocks().borrow(); + let recent = consensus_bus.recently_executed_blocks().borrow(); let recent_oldest = recent.oldest_block_number(); let recent_latest = recent.latest_block_num_hash(); let recent_len = recent.len(); @@ -777,10 +778,10 @@ async fn catch_up_consensus_from_to( consensus_leader_round = consensus_header.sub_dag.leader_round(), base_exec_block_number = base_execution_block.number, base_exec_block_hash = ?base_execution_block.hash, - recent_blocks_oldest = recent_oldest, - recent_blocks_latest_number = recent_latest.number, - recent_blocks_latest_hash = ?recent_latest.hash, - recent_blocks_len = recent_len, + recently_executed_blocks_oldest = recent_oldest, + recently_executed_blocks_latest_number = recent_latest.number, + recently_executed_blocks_latest_hash = ?recent_latest.hash, + recently_executed_blocks_len = recent_len, %wait_err, "catch-up could not confirm execution reached the referenced block" ); @@ -788,7 +789,7 @@ async fn catch_up_consensus_from_to( return Err(eyre::eyre!( "catch-up could not confirm execution of consensus header {} \ (references execution block {}, hash {:?}): {wait_err}. \ - recent_blocks range is [{}, {}] with {} blocks; the streamer will retry.", + recently_executed_blocks range is [{}, {}] with {} blocks; the streamer will retry.", number, base_execution_block.number, base_execution_block.hash, diff --git a/crates/consensus/state-sync/src/tests.rs b/crates/consensus/state-sync/src/tests.rs index 0430eb4..c5eed73 100644 --- a/crates/consensus/state-sync/src/tests.rs +++ b/crates/consensus/state-sync/src/tests.rs @@ -119,8 +119,8 @@ async fn test_prime_consensus_recovers_committed_round_via_primary_path() { } /// `prime_consensus` derives its watermark from the SSOT `executed_anchor` channel alone, -/// independent of `recent_blocks` and the producer/engine. Seeding the anchor at number N must -/// drive committed_round off N (block-number fallback for the genesis-subdag header). +/// independent of `recently_executed_blocks` and the producer/engine. Seeding the anchor at number +/// N must drive committed_round off N (block-number fallback for the genesis-subdag header). #[tokio::test] async fn test_prime_consensus_reads_executed_anchor_ssot() { let fixture = CommitteeFixture::builder(MemDatabase::default) @@ -135,7 +135,7 @@ async fn test_prime_consensus_reads_executed_anchor_ssot() { let anchor_number = 4242u64; let header = create_consensus_header_at_number(anchor_number, &committee); - // Seed the SSOT only - no recent_blocks, no DB rows, no engine. + // Seed the SSOT only - no recently_executed_blocks, no DB rows, no engine. let cb = ConsensusBus::new(); cb.executed_anchor().send_replace(header); @@ -484,10 +484,10 @@ fn chained_header( /// Push one recent execution block above genesis so `wait_for_execution` treats the genesis- /// subdag base block (number 0) as already executed and returns immediately. -fn seed_recent_blocks(cb: &ConsensusBus) { +fn seed_recently_executed_blocks(cb: &ConsensusBus) { let exec = SealedHeader::new(ExecHeader { number: 1, ..Default::default() }, B256::repeat_byte(0x01)); - cb.recent_blocks().send_modify(|blocks| blocks.push_latest(exec)); + cb.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(exec)); } /// Regression: a lagging node whose DB holds only header N must fill the gap `N+1..=M` by fetching @@ -530,7 +530,7 @@ async fn test_catch_up_fills_gap_by_number_when_db_is_empty() { let net = serving_network(served.clone()); let cb = ConsensusBus::new(); - seed_recent_blocks(&cb); + seed_recently_executed_blocks(&cb); let mut rx = cb.consensus_header().subscribe(); let result = catch_up_consensus_from_to(&config, &cb, &net, anchor.clone(), target.clone()) @@ -591,7 +591,7 @@ async fn test_gap_fetch_does_not_cache_mischained_header() { let net = serving_network(served); let cb = ConsensusBus::new(); - seed_recent_blocks(&cb); + seed_recently_executed_blocks(&cb); let result = catch_up_consensus_from_to(&config, &cb, &net, anchor.clone(), target).await; assert!(result.is_err(), "a mis-chained gap header must fail the digest-chain guard"); @@ -639,7 +639,7 @@ async fn test_catch_up_mismatch_preserves_canonical_block() { .unwrap(); let cb = ConsensusBus::new(); - seed_recent_blocks(&cb); + seed_recently_executed_blocks(&cb); let net = no_peer_network(); let result = @@ -696,7 +696,7 @@ async fn test_catch_up_drops_poisoned_gap_row_and_recovers() { assert_ne!(bogus_101.digest(), real_101.digest(), "precondition: fabricated sub_dag diverges"); let cb = ConsensusBus::new(); - seed_recent_blocks(&cb); + seed_recently_executed_blocks(&cb); // A live subscriber keeps the broadcast `send` from erroring while headers stream. let _rx = cb.consensus_header().subscribe(); @@ -790,7 +790,7 @@ async fn test_save_consensus_prunes_superseded_bydigest_on_layered_db() { #[tokio::test(flavor = "current_thread", start_paused = true)] async fn test_wait_for_execution_bounded_trips_on_stall() { let cb = ConsensusBus::new(); - cb.recent_blocks().send_modify(|b| b.push_latest(sealed_at(0))); + cb.recently_executed_blocks().send_modify(|b| b.push_latest(sealed_at(0))); let idle = std::time::Duration::from_secs(5); let target = sealed_at(5).num_hash(); // never produced @@ -809,7 +809,7 @@ async fn test_wait_for_execution_bounded_trips_on_stall() { async fn test_wait_for_execution_reports_fork_on_hash_mismatch() { let cb = ConsensusBus::new(); // Execution produced block 5, but with a different hash than the target references. - cb.recent_blocks().send_modify(|b| b.push_latest(sealed_at(5))); + cb.recently_executed_blocks().send_modify(|b| b.push_latest(sealed_at(5))); let mut target = sealed_at(5).num_hash(); target.hash = B256::repeat_byte(0xFF); // same number, divergent hash @@ -829,13 +829,13 @@ async fn test_wait_for_execution_reports_fork_on_hash_mismatch() { #[tokio::test(flavor = "current_thread", start_paused = true)] async fn test_wait_for_execution_bounded_tolerates_slow_progress() { let cb = ConsensusBus::new(); - cb.recent_blocks().send_modify(|b| b.push_latest(sealed_at(0))); + cb.recently_executed_blocks().send_modify(|b| b.push_latest(sealed_at(0))); let idle = std::time::Duration::from_secs(5); let target = sealed_at(5).num_hash(); // push one block per half-window: always inside the idle bound, so it never trips. - let tx = cb.recent_blocks().clone(); + let tx = cb.recently_executed_blocks().clone(); tokio::spawn(async move { for number in 1..=5 { tokio::time::sleep(idle / 2).await; diff --git a/crates/execution/evm/src/evm/block.rs b/crates/execution/evm/src/evm/block.rs index be140a3..abd0bb9 100644 --- a/crates/execution/evm/src/evm/block.rs +++ b/crates/execution/evm/src/evm/block.rs @@ -483,9 +483,7 @@ where /// Extract the epoch number from a header's nonce. fn extract_epoch_from_nonce(&self, nonce: u64) -> u32 { - // epochs are packed into nonce as 32 bits - let epoch = nonce >> 32; - epoch as u32 + rayls_infrastructure_types::nonce::unpack_nonce(nonce).0 } /// Applies the pre-block call to the EIP-4788 consensus root contract (cancun). diff --git a/crates/execution/evm/src/evm/config.rs b/crates/execution/evm/src/evm/config.rs index 6d2bac4..c7ab488 100644 --- a/crates/execution/evm/src/evm/config.rs +++ b/crates/execution/evm/src/evm/config.rs @@ -248,8 +248,7 @@ impl RaylsEvmConfig { if close_epoch.is_none() { return Ok(None); } - let epoch = (nonce >> 32) as u32; - let last_round = nonce as u32; + let (epoch, last_round) = rayls_infrastructure_types::nonce::unpack_nonce(nonce); let tally = self .rewards_counter .tally(epoch, last_round) diff --git a/crates/execution/evm/src/reth_env/execution.rs b/crates/execution/evm/src/reth_env/execution.rs index 15ee9c5..3a3f957 100644 --- a/crates/execution/evm/src/reth_env/execution.rs +++ b/crates/execution/evm/src/reth_env/execution.rs @@ -528,10 +528,11 @@ impl RethEnv { } /// Helper to deconstruct block nonce into epoch and round. + /// + /// Delegates to the canonical codec in `rayls_infrastructure_types::nonce` so the encode side + /// (`Header::nonce`) and every decode site share one definition of the layout. pub fn deconstruct_nonce(nonce: u64) -> (u32, u32) { - let epoch = (nonce >> 32) as u32; // Extract the upper 32 bits - let round = nonce as u32; // Extract the lower 32 bits (truncates upper bits) - (epoch, round) + rayls_infrastructure_types::nonce::unpack_nonce(nonce) } /// Get the epoch and round from the current execution tip. diff --git a/crates/execution/evm/src/reth_env/registry.rs b/crates/execution/evm/src/reth_env/registry.rs index 4f6f4c9..90ba8cf 100644 --- a/crates/execution/evm/src/reth_env/registry.rs +++ b/crates/execution/evm/src/reth_env/registry.rs @@ -111,8 +111,7 @@ impl RethEnv { /// Extract the epoch number from a header's nonce. pub fn extract_epoch_from_header(header: &ExecHeader) -> Epoch { - let nonce: u64 = header.nonce.into(); - (nonce >> 32) as u32 + rayls_infrastructure_types::nonce::unpack_nonce(header.nonce.into()).0 } /// Read the current epoch number from the [ConsensusRegistry] on-chain. diff --git a/crates/infrastructure/types/src/lib.rs b/crates/infrastructure/types/src/lib.rs index b056f7e..5983de3 100644 --- a/crates/infrastructure/types/src/lib.rs +++ b/crates/infrastructure/types/src/lib.rs @@ -12,6 +12,7 @@ pub mod executed_batch_registry; pub mod gas_accumulator; mod genesis; mod helpers; +pub mod nonce; mod notifier; mod primary; mod processor; diff --git a/crates/infrastructure/types/src/nonce.rs b/crates/infrastructure/types/src/nonce.rs new file mode 100644 index 0000000..0c2f103 --- /dev/null +++ b/crates/infrastructure/types/src/nonce.rs @@ -0,0 +1,45 @@ +//! Canonical codec for the epoch/round packed into an execution block's nonce. +//! +//! An execution (Ethereum) header carries no `epoch`/`round` fields of its own; both are packed +//! into the standard header `nonce` as `(epoch << 32) | round` — epoch in the high 32 bits, round +//! in the low 32. This module is the single source of truth for that layout: encode with +//! [`pack_nonce`], decode with [`unpack_nonce`]. Every callsite on both the consensus and +//! execution sides must go through here so the two ends can never drift — a mismatch would +//! silently corrupt every epoch/round read that crosses the consensus/execution boundary. + +use crate::{Epoch, Round}; + +/// Pack an epoch and round into an execution block nonce as `(epoch << 32) | round`. +pub fn pack_nonce(epoch: Epoch, round: Round) -> u64 { + ((epoch as u64) << 32) | round as u64 +} + +/// Decode an execution block nonce into its `(epoch, round)` — the values *at block creation*. +/// +/// Note these are the creation-time values, not the execution frontier: a block drained from a +/// parked, out-of-order batch carries the epoch/round of its origin output, which can sit below +/// the current frontier. Callers must treat the result accordingly. +pub fn unpack_nonce(nonce: u64) -> (Epoch, Round) { + let epoch = (nonce >> 32) as Epoch; // high 32 bits + let round = nonce as Round; // low 32 bits (truncates the epoch away) + (epoch, round) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrips() { + for (e, r) in [(0, 0), (1, 0), (0, 1), (7, 498), (u32::MAX, u32::MAX)] { + assert_eq!(unpack_nonce(pack_nonce(e, r)), (e, r)); + } + } + + #[test] + fn layout_is_epoch_high_round_low() { + assert_eq!(pack_nonce(1, 0), 1u64 << 32); + assert_eq!(pack_nonce(0, 1), 1); + assert_eq!(unpack_nonce((3u64 << 32) | 200), (3, 200)); + } +} diff --git a/crates/infrastructure/types/src/primary/header.rs b/crates/infrastructure/types/src/primary/header.rs index 1fc8b0c..07d2d90 100644 --- a/crates/infrastructure/types/src/primary/header.rs +++ b/crates/infrastructure/types/src/primary/header.rs @@ -150,7 +150,7 @@ impl Header { /// The nonce of this header used during execution. pub fn nonce(&self) -> u64 { - ((self.epoch as u64) << 32) | self.round as u64 + crate::nonce::pack_nonce(self.epoch, self.round) } } diff --git a/crates/middleware/bridge/src/subscriber.rs b/crates/middleware/bridge/src/subscriber.rs index 3a3e18e..b37d2e2 100644 --- a/crates/middleware/bridge/src/subscriber.rs +++ b/crates/middleware/bridge/src/subscriber.rs @@ -579,9 +579,9 @@ impl Subscriber { // // Reads the SSOT `executed_anchor` channel (seeded once at boot from the highest-nonce // recent block, advanced live by the engine) instead of re-deriving from - // `recent_blocks().latest_block()`, whose tip can regress to a PREVIOUS output's anchor - // after a drained parked (out-of-order seq) batch. Number 0 means nothing executed yet, - // so fall back to `last_streamed_number`. + // `recently_executed_blocks().latest_block()`, whose tip can regress to a PREVIOUS output's + // anchor after a drained parked (out-of-order seq) batch. Number 0 means nothing + // executed yet, so fall back to `last_streamed_number`. let (anchor_number, mut accepted_position) = { let anchor = self.consensus_bus.executed_anchor().borrow(); (anchor.number, AcceptedPosition::from_sub_dag(&anchor.sub_dag)) @@ -774,8 +774,9 @@ impl Subscriber { // in sync, otherwise we could get out of order execution racing with Bullshark. let missing = get_missing_consensus(&self.config, &self.consensus_bus).await?; // Every output now produces at least one block, so replay waits solely on - // recent_blocks, which ticks whenever a block is produced. - let mut recent_blocks_sub = self.consensus_bus.recent_blocks().subscribe(); + // recently_executed_blocks, which ticks whenever a block is produced. + let mut recently_executed_blocks_sub = + self.consensus_bus.recently_executed_blocks().subscribe(); if !missing.is_empty() { info!( @@ -819,10 +820,10 @@ impl Subscriber { error!(target: "subscriber", "error broadcasting consensus output for authority {:?}: {}", self.inner.authority_id, e); return Err(SubscriberError::ClosedChannel("consensus_output".to_string())); } - // wait until execution advances: a block landed (recent_blocks ticks), + // wait until execution advances: a block landed (recently_executed_blocks ticks), // or shutdown fires, in which case we bail. tokio::select! { - _ = recent_blocks_sub.changed() => {} + _ = recently_executed_blocks_sub.changed() => {} _ = &rx_shutdown => return Ok(()), } } @@ -832,7 +833,7 @@ impl Subscriber { execution_replay_done.send_replace(()); // Signal that execution replay is complete. The proposer waits for this before - // creating headers, ensuring recent_blocks contains up-to-date execution state + // creating headers, ensuring recently_executed_blocks contains up-to-date execution state // rather than stale MDBX data from before replay. (FIX: XL-C3) self.consensus_bus.execution_replay_complete().send_replace(true); diff --git a/crates/middleware/bridge/tests/it/main.rs b/crates/middleware/bridge/tests/it/main.rs index eecdba2..171ca1b 100644 --- a/crates/middleware/bridge/tests/it/main.rs +++ b/crates/middleware/bridge/tests/it/main.rs @@ -37,12 +37,13 @@ async fn test_output_to_header() -> eyre::Result<()> { let mut consensus_output = consensus_bus.consensus_output().subscribe(); - // prime recent_blocks BEFORE spawn so execution-wait paths have a tip; the subscriber's - // startup anchor now reads the SSOT `executed_anchor` (default number 0, nothing executed). + // prime recently_executed_blocks BEFORE spawn so execution-wait paths have a tip; the + // subscriber's startup anchor now reads the SSOT `executed_anchor` (default number 0, + // nothing executed). let mut exec_header = ExecHeader::default(); exec_header.parent_beacon_block_root = Some(B256::ZERO); let dummy_parent = SealedHeader::new(exec_header, B256::default()); - consensus_bus.recent_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); + consensus_bus.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); let (tx, mut rx) = mpsc::channel(5); tokio::spawn(async move { @@ -126,7 +127,7 @@ async fn test_output_to_header() -> eyre::Result<()> { /// /// Regression test for the guard added at subscriber.rs:641. Without that guard /// the subscriber would send the output to `to_engine`, then wait forever on -/// `recent_blocks_update.await` because no engine is running to execute the +/// `recently_executed_blocks_update.await` because no engine is running to execute the /// block (it belongs to a past epoch). #[tokio::test] async fn test_missing_consensus_beyond_epoch_boundary() -> eyre::Result<()> { @@ -189,8 +190,8 @@ async fn test_missing_consensus_beyond_epoch_boundary() -> eyre::Result<()> { // Keep the to_engine receiver alive but never read from it. // The old (broken) code path sends to_engine then waits on - // recent_blocks_update.await, which deadlocks because no engine - // is running to update recent_blocks. The guard must prevent + // recently_executed_blocks_update.await, which deadlocks because no engine + // is running to update recently_executed_blocks. The guard must prevent // reaching that code path. let (to_engine, _rx) = mpsc::channel(100); @@ -247,11 +248,12 @@ async fn subscriber_drops_post_boundary_output_without_drain_signal() -> eyre::R }); let network = PrimaryNetworkHandle::new_for_test(tx); - // Prime recent_blocks so the startup anchor path has a tip (nothing executed: anchor number 0). + // Prime recently_executed_blocks so the startup anchor path has a tip (nothing executed: anchor + // number 0). let mut exec_header = ExecHeader::default(); exec_header.parent_beacon_block_root = Some(B256::ZERO); let dummy_parent = SealedHeader::new(exec_header, B256::default()); - consensus_bus.recent_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); + consensus_bus.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); // Deterministic epoch boundary at timestamp 1000. const BOUNDARY: u64 = 1000; @@ -381,10 +383,10 @@ async fn live_numbering_seeds_from_consensus_tip_across_lagging_anchor_restart() let mut exec_header = ExecHeader::default(); exec_header.parent_beacon_block_root = Some(digest4); let exec_tip = SealedHeader::new(exec_header, B256::from([1u8; 32])); - consensus_bus.recent_blocks().send_modify(|blocks| blocks.push_latest(exec_tip)); + consensus_bus.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(exec_tip)); // Stand in for the engine: each replayed output produces a block, so drain to_engine and tick - // recent_blocks per output - the signal the replay loop now waits on. + // recently_executed_blocks per output - the signal the replay loop now waits on. // Annotated because `engine_rx` is moved into `tokio::spawn` below before `spawn_subscriber` // constrains the channel type, so inference can't resolve it from the sender side in time. let (to_engine, mut engine_rx) = mpsc::channel::<(CameFrom, ConsensusOutput)>(100); @@ -394,7 +396,7 @@ async fn live_numbering_seeds_from_consensus_tip_across_lagging_anchor_restart() let mut header = ExecHeader::default(); header.number = output.number; let block = SealedHeader::new(header, B256::from([2u8; 32])); - engine_bus.recent_blocks().send_modify(|blocks| blocks.push_latest(block)); + engine_bus.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(block)); } }); spawn_subscriber( diff --git a/crates/middleware/orchestrator/src/epoch_manager/core.rs b/crates/middleware/orchestrator/src/epoch_manager/core.rs index 52b87a9..96b3587 100644 --- a/crates/middleware/orchestrator/src/epoch_manager/core.rs +++ b/crates/middleware/orchestrator/src/epoch_manager/core.rs @@ -256,7 +256,8 @@ where } // node-scoped (not epoch-scoped): engine drains queued outputs past epoch shutdown. - // epoch-scoped death would leave recent_blocks stale and re-replay executed outputs. + // epoch-scoped death would leave recently_executed_blocks stale and re-replay executed + // outputs. self.spawn_engine_update_task( self.node_shutdown.subscribe(), engine.canonical_block_stream().await, diff --git a/crates/middleware/orchestrator/src/epoch_manager/engine.rs b/crates/middleware/orchestrator/src/epoch_manager/engine.rs index 5064820..5676158 100644 --- a/crates/middleware/orchestrator/src/epoch_manager/engine.rs +++ b/crates/middleware/orchestrator/src/epoch_manager/engine.rs @@ -39,9 +39,9 @@ where Ok(engine) } - /// Spawn a node-scoped task to update `ConsensusBus::recent_blocks` every time the engine - /// produces a new final block. This task must outlive individual epochs because the engine - /// continues executing queued outputs after epoch shutdown. + /// Spawn a node-scoped task to update `ConsensusBus::recently_executed_blocks` every time the + /// engine produces a new final block. This task must outlive individual epochs because the + /// engine continues executing queued outputs after epoch shutdown. pub(super) fn spawn_engine_update_task( &self, shutdown_rx: Noticer, @@ -55,12 +55,12 @@ where loop { tokio::select!( _ = &shutdown_rx => { - info!(target: "engine", "received node shutdown, stopping recent blocks updater"); + info!(target: "engine", "received node shutdown, stopping recently-executed blocks updater"); break; } latest = engine_state.next() => { if let Some(latest) = latest { - consensus_bus.recent_blocks().send_modify(|blocks| blocks.push_latest(latest.tip().clone_sealed_header())); + consensus_bus.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(latest.tip().clone_sealed_header())); } else { error!(target: "engine", "engine state stream ended, node will exit"); break; diff --git a/crates/middleware/orchestrator/src/epoch_manager/state.rs b/crates/middleware/orchestrator/src/epoch_manager/state.rs index 5d761e7..696504a 100644 --- a/crates/middleware/orchestrator/src/epoch_manager/state.rs +++ b/crates/middleware/orchestrator/src/epoch_manager/state.rs @@ -1,7 +1,7 @@ use crate::{engine::ExecutionNode, epoch_manager::types::EpochManager, primary::PrimaryNode}; use eyre::eyre; -use rayls_consensus_primary::{NodeMode, RecentBlocks}; -use rayls_execution_evm::{reth_env::RethEnv, system_calls::ConsensusRegistry}; +use rayls_consensus_primary::{NodeMode, RecentlyExecutedBlocks}; +use rayls_execution_evm::system_calls::ConsensusRegistry; use rayls_infrastructure_config::{Config, ConfigFmt, ConfigTrait as _, RaylsDirs}; use rayls_infrastructure_storage::{ tables::{ @@ -147,9 +147,10 @@ where }; // Use deterministic sources: boundary_consensus_hash from the boundary output (immutable) // and the durable canonical tip. Read the reth canonical head directly rather than the - // in-memory recent_blocks (which is fed asynchronously by the engine-update task): the tip - // is the epoch-closing block the engine finalized before this runs, so parent_state is - // deterministic and race-free — and identical to the value the pre-anchor code committed. + // in-memory recently_executed_blocks (which is fed asynchronously by the engine-update + // task): the tip is the epoch-closing block the engine finalized before this runs, + // so parent_state is deterministic and race-free — and identical to the value the + // pre-anchor code committed. let parent_state = engine.get_reth_env().await.canonical_tip().num_hash(); let epoch_rec = EpochRecord { @@ -276,64 +277,65 @@ where /// Restore execution state for the consensus components. pub(super) async fn try_restore_state(&self, engine: &ExecutionNode) -> eyre::Result<()> { - // Only restore recent_blocks from the chain DB on initial startup. - // On later epoch transitions, recent_blocks are already up to date + // Only restore recently_executed_blocks from the chain DB on initial startup. + // On later epoch transitions, recently_executed_blocks are already up to date // from the node-scoped engine update task and close_epoch(). Clearing // and restoring here would race with the reth DB flush and could - // revert recent_blocks to a stale state, causing get_missing_consensus() + // revert recently_executed_blocks to a stale state, causing get_missing_consensus() // to replay already-executed outputs. if self.initial_epoch { if self.sanitize_foreign_consensus_db()? { info!(target: "epoch-manager", "foreign consensus DB sanitized"); } - let block_capacity = self.consensus_bus.recent_blocks().borrow().block_capacity(); + let block_capacity = + self.consensus_bus.recently_executed_blocks().borrow().block_capacity(); - // clear recent_blocks before restoring to avoid ordering issues + // clear recently_executed_blocks before restoring to avoid ordering issues self.consensus_bus - .recent_blocks() - .send_replace(RecentBlocks::new(block_capacity as usize)); + .recently_executed_blocks() + .send_replace(RecentlyExecutedBlocks::new(block_capacity as usize)); // restore blocks from execution layer in ascending order let restored_blocks = engine.last_executed_output_blocks(block_capacity).await?; let restored_count = restored_blocks.len(); - debug!(target: "epoch-manager", restored_blocks=?restored_blocks, "Restoring recent blocks from execution layer"); - for recent_block in restored_blocks { + debug!(target: "epoch-manager", restored_blocks=?restored_blocks, "Restoring recently-executed blocks from execution layer"); + for executed_block in restored_blocks { self.consensus_bus - .recent_blocks() - .send_modify(|blocks| blocks.push_latest(recent_block)); + .recently_executed_blocks() + .send_modify(|blocks| blocks.push_latest(executed_block)); } - let recent = self.consensus_bus.recent_blocks().borrow(); + let recent = self.consensus_bus.recently_executed_blocks().borrow(); let latest = recent.latest_block(); - let (epoch, round) = RethEnv::deconstruct_nonce(latest.nonce.into()); + let (epoch, round) = (latest.subdag_leader_epoch(), latest.subdag_leader_round()); - if recent.is_empty() && latest.number == 0 { + if recent.is_empty() && latest.number() == 0 { trace!( target: "epoch-manager", - "recent_blocks empty - this is expected for fresh/genesis start" + "recently_executed_blocks empty - this is expected for fresh/genesis start" ); } else if recent.is_empty() { error!( target: "epoch-manager", restored_count, - latest_block_number = latest.number, - "CRITICAL: recent_blocks is empty despite having execution history! \ + latest_block_number = latest.number(), + "CRITICAL: recently_executed_blocks is empty despite having execution history! \ State sync fork detection may not work correctly." ); } trace!( target: "epoch-manager", - recent_blocks_len = recent.len(), + recently_executed_blocks_len = recent.len(), restored_count, - latest_block_number = latest.number, - latest_block_nonce_epoch = epoch, - latest_block_nonce_round = round, - latest_parent_beacon_root = ?latest.header().parent_beacon_block_root, + latest_block_number = latest.number(), + latest_block_subdag_leader_epoch = %epoch, + latest_block_subdag_leader_round = %round, + latest_block_subdag_consensus_digest = ?latest.subdag_consensus_digest(), oldest_block_number = recent.oldest_block_number(), - "restored recent_blocks from execution layer" + "restored recently_executed_blocks from execution layer" ); } @@ -345,10 +347,11 @@ where // log consensus DB state and check for potential desync { - let recent = self.consensus_bus.recent_blocks().borrow(); + let recent = self.consensus_bus.recently_executed_blocks().borrow(); let exec_latest = recent.latest_block_num_hash(); - // check if consensus DB's referenced execution block is within recent_blocks window + // check if consensus DB's referenced execution block is within recently_executed_blocks + // window let consensus_exec_ref = last_db_block.sub_dag.leader.header().latest_execution_block; let in_window = consensus_exec_ref.number >= recent.oldest_block_number() && consensus_exec_ref.number <= exec_latest.number; @@ -374,7 +377,7 @@ where target: "epoch-manager", consensus_exec_ref_number = consensus_exec_ref.number, consensus_exec_ref_hash = ?consensus_exec_ref.hash, - "Consensus DB references execution block hash not in recent_blocks - \ + "Consensus DB references execution block hash not in recently_executed_blocks - \ MDBX has divergent blocks from a previous run. \ Forcing CvvInactive to resync from peers." ); diff --git a/crates/middleware/orchestrator/src/epoch_manager/transition.rs b/crates/middleware/orchestrator/src/epoch_manager/transition.rs index 6dd6846..afea6e6 100644 --- a/crates/middleware/orchestrator/src/epoch_manager/transition.rs +++ b/crates/middleware/orchestrator/src/epoch_manager/transition.rs @@ -359,9 +359,10 @@ where // For early phases the engine may or may not have executed the boundary output. // Decide from the durable execution state: the boundary output runs concludeEpoch, // so if the closing epoch executed, the canonical tip's epoch state has advanced - // past it. (The former recent_blocks/parent_beacon fast-path was dead here — - // recent_blocks is empty this early in startup, before any replay — and fragile: a - // drained parked batch makes the tip's beacon differ from target_hash.) + // past it. (The former recently_executed_blocks/parent_beacon fast-path was dead + // here — recently_executed_blocks is empty this early in startup, + // before any replay — and fragile: a drained parked batch makes the + // tip's beacon differ from target_hash.) let tip_state = engine.epoch_state_from_canonical_tip().await?; let execution_done = tip_state.epoch > epoch; diff --git a/crates/middleware/orchestrator/src/tests/epoch_transition_tests.rs b/crates/middleware/orchestrator/src/tests/epoch_transition_tests.rs index 6646ced..7e5b894 100644 --- a/crates/middleware/orchestrator/src/tests/epoch_transition_tests.rs +++ b/crates/middleware/orchestrator/src/tests/epoch_transition_tests.rs @@ -479,19 +479,19 @@ fn test_checkpoint_enables_epoch_record_recovery() { assert!(needs_clear, "crash before Cleared requires table clear + epoch record"); } -// The former RANK 4 test (`test_sequential_execution_phase_prevents_recent_blocks_race`) was -// removed: it guarded a race where `spawn_engine_update_task` could write a stale block into -// `recent_blocks` and corrupt the epoch record's `parent_state`. That race no longer exists — -// `write_epoch_record` now sources `parent_state` from the durable canonical tip -// (`engine.get_reth_env().canonical_tip()`), not from the async-fed `recent_blocks`, so no -// explicit push or sequential-phase ordering is needed to keep `parent_state` deterministic. - -/// RANK 4: Verify recent_blocks accurate after reset. +// The former RANK 4 test (`test_sequential_execution_phase_prevents_recently_executed_blocks_race`) +// was removed: it guarded a race where `spawn_engine_update_task` could write a stale block into +// `recently_executed_blocks` and corrupt the epoch record's `parent_state`. That race no longer +// exists — `write_epoch_record` now sources `parent_state` from the durable canonical tip +// (`engine.get_reth_env().canonical_tip()`), not from the async-fed `recently_executed_blocks`, so +// no explicit push or sequential-phase ordering is needed to keep `parent_state` deterministic. + +/// RANK 4: Verify recently_executed_blocks accurate after reset. /// -/// After reset_for_epoch(), recent_blocks should carry over the latest block +/// After reset_for_epoch(), recently_executed_blocks should carry over the latest block /// from the previous epoch (as the parent for the new epoch's first block). #[tokio::test] -async fn test_recent_blocks_accurate_after_transition() { +async fn test_recently_executed_blocks_accurate_after_transition() { let mut bus = ConsensusBus::new(); // Push a block as if the epoch just ended. @@ -500,13 +500,13 @@ async fn test_recent_blocks_accurate_after_transition() { h.number = 50; rayls_infrastructure_types::SealedHeader::seal_slow(h) }; - bus.recent_blocks().send_modify(|blocks| blocks.push_latest(closing_header)); + bus.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(closing_header)); // Reset for new epoch. bus.reset_for_epoch(); - // After reset, recent_blocks should have exactly the last block from previous epoch. - let recent = bus.recent_blocks().borrow(); + // After reset, recently_executed_blocks should have exactly the last block from previous epoch. + let recent = bus.recently_executed_blocks().borrow(); assert_eq!( recent.latest_block_num_hash().number, 50, @@ -1167,18 +1167,18 @@ async fn test_recovery_boundary_detected_execution_done() { }; db.save_checkpoint(&checkpoint).unwrap(); - // Simulate that execution already completed: set recent_blocks to have the + // Simulate that execution already completed: set recently_executed_blocks to have the // closing block with the matching target hash. let mut closing_header = rayls_infrastructure_types::ExecHeader::default(); closing_header.number = 200; closing_header.parent_beacon_block_root = Some(target_hash); let sealed = rayls_infrastructure_types::SealedHeader::seal_slow(closing_header); - bus.recent_blocks().send_modify(|blocks| blocks.push_latest(sealed)); + bus.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(sealed)); // Simulate recovery logic (mirrors manager.rs:2132-2158). - let latest = bus.recent_blocks().borrow().latest_block(); + let latest = bus.recently_executed_blocks().borrow().latest_block(); assert_eq!( - latest.parent_beacon_block_root, + latest.subdag_consensus_digest().map(|d| d.get()), Some(target_hash), "execution should be done for this target" ); @@ -1206,16 +1206,16 @@ async fn test_recovery_boundary_detected_execution_not_done() { }; db.save_checkpoint(&checkpoint).unwrap(); - // recent_blocks has a different block (execution not done). + // recently_executed_blocks has a different block (execution not done). let mut header = rayls_infrastructure_types::ExecHeader::default(); header.number = 50; // parent_beacon_block_root does NOT match target_hash. let sealed = rayls_infrastructure_types::SealedHeader::seal_slow(header); - bus.recent_blocks().send_modify(|blocks| blocks.push_latest(sealed)); + bus.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(sealed)); - let latest = bus.recent_blocks().borrow().latest_block(); + let latest = bus.recently_executed_blocks().borrow().latest_block(); assert_ne!( - latest.parent_beacon_block_root, + latest.subdag_consensus_digest().map(|d| d.get()), Some(target_hash), "execution should NOT be done for this target" ); @@ -1596,7 +1596,7 @@ async fn test_full_epoch_transition_simulation() { closing_header.number = 100; closing_header.parent_beacon_block_root = Some(target_hash); let sealed = rayls_infrastructure_types::SealedHeader::seal_slow(closing_header); - bus.recent_blocks().send_modify(|blocks| blocks.push_latest(sealed)); + bus.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(sealed)); db.save_checkpoint(&EpochTransitionCheckpoint { epoch, @@ -1618,9 +1618,9 @@ async fn test_full_epoch_transition_simulation() { assert_eq!(*bus.committed_round_updates().borrow(), 0); assert!(bus.drain_signal().borrow().is_none()); - // Verify recent_blocks carries over the closing block. + // Verify recently_executed_blocks carries over the closing block. assert_eq!( - bus.recent_blocks().borrow().latest_block_num_hash().number, + bus.recently_executed_blocks().borrow().latest_block_num_hash().number, 100, "closing block must carry over to next epoch" ); @@ -1650,11 +1650,11 @@ async fn test_crash_recovery_then_successful_epoch() { closing_header.number = 500; closing_header.parent_beacon_block_root = Some(target_hash); let sealed = rayls_infrastructure_types::SealedHeader::seal_slow(closing_header); - bus.recent_blocks().send_modify(|blocks| blocks.push_latest(sealed)); + bus.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(sealed)); // Recovery: detect execution done, clear tables, remove checkpoint. - let latest = bus.recent_blocks().borrow().latest_block(); - assert_eq!(latest.parent_beacon_block_root, Some(target_hash)); + let latest = bus.recently_executed_blocks().borrow().latest_block(); + assert_eq!(latest.subdag_consensus_digest().map(|d| d.get()), Some(target_hash)); db.clear_checkpoint(5).unwrap(); // --- Next epoch starts clean --- diff --git a/crates/middleware/orchestrator/src/types/engine_to_primary_rpc.rs b/crates/middleware/orchestrator/src/types/engine_to_primary_rpc.rs index b13f64f..f49e9ba 100644 --- a/crates/middleware/orchestrator/src/types/engine_to_primary_rpc.rs +++ b/crates/middleware/orchestrator/src/types/engine_to_primary_rpc.rs @@ -91,7 +91,7 @@ impl EngineToPrimary for EngineToPrimaryRpc { gc_round: *self.consensus_bus.gc_round_updates().borrow(), last_canonical_block: self .consensus_bus - .recent_blocks() + .recently_executed_blocks() .borrow() .latest_block_num_hash() .number, diff --git a/crates/middleware/orchestrator/tests/it/main.rs b/crates/middleware/orchestrator/tests/it/main.rs index c2748b3..a1504bb 100644 --- a/crates/middleware/orchestrator/tests/it/main.rs +++ b/crates/middleware/orchestrator/tests/it/main.rs @@ -240,7 +240,7 @@ fn spawn_consensus( let mut exec_header = ExecHeader::default(); exec_header.parent_beacon_block_root = Some(B256::ZERO); let dummy_parent = SealedHeader::new(exec_header, B256::default()); - consensus_bus.recent_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); + consensus_bus.recently_executed_blocks().send_modify(|blocks| blocks.push_latest(dummy_parent)); Consensus::spawn(config, consensus_bus, bullshark, task_manager); } @@ -320,7 +320,9 @@ async fn test_prime_consensus_recovers_committed_round_after_restart() -> eyre:: // Create a FRESH ConsensusBus (simulating node restart) and seed the executed_anchor SSOT // with the recovered header, which is what core.rs does at boot from the highest-nonce block. let consensus_bus = ConsensusBus::new(); - consensus_bus.recent_blocks().send_modify(|blocks| blocks.push_latest(sealed_header)); + consensus_bus + .recently_executed_blocks() + .send_modify(|blocks| blocks.push_latest(sealed_header)); consensus_bus.executed_anchor().send_replace(consensus_header.clone()); // Verify initial state: committed_round should be 0 (default) @@ -446,30 +448,32 @@ async fn test_prime_consensus_recovers_via_primary_path() -> eyre::Result<()> { exec_header.parent_beacon_block_root = Some(consensus_digest); let sealed_header = SealedHeader::new(exec_header, B256::default()); - // Create ConsensusBus and populate recent_blocks (simulating try_restore_state) + // Create ConsensusBus and populate recently_executed_blocks (simulating try_restore_state) let consensus_bus = ConsensusBus::new(); - // Verify recent_blocks is initially empty + // Verify recently_executed_blocks is initially empty assert!( - consensus_bus.recent_blocks().borrow().is_empty(), - "recent_blocks should be empty before population" + consensus_bus.recently_executed_blocks().borrow().is_empty(), + "recently_executed_blocks should be empty before population" ); - // Push the sealed header to recent_blocks (what try_restore_state does) - consensus_bus.recent_blocks().send_modify(|blocks| blocks.push_latest(sealed_header.clone())); + // Push the sealed header to recently_executed_blocks (what try_restore_state does) + consensus_bus + .recently_executed_blocks() + .send_modify(|blocks| blocks.push_latest(sealed_header.clone())); // Seed the executed_anchor SSOT with the recovered header (core.rs does this at boot). consensus_bus.executed_anchor().send_replace(consensus_header.clone()); - // Verify recent_blocks is now populated + // Verify recently_executed_blocks is now populated assert!( - !consensus_bus.recent_blocks().borrow().is_empty(), - "recent_blocks should be populated after push" + !consensus_bus.recently_executed_blocks().borrow().is_empty(), + "recently_executed_blocks should be populated after push" ); // Verify the parent_beacon_block_root is set correctly - let latest_block = consensus_bus.recent_blocks().borrow().latest_block().clone(); + let latest_block = consensus_bus.recently_executed_blocks().borrow().latest_block().clone(); assert_eq!( - latest_block.header().parent_beacon_block_root, + latest_block.subdag_consensus_digest().map(|d| d.get()), Some(consensus_digest), "parent_beacon_block_root should match consensus_digest" ); @@ -482,7 +486,7 @@ async fn test_prime_consensus_recovers_via_primary_path() -> eyre::Result<()> { ); // Act: Call prime_consensus (this is what happens during node startup) - // Now it should use the PRIMARY path: recent_blocks → parent_beacon_block_root → + // Now it should use the PRIMARY path: recently_executed_blocks → parent_beacon_block_root → // get_consensus_by_hash prime_consensus(&consensus_bus, &config);