fix(indexer): do not park immediately on possibly-transient apply failures

This commit is contained in:
erhant 2026-07-09 14:02:38 +03:00
parent c9f7beb95f
commit 93182f0545
3 changed files with 70 additions and 0 deletions

View File

@ -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");
}
}

View File

@ -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::*;

View File

@ -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.