fix(sequencer): add apply_produced which is like apply_adopted but for our own block, which is not in channel yet

This commit is contained in:
erhant 2026-07-23 09:33:05 +03:00
parent 1feebc1d91
commit 71b59ac1b8
4 changed files with 82 additions and 10 deletions

View File

@ -161,6 +161,22 @@ impl ChainState {
}
}
/// Applies a block we produced ourselves.
///
/// Unlike [`Self::apply_adopted`] this never reorgs: our block is not on
/// the channel yet, so it may only *extend* the head. A head already at
/// (or past) this height means a peer's block won the race on the
/// channel — ours is stale and the caller drops it.
pub fn apply_produced(&mut self, this_msg: MsgId, block: &Block) -> AcceptOutcome {
if self
.head_tip()
.is_some_and(|tip| block.header.block_id <= tip.block_id)
{
return AcceptOutcome::AlreadyApplied;
}
self.apply_adopted(this_msg, block)
}
/// Reverts an orphaned head block and everything after it, then re-derives head.
pub fn revert_orphan(&mut self, this_msg: MsgId, block: &Block) {
if let Some(idx) = self.head_position_of(this_msg, block) {
@ -632,6 +648,49 @@ mod tests {
assert_head_matches_replay(&chain);
}
#[test]
fn produced_block_losing_a_race_does_not_reorg_the_head() {
let accounts = initial_pub_accounts_private_keys();
let from = accounts[0].account_id;
let to = accounts[1].account_id;
let sign_key = accounts[0].pub_sign_key.clone();
let mut chain = ChainState::new(initial_state());
let genesis = produce_dummy_block(1, None, vec![]);
chain.apply_adopted(msg(1), &genesis);
// A peer's block wins height 2 on the channel.
let peer = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
chain.apply_adopted(msg(2), &peer);
// Our own block at that height is not on the channel, so — unlike an
// adopted competitor — it must not reorg the head onto itself.
let tx = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key);
let ours = produce_dummy_block(2, Some(genesis.header.hash), vec![tx]);
assert!(matches!(
chain.apply_produced(msg(12), &ours),
AcceptOutcome::AlreadyApplied
));
assert_eq!(chain.head_tip().expect("head tip").hash, peer.header.hash);
assert_eq!(chain.head_state().get_account_by_id(to).balance, 20000);
assert_head_matches_replay(&chain);
}
#[test]
fn produced_block_extending_the_head_applies() {
let mut chain = ChainState::new(initial_state());
let genesis = produce_dummy_block(1, None, vec![]);
chain.apply_adopted(msg(1), &genesis);
let ours = produce_dummy_block(2, Some(genesis.header.hash), vec![]);
assert!(matches!(
chain.apply_produced(msg(2), &ours),
AcceptOutcome::Applied
));
assert_eq!(chain.head_tip().expect("head tip").hash, ours.header.hash);
assert_head_matches_replay(&chain);
}
#[test]
fn invalid_adopted_competitor_leaves_head_intact() {
let mut chain = ChainState::new(initial_state());

View File

@ -668,7 +668,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
withdrawal_reconciliation_keys: Vec<WithdrawalReconciliationKey>,
) -> Result<()> {
let mut chain = self.chain.lock().expect("chain state mutex poisoned");
match chain.apply_adopted(this_msg, block) {
match chain.apply_produced(this_msg, block) {
AcceptOutcome::Applied => {
// Persisted under the lock so disk writes land in apply order
// with the follow path.

View File

@ -1680,7 +1680,15 @@ async fn record_produced_block_skips_persistence_on_lost_race() {
.apply_adopted(MsgId::from([9; 32]), &peer_block);
// Our competing block at the same height: same parent, different content.
let tx = common::test_utils::produce_dummy_empty_transaction();
let acc1 = initial_public_user_accounts()[0].account_id;
let acc2 = initial_public_user_accounts()[1].account_id;
let tx = common::test_utils::create_transaction_native_token_transfer(
acc1,
0,
acc2,
10,
&create_signing_key_for_account1(),
);
let our_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![tx]);
sequencer
.record_produced_block(

View File

@ -749,21 +749,26 @@ impl RocksDBIO {
self.put_block_payload(&to_write, &mut batch)?;
}
// `head_tip` is `None` only for a chain holding no blocks at all, which
// implies nothing was applied — and the store, created with genesis,
// cannot represent it. No tip to pin, nothing to persist.
let Some(tip) = head_tip else {
debug_assert!(batch.is_empty() && final_snapshot.is_none());
return Ok(());
};
// A shrink-only update (orphans without adopted replacements) has no
// payloads to write but must still rewind the tip meta, or the stored
// state tears against the stale disk head on the next produce.
let tip_rewound = head_tip.is_some_and(|tip| tip.id < last_block_in_db);
if batch.is_empty() && final_snapshot.is_none() && !tip_rewound {
if batch.is_empty() && final_snapshot.is_none() && tip.id == last_block_in_db {
return Ok(());
}
if let Some(tip) = head_tip {
for stale_id in tip.id.saturating_add(1)..=last_block_in_db {
self.delete_block_payload(stale_id, &mut batch)?;
}
self.put_meta_last_block_in_db_batch(tip.id, &mut batch)?;
self.put_meta_latest_block_meta_batch(tip, &mut batch)?;
for stale_id in tip.id.saturating_add(1)..=last_block_in_db {
self.delete_block_payload(stale_id, &mut batch)?;
}
self.put_meta_last_block_in_db_batch(tip.id, &mut batch)?;
self.put_meta_latest_block_meta_batch(tip, &mut batch)?;
self.put_lee_state_in_db_batch(state, &mut batch)?;
if let Some((final_state, final_meta)) = final_snapshot {
self.put_final_snapshot_batch(final_state, final_meta, &mut batch)?;