fix(indexer): use better error types, tend to few @Arjentix PR review

This commit is contained in:
erhant 2026-07-03 22:36:42 +03:00
parent 9f2fa83945
commit 378d3ab9c2
7 changed files with 136 additions and 80 deletions

View File

@ -3,5 +3,6 @@
"bedrock_config": { "bedrock_config": {
"addr": "http://logos-blockchain-node-0:18080" "addr": "http://logos-blockchain-node-0:18080"
}, },
"channel_id": "0101010101010101010101010101010101010101010101010101010101010101" "channel_id": "0101010101010101010101010101010101010101010101010101010101010101",
"allow_chain_reset": true
} }

View File

@ -186,9 +186,11 @@ impl IndexerStore {
})) }))
} }
/// Records the stall reason: the first break is stored verbatim; subsequent /// Record the stall reason.
/// breaks only bump `orphans_since`, preserving the original cause. ///
fn record_stall( /// - First stall is stored verbatim
/// - Subsequent stalls only bump `orphans_since`, preserving the original cause.
pub fn record_stall(
&self, &self,
header: Option<&BlockHeader>, header: Option<&BlockHeader>,
l1_slot: Slot, l1_slot: Slot,
@ -200,6 +202,7 @@ impl IndexerStore {
existing existing
} }
None => StallReason { None => StallReason {
// need to map out of `header` because they are not ser/de
block_id: header.map(|h| h.block_id), block_id: header.map(|h| h.block_id),
block_hash: header.map(|h| h.hash), block_hash: header.map(|h| h.hash),
prev_block_hash: header.map(|h| h.prev_block_hash), prev_block_hash: header.map(|h| h.prev_block_hash),
@ -212,11 +215,6 @@ impl IndexerStore {
self.set_stall_reason(&Some(stall)) self.set_stall_reason(&Some(stall))
} }
/// Records a stall for an inscription that could not even be parsed.
pub fn record_deserialize_stall(&self, l1_slot: Slot, error: String) -> Result<()> {
self.record_stall(None, l1_slot, BlockIngestError::Deserialize(error))
}
/// Validates `block` against the tip and, if it chains, applies it atomically /// Validates `block` against the tip and, if it chains, applies it atomically
/// (scratch clone, commit only on full success) and advances the tip. On any /// (scratch clone, commit only on full success) and advances the tip. On any
/// failure records the stall and returns `Parked` without touching state. /// failure records the stall and returns `Parked` without touching state.
@ -306,25 +304,26 @@ fn validate_against_tip(tip: Option<&Tip>, block: &Block) -> Result<(), BlockIng
/// [`BlockIngestError`] so the caller can park rather than crash. Operates on a /// [`BlockIngestError`] so the caller can park rather than crash. Operates on a
/// scratch state; the caller commits only on `Ok`. /// scratch state; the caller commits only on `Ok`.
fn apply_block_to_scratch(block: &Block, state: &mut V03State) -> Result<(), BlockIngestError> { fn apply_block_to_scratch(block: &Block, state: &mut V03State) -> Result<(), BlockIngestError> {
let (clock_tx, user_txs) = let (clock_tx, user_txs) = block
block.body.transactions.split_last().ok_or_else(|| { .body
BlockIngestError::StateTransition("block has no transactions".to_owned()) .transactions
})?; .split_last()
.ok_or(BlockIngestError::EmptyBlock)?;
let expected_clock = LeeTransaction::Public(clock_invocation(block.header.timestamp)); let expected_clock = LeeTransaction::Public(clock_invocation(block.header.timestamp));
if *clock_tx != expected_clock { if *clock_tx != expected_clock {
return Err(BlockIngestError::StateTransition( return Err(BlockIngestError::InvalidClockTransaction);
"last transaction must be the clock invocation for the block timestamp".to_owned(),
));
} }
let is_genesis = block.header.block_id == GENESIS_BLOCK_ID; let is_genesis = block.header.block_id == GENESIS_BLOCK_ID;
for transaction in user_txs { for (tx_index, transaction) in user_txs.iter().enumerate() {
let state_transition = |err: anyhow::Error| BlockIngestError::StateTransition {
tx_index: tx_index.try_into().expect("tx index fits in u64"),
reason: format!("{err:#}"),
};
if is_genesis { if is_genesis {
let LeeTransaction::Public(public_tx) = transaction else { let LeeTransaction::Public(public_tx) = transaction else {
return Err(BlockIngestError::StateTransition( return Err(BlockIngestError::NonPublicGenesisTransaction);
"genesis block should contain only public transactions".to_owned(),
));
}; };
state state
.transition_from_public_transaction( .transition_from_public_transaction(
@ -332,19 +331,17 @@ fn apply_block_to_scratch(block: &Block, state: &mut V03State) -> Result<(), Blo
block.header.block_id, block.header.block_id,
block.header.timestamp, block.header.timestamp,
) )
.map_err(|err| BlockIngestError::StateTransition(format!("{err:?}")))?; .map_err(|err| state_transition(err.into()))?;
} else { } else {
transaction transaction
.clone() .clone()
.execute_on_state(state, block.header.block_id, block.header.timestamp) .execute_on_state(state, block.header.block_id, block.header.timestamp)
.map_err(|err| BlockIngestError::StateTransition(format!("{err:?}")))?; .map_err(|err| state_transition(err.into()))?;
} }
} }
let LeeTransaction::Public(clock_public_tx) = clock_tx else { let LeeTransaction::Public(clock_public_tx) = clock_tx else {
return Err(BlockIngestError::StateTransition( return Err(BlockIngestError::InvalidClockTransaction);
"clock invocation must be a public transaction".to_owned(),
));
}; };
state state
.transition_from_public_transaction( .transition_from_public_transaction(
@ -352,7 +349,10 @@ fn apply_block_to_scratch(block: &Block, state: &mut V03State) -> Result<(), Blo
block.header.block_id, block.header.block_id,
block.header.timestamp, block.header.timestamp,
) )
.map_err(|err| BlockIngestError::StateTransition(format!("{err:?}")))?; .map_err(|err| BlockIngestError::StateTransition {
tx_index: user_txs.len().try_into().expect("tx index fits in u64"),
reason: format!("{:#}", anyhow::Error::from(err)),
})?;
Ok(()) Ok(())
} }
@ -376,7 +376,10 @@ mod stall_reason_tests {
block_hash: Some(HashType([1_u8; 32])), block_hash: Some(HashType([1_u8; 32])),
prev_block_hash: Some(HashType([2_u8; 32])), prev_block_hash: Some(HashType([2_u8; 32])),
l1_slot: Slot::from(42), l1_slot: Slot::from(42),
error: BlockIngestError::StateTransition("boom".to_owned()), error: BlockIngestError::StateTransition {
tx_index: 0,
reason: "boom".to_owned(),
},
first_seen: Some(99), first_seen: Some(99),
orphans_since: 3, orphans_since: 3,
}; };
@ -385,7 +388,10 @@ mod stall_reason_tests {
let got = store.get_stall_reason().expect("get").expect("present"); let got = store.get_stall_reason().expect("get").expect("present");
assert_eq!(got.block_id, Some(7)); assert_eq!(got.block_id, Some(7));
assert_eq!(got.orphans_since, 3); assert_eq!(got.orphans_since, 3);
assert!(matches!(got.error, BlockIngestError::StateTransition(_))); assert!(matches!(
got.error,
BlockIngestError::StateTransition { .. }
));
assert_eq!(got.block_hash, Some(HashType([1_u8; 32]))); assert_eq!(got.block_hash, Some(HashType([1_u8; 32])));
assert_eq!(got.prev_block_hash, Some(HashType([2_u8; 32]))); assert_eq!(got.prev_block_hash, Some(HashType([2_u8; 32])));
assert_eq!(got.l1_slot, Slot::from(42)); assert_eq!(got.l1_slot, Slot::from(42));
@ -591,7 +597,11 @@ mod accept_tests {
let store = IndexerStore::open_db(dir.path()).expect("open store"); let store = IndexerStore::open_db(dir.path()).expect("open store");
store store
.record_deserialize_stall(Slot::from(0), "bad bytes".to_owned()) .record_stall(
None,
Slot::from(0),
BlockIngestError::Deserialize("bad bytes".to_owned()),
)
.expect("record"); .expect("record");
let stall = store.get_stall_reason().expect("get").expect("present"); let stall = store.get_stall_reason().expect("get").expect("present");

View File

@ -1,26 +1,43 @@
use common::HashType; use common::HashType;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Why the indexer could not apply an L2 block from the channel. Stored inside a /// Why the indexer could not apply an L2 block from the channel.
/// [`crate::stall_reason::StallReason`] and surfaced on the status snapshot. ///
/// Persisted in `RocksDB`, so every variant must have the following
/// traits: `Clone + Serialize + Deserialize`.
#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)] #[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
pub enum BlockIngestError { pub enum BlockIngestError {
#[error("failed to deserialize L2 block: {0}")] #[error("Failed to deserialize L2 block: {0}")]
/// Here we store the error string that is derived from [`borsh::from_slice`]'s [`Err`].
Deserialize(String), Deserialize(String),
#[error("unexpected block id: expected {expected}, got {got}")] #[error("Unexpected block id: expected {expected}, got {got}")]
UnexpectedBlockId { expected: u64, got: u64 }, UnexpectedBlockId { expected: u64, got: u64 },
#[error("broken chain link: expected prev {expected_prev}, got {got_prev}")] #[error("Broken chain link: expected prev {expected_prev}, got {got_prev}")]
BrokenChainLink { BrokenChainLink {
expected_prev: HashType, expected_prev: HashType,
got_prev: HashType, got_prev: HashType,
}, },
#[error("block hash mismatch: computed {computed}, header {header}")] #[error("Block hash mismatch: computed {computed}, header {header}")]
HashMismatch { HashMismatch {
computed: HashType, computed: HashType,
header: HashType, header: HashType,
}, },
#[error("state transition failed: {0}")] #[error("Block has no transactions")]
StateTransition(String), EmptyBlock,
#[error("Last transaction must be the public clock invocation for the block timestamp")]
InvalidClockTransaction,
#[error("Genesis block must contain only public transactions")]
NonPublicGenesisTransaction,
#[error("State transition failed at transaction {tx_index}: {reason}")]
StateTransition {
/// Index of the failing transaction within the block body.
tx_index: u64,
/// Reason string from `lee::Error` to `anyhow::Error` to `{:#}`.
///
/// This is required because `lee::Error` is not `Clone + Serialize + Deserialize`, so we
/// cannot store it directly.
reason: String,
},
} }
#[cfg(test)] #[cfg(test)]
@ -47,10 +64,4 @@ mod tests {
} }
)); ));
} }
#[test]
fn display_is_human_readable() {
let err = BlockIngestError::StateTransition("nonce too low".to_owned());
assert_eq!(err.to_string(), "state transition failed: nonce too low");
}
} }

