fix(sequencer): fix silent reconstruction when only genesis was committed

This commit is contained in:
Daniil Polyakov 2026-07-21 22:15:20 +03:00
parent 8fdc3f203d
commit e3442c695f
7 changed files with 53 additions and 42 deletions

View File

@ -65,6 +65,7 @@ impl BlockIngestError {
}
/// The last successfully applied block a candidate must extend.
#[derive(Debug, Copy, Clone)]
pub struct Tip {
pub block_id: BlockId,
pub hash: HashType,
@ -73,7 +74,7 @@ pub struct Tip {
/// Checks that `block` is the valid continuation of `tip`: hash integrity,
/// then block-id continuity, then `prev_block_hash` linkage. A `None` tip
/// (cold store) expects the genesis block.
pub fn validate_against_tip(tip: Option<&Tip>, block: &Block) -> Result<(), BlockIngestError> {
pub fn validate_against_tip(tip: Option<Tip>, block: &Block) -> Result<(), BlockIngestError> {
let computed = block.recompute_hash();
if computed != block.header.hash {
return Err(BlockIngestError::HashMismatch {

View File

@ -253,7 +253,7 @@ impl IndexerStore {
return Ok(AcceptOutcome::AlreadyApplied);
}
if let Err(err) = chain_consistency::validate_against_tip(tip.as_ref(), block) {
if let Err(err) = chain_consistency::validate_against_tip(tip, block) {
self.record_stall(Some(&block.header), l1_slot, err.clone())?;
return Ok(AcceptOutcome::Parked(err));
}

View File

@ -68,21 +68,24 @@ impl SequencerStore {
signing_key: lee::PrivateKey,
) -> DbResult<Self> {
let genesis_id = dbio.get_meta_first_block_in_db()?;
let last_id = dbio.latest_block_meta()?.id;
let last_id = dbio.latest_block_meta()?.map(|meta| meta.id);
info!("Preparing block cache");
let mut tx_hash_to_block_map = HashMap::new();
for i in genesis_id..=last_id {
let block = dbio
.get_block(i)?
.expect("Block should be present in the database");
tx_hash_to_block_map.extend(block_to_transactions_map(&block));
if let Some(last_id) = last_id {
info!("Preparing block cache");
for i in genesis_id..=last_id {
let block = dbio
.get_block(i)?
.expect("Block should be present in the database");
tx_hash_to_block_map.extend(block_to_transactions_map(&block));
}
info!(
"Block cache prepared. Total blocks in cache: {}",
tx_hash_to_block_map.len()
);
}
info!(
"Block cache prepared. Total blocks in cache: {}",
tx_hash_to_block_map.len()
);
Ok(Self {
dbio,
@ -131,7 +134,7 @@ impl SequencerStore {
);
}
pub fn latest_block_meta(&self) -> DbResult<BlockMeta> {
pub fn latest_block_meta(&self) -> DbResult<Option<BlockMeta>> {
self.dbio.latest_block_meta()
}
@ -299,7 +302,7 @@ mod tests {
.unwrap();
// Verify that initially the latest block hash equals genesis hash
let latest_meta = node_store.latest_block_meta().unwrap();
let latest_meta = node_store.latest_block_meta().unwrap().unwrap();
assert_eq!(latest_meta.hash, genesis_hash);
}
@ -337,7 +340,7 @@ mod tests {
.unwrap();
// Verify that the latest block meta now equals the new block's hash
let latest_meta = node_store.latest_block_meta().unwrap();
let latest_meta = node_store.latest_block_meta().unwrap().unwrap();
assert_eq!(latest_meta.hash, block_hash);
}

View File

@ -201,7 +201,8 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
let latest_block_meta = store
.latest_block_meta()
.expect("Failed to read latest block meta from store");
.expect("Failed to read latest block meta from store")
.expect("Sequencer store should have at least the genesis block after reconstruction");
let sequencer_core = Self {
state,
@ -256,14 +257,16 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
let local_tip = store
.latest_block_meta()
.context("Failed to read latest block meta")?
.id;
.map(|meta| meta.id);
let had_checkpoint_before_start = !is_fresh_start;
let has_committed_to_channel = local_tip > GENESIS_BLOCK_ID && had_checkpoint_before_start;
if has_committed_to_channel && channel_tip_slot.is_none() {
if let Some(local_tip) = local_tip
&& had_checkpoint_before_start
&& channel_tip_slot.is_none()
{
return Err(anyhow!(
"Sequencer holds committed blocks (tip {local_tip}) but the Bedrock channel \
no longer exists on the connected chain the channel was wiped or the node \
points at a different chain. Refusing to resume onto a foreign channel."
no longer exists on the connected chain the channel was wiped or the node \
points at a different chain. Refusing to resume onto a foreign channel."
));
}
@ -348,7 +351,9 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
// A block at/below the tip must match what we already stored, otherwise
// the channel is a different chain.
if block_id <= tip.id {
if let Some(tip) = &tip
&& block_id <= tip.id
{
match store
.get_block_at_id(block_id)
.context("Failed to read stored block")?
@ -375,14 +380,14 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
}
// New continuation: validate it chains onto the tip, then apply.
let cc_tip = Tip {
let cc_tip = tip.as_ref().map(|tip| Tip {
block_id: tip.id,
hash: tip.hash,
};
chain_consistency::validate_against_tip(Some(&cc_tip), block).map_err(|err| {
});
chain_consistency::validate_against_tip(cc_tip, block).map_err(|err| {
anyhow!(
"Channel block {block_id} does not extend local tip {}: {err}",
tip.id
"Channel block {block_id} does not extend local tip {:?}: {err}",
tip.map(|tip| tip.id)
)
})?;
@ -650,7 +655,8 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
let latest_block_meta = self
.store
.latest_block_meta()
.context("Failed to get latest block meta from store")?;
.context("Failed to get latest block meta from store")?
.context("Sequencer store should have at least the genesis block")?;
let new_block_timestamp = u64::try_from(chrono::Utc::now().timestamp_millis())
.expect("Timestamp must be positive");

View File

@ -627,7 +627,7 @@ async fn produce_block_with_correct_prev_meta_after_restart() {
sequencer.produce_new_block().await.unwrap();
// Get the metadata of the last block produced
sequencer.store.latest_block_meta().unwrap()
sequencer.store.latest_block_meta().unwrap().unwrap()
};
// Step 2: Restart sequencer from the same storage

View File

@ -27,7 +27,7 @@ fn block_to_channel_message(block: &Block, slot: u64) -> (ZoneMessage, Slot) {
/// one block per slot at `slot_step` spacing.
fn channel_from_store(store: &SequencerStore, slot_step: u64) -> Vec<(ZoneMessage, Slot)> {
let genesis_id = store.genesis_id();
let tip_id = store.latest_block_meta().expect("tip").id;
let tip_id = store.latest_block_meta().expect("tip").expect("present").id;
(genesis_id..=tip_id)
.enumerate()
.map(|(index, id)| {
@ -45,7 +45,7 @@ async fn reconstructs_missing_channel_blocks_into_fresh_store() {
SequencerCoreWithMockClients::start_from_config(config_a.clone()).await;
seq_a.produce_new_block().await.unwrap();
seq_a.produce_new_block().await.unwrap();
let tip_a = seq_a.block_store().latest_block_meta().unwrap();
let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap();
let messages = channel_from_store(seq_a.block_store(), 10);
let tip_slot = messages.last().unwrap().1;
@ -67,7 +67,7 @@ async fn reconstructs_missing_channel_blocks_into_fresh_store() {
.expect("reconstruct");
assert!(!channel_was_empty);
let tip_b = store_b.latest_block_meta().unwrap();
let tip_b = store_b.latest_block_meta().unwrap().unwrap();
assert_eq!(tip_b.id, tip_a.id);
assert_eq!(tip_b.hash, tip_a.hash);
@ -93,7 +93,7 @@ async fn reconstructs_missing_channel_blocks_into_fresh_store() {
.await
.expect("reconstruct idempotent");
assert!(!channel_was_empty);
assert_eq!(store_b.latest_block_meta().unwrap().id, tip_a.id);
assert_eq!(store_b.latest_block_meta().unwrap().unwrap().id, tip_a.id);
}
#[tokio::test]
@ -243,7 +243,7 @@ async fn reconstructed_deposit_is_not_reminted_after_backfill_redelivery() {
.unwrap();
seq_a.produce_new_block().await.unwrap();
let tip_a = seq_a.block_store().latest_block_meta().unwrap();
let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap();
let messages = channel_from_store(seq_a.block_store(), 10);
let tip_slot = messages.last().unwrap().1;
let channel_id = config_a.bedrock_config.channel_id;
@ -274,9 +274,9 @@ async fn reconstructed_deposit_is_not_reminted_after_backfill_redelivery() {
)
.await
.expect("reconstruct");
seq_b.chain_height = seq_b.store.latest_block_meta().unwrap().id;
seq_b.chain_height = seq_b.store.latest_block_meta().unwrap().unwrap().id;
let tip_b = seq_b.block_store().latest_block_meta().unwrap();
let tip_b = seq_b.block_store().latest_block_meta().unwrap().unwrap();
assert_eq!(tip_b.id, tip_a.id);
assert_eq!(tip_b.hash, tip_a.hash);
@ -412,7 +412,7 @@ async fn reconstruction_reconciles_already_finished_deposit() {
.await
.unwrap();
seq_a.produce_new_block().await.unwrap();
let deposit_block_id = seq_a.block_store().latest_block_meta().unwrap().id;
let deposit_block_id = seq_a.block_store().latest_block_meta().unwrap().unwrap().id;
let messages = channel_from_store(seq_a.block_store(), 10);
let tip_slot = messages.last().unwrap().1;
@ -476,14 +476,14 @@ async fn committed_local_against_missing_channel_fails_without_anchor() {
SequencerCoreWithMockClients::start_from_config(config.clone()).await;
seq.produce_new_block().await.unwrap();
seq.produce_new_block().await.unwrap();
assert!(seq.block_store().latest_block_meta().unwrap().id > 1);
assert!(seq.block_store().latest_block_meta().unwrap().unwrap().id > 1);
} // drop releases the store so we can reopen it
// Reopen: blocks beyond genesis, no anchor. `is_fresh_start = false` stands in
// for a checkpoint persisted by a prior sync (the mock never emits one).
let (mut store, mut state) = SequencerCore::<MockBlockPublisher>::open_or_create_store(&config);
assert!(store.get_zone_anchor().unwrap().is_none());
assert!(store.latest_block_meta().unwrap().id > 1);
assert!(store.latest_block_meta().unwrap().unwrap().id > 1);
// The channel is gone: no tip, no messages.
let mock =

View File

@ -344,8 +344,9 @@ impl RocksDBIO {
self.put_batch(&LatestBlockMetaCellRef(block_meta), (), batch)
}
pub fn latest_block_meta(&self) -> DbResult<BlockMeta> {
self.get::<LatestBlockMetaCellOwned>(()).map(|val| val.0)
pub fn latest_block_meta(&self) -> DbResult<Option<BlockMeta>> {
self.get_opt::<LatestBlockMetaCellOwned>(())
.map(|val| val.map(|cell| cell.0))
}
pub fn get_zone_sdk_checkpoint_bytes(&self) -> DbResult<Option<Vec<u8>>> {