From 15ebccb11eb073a9d70c0608626ed5c1cc3b9feb Mon Sep 17 00:00:00 2001 From: erhant Date: Wed, 1 Jul 2026 20:04:32 +0300 Subject: [PATCH] fix(indexer): catch the edge case of re-using the last valid tip on restart --- lez/indexer/core/src/block_store.rs | 74 ++++++++++++++++++++++++++++- lez/indexer/core/src/lib.rs | 10 ++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/lez/indexer/core/src/block_store.rs b/lez/indexer/core/src/block_store.rs index f0f09ec8..f5fd08b4 100644 --- a/lez/indexer/core/src/block_store.rs +++ b/lez/indexer/core/src/block_store.rs @@ -24,6 +24,8 @@ struct Tip { pub enum AcceptOutcome { /// Chained and applied; tip and L1 read cursor both advance. Applied, + /// A duplicate re-delivery of the current tip. Just L2 advances. + AlreadyApplied, /// Did not chain or failed to apply; tip stays frozen, stall recorded. Parked(BlockIngestError), } @@ -266,6 +268,15 @@ impl IndexerStore { block: &Block, l1_slot: serde_json::Value, ) -> Result { + // idempotent edge case: re-delivery of the current tip + // (e.g. after a crash that left the L1 cursor behind the applied tip) + if let Some(tip) = self.validated_tip()? + && block.header.block_id == tip.block_id + && block.header.hash == tip.hash + { + return Ok(AcceptOutcome::AlreadyApplied); + } + if let Some(err) = self.acceptance_error(block)? { self.record_stall(Some(&block.header), l1_slot, err.clone())?; return Ok(AcceptOutcome::Parked(err)); @@ -286,7 +297,12 @@ impl IndexerStore { // Commit in-memory state (infallible) only after the DB write succeeded. *self.current_state.write().await = scratch; - self.set_stall_reason(&None)?; + // Clear a recorded stall now that a block has chained and applied. Only write + // when one is actually present, so the steady state does no extra per-block + // write (and can't turn a transient clear-write error into a spurious park). + if self.get_stall_reason()?.is_some() { + self.set_stall_reason(&None)?; + } Ok(AcceptOutcome::Applied) } } @@ -656,4 +672,60 @@ mod accept_tests { "tip must advance to the recovered block" ); } + + #[tokio::test] + async fn redelivered_tip_block_is_idempotent_not_parked() { + use testnet_initial_state::initial_pub_accounts_private_keys; + + let dir = tempfile::tempdir().expect("tempdir"); + let store = IndexerStore::open_db(dir.path()).expect("open store"); + + 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 genesis = produce_dummy_block(1, None, vec![]); + store + .accept_block(&genesis, serde_json::Value::Null) + .await + .expect("accept genesis"); + + // Block 2: a single transfer of 10. + let tx = common::test_utils::create_transaction_native_token_transfer( + from, 0, to, 10, &sign_key, + ); + let block = produce_dummy_block(2, Some(genesis.header.hash), vec![tx]); + assert!(matches!( + store + .accept_block(&block, serde_json::Value::Null) + .await + .unwrap(), + AcceptOutcome::Applied + )); + let balance_after = store.account_current_state(&from).await.unwrap().balance; + + // Re-deliver the exact same block: idempotent skip, no state change, no park. + assert!(matches!( + store + .accept_block(&block, serde_json::Value::Null) + .await + .unwrap(), + AcceptOutcome::AlreadyApplied + )); + assert_eq!( + store.account_current_state(&from).await.unwrap().balance, + balance_after, + "re-delivered block must not be applied twice" + ); + assert_eq!( + store.get_last_block_id().unwrap(), + Some(2), + "tip must stay at the already-applied block" + ); + assert!( + store.get_stall_reason().unwrap().is_none(), + "a benign duplicate must not park the indexer" + ); + } } diff --git a/lez/indexer/core/src/lib.rs b/lez/indexer/core/src/lib.rs index dfe3eca6..ba0e7ace 100644 --- a/lez/indexer/core/src/lib.rs +++ b/lez/indexer/core/src/lib.rs @@ -261,6 +261,16 @@ impl IndexerCore { } yield Ok(block); } + Ok(AcceptOutcome::AlreadyApplied) => { + info!( + "Skipping already-applied block {}", + block.header.block_id + ); + cursor = Some(slot); + if let Err(err) = self.store.set_zone_cursor(&slot) { + warn!("Failed to persist indexer cursor: {err:#}"); + } + } Ok(AcceptOutcome::Parked(ingest_err)) => { error!( "Parked at block {}: {ingest_err}",