From 93182f054564574abbdbd1ad391b76b036bfe298 Mon Sep 17 00:00:00 2001 From: erhant Date: Thu, 9 Jul 2026 14:02:38 +0300 Subject: [PATCH] fix(indexer): do not park immediately on possibly-transient apply failures --- lez/indexer/core/src/block_store.rs | 48 ++++++++++++++++++++++++++++ lez/indexer/core/src/ingest_error.rs | 13 ++++++++ lez/indexer/core/src/lib.rs | 9 ++++++ 3 files changed, 70 insertions(+) diff --git a/lez/indexer/core/src/block_store.rs b/lez/indexer/core/src/block_store.rs index ed142285..db3bed79 100644 --- a/lez/indexer/core/src/block_store.rs +++ b/lez/indexer/core/src/block_store.rs @@ -29,6 +29,11 @@ pub enum AcceptOutcome { AlreadyApplied, /// Did not chain or failed to apply; tip stays frozen, stall recorded. Parked(BlockIngestError), + /// Chained but failed to apply, possibly transiently + /// ([`BlockIngestError::is_retryable`]); nothing recorded, tip and state + /// untouched. The caller retries and parks via + /// [`IndexerStore::record_stall`] once it gives up. + ApplyFailed(BlockIngestError), } #[derive(Clone)] @@ -254,6 +259,9 @@ impl IndexerStore { // TODO: we use scratch state to be atomic, but need to revisit how expensive a clone is let mut scratch = self.current_state.read().await.clone(); if let Err(err) = apply_block_to_scratch(block, &mut scratch) { + if err.is_retryable() { + return Ok(AcceptOutcome::ApplyFailed(err)); + } self.record_stall(Some(&block.header), l1_slot, err.clone())?; return Ok(AcceptOutcome::Parked(err)); } @@ -876,4 +884,44 @@ mod accept_tests { let reopened = IndexerStore::open_db(dir.path()).expect("reopen"); assert_eq!(reopened.last_block().unwrap(), Some(101)); } + + #[tokio::test] + async fn transient_apply_failure_returns_apply_failed_without_stall() { + 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, Slot::from(0)) + .await + .expect("accept genesis"); + + // Overdraft: rejected during execution → StateTransition → retryable. + let tx = common::test_utils::create_transaction_native_token_transfer( + from, + 0, + to, + 1_000_000_000, + &sign_key, + ); + let block = produce_dummy_block(2, Some(genesis.header.hash), vec![tx]); + let outcome = store.accept_block(&block, Slot::from(0)).await.unwrap(); + + assert!(matches!( + outcome, + AcceptOutcome::ApplyFailed(BlockIngestError::StateTransition { .. }) + )); + assert!( + store.get_stall_reason().unwrap().is_none(), + "retryable failure must not persist a stall" + ); + assert_eq!(store.get_last_block_id().unwrap(), Some(1), "tip frozen"); + } } diff --git a/lez/indexer/core/src/ingest_error.rs b/lez/indexer/core/src/ingest_error.rs index bda53497..299013d7 100644 --- a/lez/indexer/core/src/ingest_error.rs +++ b/lez/indexer/core/src/ingest_error.rs @@ -40,6 +40,19 @@ pub enum BlockIngestError { }, } +impl BlockIngestError { + /// Whether the failure may be transient rather than a property of the block. + /// + /// FIXME: `StateTransition` is too coarse — its `reason` string mixes genuine + /// state-transition rejections with infra failures (risc0 executor teardown, + /// storage errors). Once it carries a structured cause, narrow this so only + /// infra failures retry. + #[must_use] + pub const fn is_retryable(&self) -> bool { + matches!(self, Self::StateTransition { .. }) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/lez/indexer/core/src/lib.rs b/lez/indexer/core/src/lib.rs index 77839296..f748bf75 100644 --- a/lez/indexer/core/src/lib.rs +++ b/lez/indexer/core/src/lib.rs @@ -236,6 +236,15 @@ impl IndexerCore { // L1 proceeds regardless self.advance_cursor(&mut cursor, slot); } + Ok(AcceptOutcome::ApplyFailed(ingest_err)) => { + error!( + "Apply failed at block {}: {ingest_err}", + block.header.block_id + ); + self.set_status(IndexerSyncStatus::error(ingest_err.to_string())); + had_cycle_error = true; + break; + } Err(err) => { // Infrastructure error (DB read/write), not a bad block. // will re-poll from the same cursor next cycle.