View File

@ -2,7 +2,7 @@ use std::{path::Path, sync::Arc};
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
use common::block::Block; use common::{HashType, block::Block};
// TODO: Remove after testnet // TODO: Remove after testnet
use futures::StreamExt as _; use futures::StreamExt as _;
pub use ingest_error::BlockIngestError; pub use ingest_error::BlockIngestError;
@ -29,7 +29,25 @@ enum ChainIdentityOutcome {
/// or the check was inconclusive — none of which prove a reset. /// or the check was inconclusive — none of which prove a reset.
Consistent, Consistent,
/// The channel serves a different block at one of our ids — a chain reset. /// The channel serves a different block at one of our ids — a chain reset.
Mismatch { detail: String }, Mismatch(ChainMismatch),
}
/// The differing pair behind a [`ChainIdentityOutcome::Mismatch`]: our stored
/// block vs. the block the channel serves at the same id, as `(block_id, hash)`.
struct ChainMismatch {
ours: (u64, HashType),
channel: (u64, HashType),
}
impl std::fmt::Display for ChainMismatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Self { ours, channel } = self;
write!(
f,
"stored block {} {} != channel block {} {}",
ours.0, ours.1, channel.0, channel.1
)
}
} }
#[derive(Clone)] #[derive(Clone)]
@ -51,18 +69,19 @@ impl IndexerCore {
let core = Self::open(config.clone(), storage_dir)?; let core = Self::open(config.clone(), storage_dir)?;
match core.chain_identity_outcome().await? { match core.chain_identity_outcome().await? {
ChainIdentityOutcome::Consistent => Ok(core), ChainIdentityOutcome::Consistent => Ok(core),
ChainIdentityOutcome::Mismatch { detail } if config.allow_chain_reset => { ChainIdentityOutcome::Mismatch(mismatch) if config.allow_chain_reset => {
warn!( warn!(
"Chain reset detected ({detail}). Wiping indexer store at {} and re-indexing.", "Chain reset detected ({mismatch}). Wiping indexer store at {} and \
re-indexing.",
home.display() home.display()
); );
drop(core); // sole owner before the ingest task is spawned → closes the DB drop(core); // sole owner before the ingest task is spawned → closes the DB
storage::indexer::RocksDBIO::destroy(&home)?; storage::indexer::RocksDBIO::destroy(&home)?;
Self::open(config, storage_dir) Self::open(config, storage_dir)
} }
ChainIdentityOutcome::Mismatch { detail } => Err(anyhow::anyhow!( ChainIdentityOutcome::Mismatch(mismatch) => Err(anyhow::anyhow!(
"Indexer store at {} holds a different chain than the channel now serves \ "Indexer store at {} holds a different chain than the channel now serves \
({detail}). Delete the indexer storage directory, point at a fresh one, or \ ({mismatch}). Delete the indexer storage directory, point at a fresh one, or \
set `allow_chain_reset` in the indexer config.", set `allow_chain_reset` in the indexer config.",
home.display() home.display()
)), )),
@ -121,14 +140,16 @@ impl IndexerCore {
Ok(compare_block(&ours, &channel_block)) Ok(compare_block(&ours, &channel_block))
} }
/// Reads the first block the channel serves at/after the tip's slot. `next_messages` /// Reads the first block the channel serves at/after the tip's slot.
/// is exclusive, so `cursor - 1` includes the tip's own slot. `None` = inconclusive ///
/// (timeout/error, or bedrock's LIB behind our tip → empty stream). /// `None` = inconclusive (timeout/error, or bedrock's LIB behind our tip → empty stream).
async fn fetch_channel_block_from(&self, cursor: Slot) -> Result<Option<Block>> { async fn fetch_channel_block_from(&self, cursor: Slot) -> Result<Option<Block>> {
const TIP_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); const TIP_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
// Slot-0 cursor is degenerate (inscriptions live at wall-clock slots); bail
// rather than let `next_messages(None)` do a from-genesis scan. // `next_messages` is exclusive, so `cursor - 1` includes the tip's own slot.
let Some(from_slot) = cursor.into_inner().checked_sub(1) else { let Some(from_slot) = cursor.into_inner().checked_sub(1) else {
// Slot-0 cursor is degenerate (inscriptions live at wall-clock slots);
// so we bail rather than let `next_messages(None)` do a from-genesis scan.
return Ok(None); return Ok(None);
}; };
let fetch = async { let fetch = async {
@ -192,13 +213,20 @@ impl IndexerCore {
/// Parks on an inscription that could not be parsed as an L2 block: /// Parks on an inscription that could not be parsed as an L2 block:
/// records the stall and flips the status. The validated tip stays frozen. /// records the stall and flips the status. The validated tip stays frozen.
fn park_undeserializable(&self, slot: Slot, error: &impl std::fmt::Display) { fn park_undeserializable(&self, slot: Slot, error: std::io::Error) {
error!("Failed to deserialize L2 block from zone-sdk: {error}"); let error = anyhow::Error::new(error);
if let Err(err) = self.store.record_deserialize_stall(slot, error.to_string()) {
// use `:#` to get the entire error chain
let reason = format!("{error:#}");
error!("Failed to deserialize L2 block from zone-sdk: {reason}");
if let Err(err) =
self.store
.record_stall(None, slot, BlockIngestError::Deserialize(reason.clone()))
{
warn!("Failed to record stall reason: {err:#}"); warn!("Failed to record stall reason: {err:#}");
} }
self.set_status(IndexerSyncStatus::stalled(format!( self.set_status(IndexerSyncStatus::stalled(format!(
"failed to deserialize L2 block: {error}" "failed to deserialize L2 block: {reason}"
))); )));
} }
@ -250,15 +278,7 @@ impl IndexerCore {
let block: Block = match borsh::from_slice(&zone_block.data) { let block: Block = match borsh::from_slice(&zone_block.data) {
Ok(b) => b, Ok(b) => b,
Err(error) => { Err(error) => {
error!("Failed to deserialize L2 block from zone-sdk: {error}"); self.park_undeserializable(slot, error);
if let Err(err) = self.store.record_deserialize_stall(slot, error.to_string()) {
warn!("Failed to record stall reason: {err:#}");
}
self.set_status(IndexerSyncStatus::stalled(format!(
"failed to deserialize L2 block: {error}"
)));
// L1 proceeds regardless // L1 proceeds regardless
self.advance_cursor(&mut cursor, slot); self.advance_cursor(&mut cursor, slot);
continue; continue;
@ -325,15 +345,10 @@ fn compare_block(ours: &Block, channel: &Block) -> ChainIdentityOutcome {
if ours.header.hash == channel.header.hash { if ours.header.hash == channel.header.hash {
ChainIdentityOutcome::Consistent ChainIdentityOutcome::Consistent
} else { } else {
ChainIdentityOutcome::Mismatch { ChainIdentityOutcome::Mismatch(ChainMismatch {
detail: format!( ours: (ours.header.block_id, ours.header.hash),
"stored block {} {} != channel block {} {}", channel: (channel.header.block_id, channel.header.hash),
ours.header.block_id, })
ours.header.hash,
channel.header.block_id,
channel.header.hash
),
}
} }
} }
@ -362,7 +377,7 @@ mod chain_identity_tests {
let current = block_with_prev(2); let current = block_with_prev(2);
assert!(matches!( assert!(matches!(
compare_block(&stored, &current), compare_block(&stored, &current),
ChainIdentityOutcome::Mismatch { .. } ChainIdentityOutcome::Mismatch(_)
)); ));
} }
} }

View File

@ -129,7 +129,10 @@ mod tests {
block_hash: None, block_hash: None,
prev_block_hash: None, prev_block_hash: None,
l1_slot: Slot::from(0), l1_slot: Slot::from(0),
error: BlockIngestError::StateTransition("boom".to_owned()), error: BlockIngestError::StateTransition {
tx_index: 0,
reason: Default::default(),
},
first_seen: None, first_seen: None,
orphans_since: 2, orphans_since: 2,
}), }),

View File

@ -745,7 +745,16 @@ impl From<indexer_core::BlockIngestError> for BlockIngestError {
header: header.into(), header: header.into(),
} }
} }
indexer_core::BlockIngestError::StateTransition(msg) => Self::StateTransition(msg), indexer_core::BlockIngestError::EmptyBlock => Self::EmptyBlock,
indexer_core::BlockIngestError::InvalidClockTransaction => {
Self::InvalidClockTransaction
}
indexer_core::BlockIngestError::NonPublicGenesisTransaction => {
Self::NonPublicGenesisTransaction
}
indexer_core::BlockIngestError::StateTransition { tx_index, reason } => {
Self::StateTransition { tx_index, reason }
}
} }
} }
} }

View File

@ -397,7 +397,14 @@ pub enum BlockIngestError {
computed: HashType, computed: HashType,
header: HashType, header: HashType,
}, },
StateTransition(String), EmptyBlock,
InvalidClockTransaction,
NonPublicGenesisTransaction,
StateTransition {
/// Index of the failing transaction within the block body.
tx_index: u64,
reason: String,
},
} }
/// Diagnostic record of the first block that broke the L2 chain. /// Diagnostic record of the first block that broke the L2 chain.