Skip to content

Commit 3cf7be7

Browse files
apollo_consensus_orchestrator,apollo_consensus_manager: move SNIP-35 bootstrap to pre-consensus
1 parent a6145b3 commit 3cf7be7

4 files changed

Lines changed: 81 additions & 42 deletions

File tree

crates/apollo_consensus_manager/src/consensus_manager.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,14 +159,18 @@ impl ConsensusManager {
159159
outbound_internal_receiver,
160160
);
161161

162-
let consensus_context = self.create_sequencer_consensus_context(
162+
let mut consensus_context = self.create_sequencer_consensus_context(
163163
&votes_broadcast_channels,
164164
outbound_internal_sender,
165165
self.config_manager_client.clone(),
166166
);
167167

168168
let current_height =
169169
self.batcher_client.get_height().await.expect("Failed to get height from batcher");
170+
consensus_context
171+
.initialize_fee_proposals_window(current_height.height)
172+
.await
173+
.expect("Failed to bootstrap SNIP-35 fee_proposals window");
170174
let run_consensus_args = self.create_run_consensus_args(current_height.height);
171175

172176
let network_task =

crates/apollo_consensus_orchestrator/src/sequencer_consensus_context.rs

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
mod sequencer_consensus_context_test;
77

88
use std::cmp::max;
9-
use std::collections::{BTreeMap, HashMap};
9+
use std::collections::{BTreeMap, HashMap, VecDeque};
1010
use std::sync::{Arc, Mutex};
1111
use std::time::Duration;
1212

@@ -37,7 +37,11 @@ use apollo_protobuf::consensus::{
3737
TransactionBatch,
3838
Vote,
3939
};
40-
use apollo_state_sync_types::communication::{SharedStateSyncClient, StateSyncClientError};
40+
use apollo_state_sync_types::communication::{
41+
SharedStateSyncClient,
42+
StateSyncClientError,
43+
StateSyncClientResult,
44+
};
4145
use apollo_state_sync_types::errors::StateSyncError;
4246
use apollo_state_sync_types::state_sync_types::SyncBlock;
4347
use apollo_time::time::Clock;
@@ -305,39 +309,40 @@ impl SequencerConsensusContext {
305309
self.fee_proposals_window = self.fee_proposals_window.split_off(&cutoff);
306310
}
307311

308-
/// SNIP-35: bootstrap the fee_proposals window from state_sync on startup so `fee_actual` is
309-
/// available immediately. Pre-V0_14_3 blocks return `Ok` with `fee_proposal_fri == None` and
310-
/// contribute nothing; an `Err` from state_sync means the block could not be retrieved, so we
311-
/// log and stop bootstrapping.
312-
// TODO(AndrewL): On `Err`, state_sync may simply not have downloaded the block yet rather than
313-
// the block being absent. Returning a partial window here is non-deterministic across nodes
314-
// (different local sync progress → different windows → different `fee_actual`). Resolving this
315-
// requires a startup gate that guarantees state_sync is caught up to `height - 1` before
316-
// consensus enters `set_height_and_round`, since blocking inside this call to retry is unsafe
317-
// (peers may already be voting at this height). Track and fix in a follow-up PR.
318-
async fn bootstrap_fee_proposals_window(&mut self, height: BlockNumber) {
312+
/// Fill `[start_height - WINDOW, start_height)` from local state_sync storage. Must be
313+
/// called before `run_consensus` so the window is populated before voting begins. Blocks
314+
/// state_sync has not caught up to yet are pushed to the back of the queue and revisited —
315+
/// joining consensus with a partial window would make this node disagree with caught-up
316+
/// peers on `fee_actual`. Other state_sync errors propagate.
317+
pub async fn initialize_fee_proposals_window(
318+
&mut self,
319+
start_height: BlockNumber,
320+
) -> StateSyncClientResult<()> {
321+
const STATE_SYNC_RETRY_INTERVAL: Duration = Duration::from_millis(500);
319322
let window_size =
320323
u64::try_from(FEE_PROPOSAL_WINDOW_SIZE).expect("FEE_PROPOSAL_WINDOW_SIZE fits in u64");
321-
let start = height.0.saturating_sub(window_size);
322-
for h in start..height.0 {
323-
let block_number = BlockNumber(h);
324+
let window_end_height = start_height.0;
325+
let window_start_height = window_end_height.saturating_sub(window_size);
326+
let mut pending_heights: VecDeque<BlockNumber> =
327+
(window_start_height..window_end_height).map(BlockNumber).collect();
328+
while let Some(block_number) = pending_heights.pop_front() {
324329
match self.deps.state_sync_client.get_block(block_number).await {
325-
Ok(block) => {
326-
self.record_fee_proposal(
327-
block_number,
328-
block.block_header_without_hash.fee_proposal_fri,
329-
);
330-
}
331-
Err(e) => {
330+
Ok(block) => self.record_fee_proposal(
331+
block_number,
332+
block.block_header_without_hash.fee_proposal_fri,
333+
),
334+
Err(StateSyncClientError::StateSyncError(StateSyncError::BlockNotFound(_))) => {
332335
warn!(
333-
"SNIP-35 bootstrap failed at block {h}: {e:?}. Window has {} / \
334-
{FEE_PROPOSAL_WINDOW_SIZE} entries.",
335-
self.fee_proposals_window.len()
336+
"State sync not ready for height {block_number}; re-queueing after \
337+
{STATE_SYNC_RETRY_INTERVAL:?}"
336338
);
337-
break;
339+
pending_heights.push_back(block_number);
340+
tokio::time::sleep(STATE_SYNC_RETRY_INTERVAL).await;
338341
}
342+
Err(e) => return Err(e),
339343
}
340344
}
345+
Ok(())
341346
}
342347

343348
async fn start_stream(&mut self, stream_id: HeightAndRound) -> StreamSender {
@@ -954,11 +959,6 @@ impl ConsensusContext for SequencerConsensusContext {
954959
let gas_price_u64 = u64::try_from(self.l2_gas_price.0).unwrap_or(u64::MAX);
955960
CONSENSUS_L2_GAS_PRICE.set_lossy(gas_price_u64);
956961
}
957-
// SNIP-35: on first height, bootstrap the window so fee_actual is available
958-
// immediately.
959-
if self.current_height.is_none() && self.fee_proposals_window.is_empty() {
960-
self.bootstrap_fee_proposals_window(height).await;
961-
}
962962
self.current_height = Some(height);
963963
self.current_round = round;
964964
self.prune_fee_proposals_window(height);

crates/apollo_consensus_orchestrator/src/sequencer_consensus_context_test.rs

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::collections::BTreeMap;
12
use std::future::ready;
23
use std::sync::Arc;
34
use std::time::Duration;
@@ -1495,7 +1496,7 @@ async fn test_first_height_keeps_sync_provided_l2_gas_price() {
14951496

14961497
let (mut deps, _network) = create_test_and_network_deps();
14971498
// Specific get_block expectation must be registered before setup_default_expectations, which
1498-
// installs a catch-all BlockNotFound handler for SNIP-35 bootstrap.
1499+
// installs a catch-all handler.
14991500
deps.state_sync_client.expect_get_block().times(1).return_once(|height| {
15001501
let mut sync_block = SyncBlock::default();
15011502
sync_block.block_header_without_hash.block_number = height;
@@ -1537,9 +1538,7 @@ async fn test_first_height_keeps_sync_provided_l2_gas_price() {
15371538
}
15381539

15391540
// Distinct value per height so map equality verifies identity, not just key set.
1540-
fn window_of(
1541-
heights: impl IntoIterator<Item = u64>,
1542-
) -> std::collections::BTreeMap<BlockNumber, Option<GasPrice>> {
1541+
fn window_of(heights: impl IntoIterator<Item = u64>) -> BTreeMap<BlockNumber, Option<GasPrice>> {
15431542
heights.into_iter().map(|h| (BlockNumber(h), Some(GasPrice(u128::from(h))))).collect()
15441543
}
15451544

@@ -1552,9 +1551,9 @@ fn window_of(
15521551
#[case::saturates_at_zero_when_height_below_window(window_of(0..=2), BlockNumber(5), window_of(0..=2))]
15531552
#[case::no_op_when_all_entries_within_window(window_of(105..=110), BlockNumber(110), window_of(105..=110))]
15541553
fn test_prune_fee_proposals_window(
1555-
#[case] initial_window: std::collections::BTreeMap<BlockNumber, Option<GasPrice>>,
1554+
#[case] initial_window: BTreeMap<BlockNumber, Option<GasPrice>>,
15561555
#[case] current_height: BlockNumber,
1557-
#[case] expected_window: std::collections::BTreeMap<BlockNumber, Option<GasPrice>>,
1556+
#[case] expected_window: BTreeMap<BlockNumber, Option<GasPrice>>,
15581557
) {
15591558
let (mut deps, _network) = create_test_and_network_deps();
15601559
deps.setup_default_expectations();
@@ -1563,3 +1562,42 @@ fn test_prune_fee_proposals_window(
15631562
context.prune_fee_proposals_window(current_height);
15641563
assert_eq!(context.fee_proposals_window, expected_window);
15651564
}
1565+
1566+
// `initialize_fee_proposals_window` reads `[start_height - WINDOW, start_height)` from state_sync
1567+
// and records each block's `fee_proposal_fri`. `expected_window` is the mapping the test asserts;
1568+
// the mock answers `get_block(h)` from the same map. Genesis case (`start_height < WINDOW_SIZE`)
1569+
// is exercised by a smaller window: the bootstrap range collapses to `[0, start_height)`.
1570+
#[rstest]
1571+
#[case::all_some(BlockNumber(100), window_of(90..100))]
1572+
// v0.14.2 era: every block recorded with `fee_proposal_fri = None`.
1573+
#[case::all_none(BlockNumber(100), (90u64..100).map(|h| (BlockNumber(h), None)).collect())]
1574+
// Transition: heights < 95 are pre-SNIP-35 (None), heights >= 95 are SNIP-35 (Some).
1575+
#[case::mixed_transition(
1576+
BlockNumber(100),
1577+
(90u64..100)
1578+
.map(|h| (BlockNumber(h), (h >= 95).then(|| GasPrice(u128::from(h)))))
1579+
.collect(),
1580+
)]
1581+
#[case::genesis_collapses_range(BlockNumber(3), window_of(0..3))]
1582+
#[tokio::test]
1583+
async fn test_initialize_fee_proposals_window(
1584+
#[case] start_height: BlockNumber,
1585+
#[case] expected_window: BTreeMap<BlockNumber, Option<GasPrice>>,
1586+
) {
1587+
let mock_window = expected_window.clone();
1588+
let (mut deps, _network) = create_test_and_network_deps();
1589+
deps.state_sync_client.expect_get_block().times(expected_window.len()).returning(
1590+
move |height| {
1591+
let mut sync_block = SyncBlock::default();
1592+
sync_block.block_header_without_hash.block_number = height;
1593+
sync_block.block_header_without_hash.fee_proposal_fri =
1594+
*mock_window.get(&height).unwrap();
1595+
Ok(sync_block)
1596+
},
1597+
);
1598+
deps.setup_default_expectations();
1599+
1600+
let mut context = deps.build_context();
1601+
context.initialize_fee_proposals_window(start_height).await.unwrap();
1602+
assert_eq!(context.fee_proposals_window, expected_window);
1603+
}

crates/apollo_consensus_orchestrator/src/test_utils.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -326,9 +326,6 @@ impl TestDeps {
326326
self.l1_gas_price_provider.expect_get_rate().return_const(Ok(ETH_TO_FRI_RATE));
327327
}
328328

329-
/// Default get_block returns a pre-SNIP-35 block (no `fee_proposal_fri`). Used by SNIP-35
330-
/// bootstrap on startup; mirrors real chain behavior where blocks before V0_14_3 exist but
331-
/// don't carry the field.
332329
fn setup_default_state_sync_get_block(&mut self) {
333330
self.state_sync_client.expect_get_block().returning(|_| Ok(SyncBlock::default()));
334331
}

0 commit comments

Comments
 (0)