2026-06-23 14:33:38 +03:00
|
|
|
use std::{path::Path, sync::Arc};
|
2026-02-12 14:27:36 +02:00
|
|
|
|
2026-07-07 14:30:10 +03:00
|
|
|
use anyhow::Result;
|
2026-06-23 14:33:38 +03:00
|
|
|
use arc_swap::ArcSwap;
|
2026-07-07 14:30:10 +03:00
|
|
|
use common::block::Block;
|
2026-06-27 17:50:08 +03:00
|
|
|
// TODO: Remove after testnet
|
2026-03-09 12:31:49 +00:00
|
|
|
use futures::StreamExt as _;
|
2026-06-26 14:00:44 +03:00
|
|
|
pub use ingest_error::BlockIngestError;
|
2026-04-29 14:05:23 +02:00
|
|
|
use log::{error, info, warn};
|
2026-04-29 10:33:07 +02:00
|
|
|
use logos_blockchain_zone_sdk::{
|
2026-06-27 19:22:54 +03:00
|
|
|
CommonHttpClient, Slot, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer,
|
2026-01-12 15:51:24 +02:00
|
|
|
};
|
2026-06-26 15:34:17 +03:00
|
|
|
pub use stall_reason::StallReason;
|
2026-01-09 15:10:38 +02:00
|
|
|
|
2026-06-22 18:36:29 +03:00
|
|
|
use crate::{
|
2026-06-26 16:39:37 +03:00
|
|
|
block_store::{AcceptOutcome, IndexerStore},
|
2026-07-07 14:30:10 +03:00
|
|
|
chain_consistency::ChainConsistency,
|
2026-06-22 18:36:29 +03:00
|
|
|
config::IndexerConfig,
|
2026-06-23 14:33:38 +03:00
|
|
|
status::{IndexerStatus, IndexerSyncStatus},
|
2026-06-22 18:36:29 +03:00
|
|
|
};
|
2026-02-03 11:36:07 +02:00
|
|
|
pub mod block_store;
|
2026-07-07 14:30:10 +03:00
|
|
|
pub mod chain_consistency;
|
2026-01-12 15:51:24 +02:00
|
|
|
pub mod config;
|
2026-06-26 14:00:44 +03:00
|
|
|
pub mod ingest_error;
|
2026-06-26 15:34:17 +03:00
|
|
|
pub mod stall_reason;
|
2026-06-22 18:36:29 +03:00
|
|
|
pub mod status;
|
2026-01-12 15:51:24 +02:00
|
|
|
|
2026-01-30 21:37:27 +03:00
|
|
|
#[derive(Clone)]
|
2026-01-09 15:10:38 +02:00
|
|
|
pub struct IndexerCore {
|
2026-04-29 10:33:07 +02:00
|
|
|
pub zone_indexer: Arc<ZoneIndexer<NodeHttpClient>>,
|
2026-07-07 14:30:10 +03:00
|
|
|
/// Direct node handle for queries outside `ZoneIndexer`'s streaming API.
|
|
|
|
|
pub node: NodeHttpClient,
|
2026-02-03 11:36:07 +02:00
|
|
|
pub config: IndexerConfig,
|
|
|
|
|
pub store: IndexerStore,
|
2026-06-22 18:36:29 +03:00
|
|
|
/// Live ingestion status; updated by the ingest stream, read by `status`.
|
2026-06-23 14:33:38 +03:00
|
|
|
pub status: Arc<ArcSwap<IndexerSyncStatus>>,
|
2026-01-09 15:10:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl IndexerCore {
|
2026-06-27 17:50:08 +03:00
|
|
|
/// Builds the core, then verifies the stored chain matches the channel's by
|
2026-07-06 11:23:17 +03:00
|
|
|
/// re-reading the channel at the stored tip's position.
|
|
|
|
|
///
|
|
|
|
|
/// On mismatch: refuse (error) unless `config.allow_chain_reset` is set, in which case wipe the
|
2026-07-02 21:39:30 +03:00
|
|
|
/// store and re-index from scratch.
|
|
|
|
|
pub async fn new(config: IndexerConfig, storage_dir: &Path) -> Result<Self> {
|
2026-06-26 20:37:35 +03:00
|
|
|
let home = storage_dir.join(format!("rocksdb-{}", config.channel_id));
|
2026-07-02 21:39:30 +03:00
|
|
|
let core = Self::open(config.clone(), storage_dir)?;
|
2026-07-06 11:23:17 +03:00
|
|
|
match core.verify_chain_consistency().await? {
|
|
|
|
|
// `Inconclusive` is deliberately treated the same as `Consistent`.
|
|
|
|
|
//
|
|
|
|
|
// We could not prove a reset, so proceed from the cursor without wiping
|
|
|
|
|
// a possibly-valid store. A genuinely divergent chain is still caught
|
|
|
|
|
// later when the ingest loop tries to apply and parks.
|
2026-07-06 11:32:26 +03:00
|
|
|
ChainConsistency::Consistent | ChainConsistency::Inconclusive => Ok(core),
|
|
|
|
|
ChainConsistency::Inconsistent(mismatch) if config.allow_chain_reset => {
|
2026-06-26 20:37:35 +03:00
|
|
|
warn!(
|
2026-07-03 22:36:42 +03:00
|
|
|
"Chain reset detected ({mismatch}). Wiping indexer store at {} and \
|
|
|
|
|
re-indexing.",
|
2026-06-26 20:37:35 +03:00
|
|
|
home.display()
|
|
|
|
|
);
|
|
|
|
|
drop(core); // sole owner before the ingest task is spawned → closes the DB
|
|
|
|
|
storage::indexer::RocksDBIO::destroy(&home)?;
|
2026-07-02 21:39:30 +03:00
|
|
|
Self::open(config, storage_dir)
|
2026-06-26 20:37:35 +03:00
|
|
|
}
|
2026-07-06 11:32:26 +03:00
|
|
|
ChainConsistency::Inconsistent(mismatch) => Err(anyhow::anyhow!(
|
2026-06-26 20:37:35 +03:00
|
|
|
"Indexer store at {} holds a different chain than the channel now serves \
|
2026-07-03 22:36:42 +03:00
|
|
|
({mismatch}). Delete the indexer storage directory, point at a fresh one, or \
|
2026-07-02 21:39:30 +03:00
|
|
|
set `allow_chain_reset` in the indexer config.",
|
2026-06-26 20:37:35 +03:00
|
|
|
home.display()
|
|
|
|
|
)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 21:39:30 +03:00
|
|
|
/// Opens the store and builds the core without the chain-identity check.
|
|
|
|
|
fn open(config: IndexerConfig, storage_dir: &Path) -> Result<Self> {
|
|
|
|
|
// Namespace the DB by channel so indexers on different channels can
|
|
|
|
|
// share a storage dir without their RocksDB state colliding.
|
|
|
|
|
let home = storage_dir.join(format!("rocksdb-{}", config.channel_id));
|
|
|
|
|
|
|
|
|
|
let basic_auth = config.bedrock_config.auth.clone().map(Into::into);
|
|
|
|
|
let node = NodeHttpClient::new(
|
|
|
|
|
CommonHttpClient::new(basic_auth),
|
|
|
|
|
config.bedrock_config.addr.clone(),
|
|
|
|
|
);
|
2026-07-07 14:30:10 +03:00
|
|
|
let zone_indexer = ZoneIndexer::new(config.channel_id, node.clone());
|
2026-07-02 21:39:30 +03:00
|
|
|
|
|
|
|
|
Ok(Self {
|
|
|
|
|
zone_indexer: Arc::new(zone_indexer),
|
2026-07-07 14:30:10 +03:00
|
|
|
node,
|
2026-07-02 21:39:30 +03:00
|
|
|
config,
|
|
|
|
|
store: IndexerStore::open_db(&home)?,
|
|
|
|
|
status: Arc::new(ArcSwap::from_pointee(IndexerSyncStatus::starting())),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 18:36:29 +03:00
|
|
|
/// Snapshot of the current ingestion status (sync state + indexed tip).
|
|
|
|
|
///
|
2026-06-23 14:33:38 +03:00
|
|
|
/// Combines the ingest loop's live status with the L2 tip read fresh from the
|
2026-06-22 18:36:29 +03:00
|
|
|
/// store, so callers (FFI/RPC) can tell "catching up" from "failed".
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn status(&self) -> IndexerStatus {
|
2026-06-23 14:33:38 +03:00
|
|
|
let sync = IndexerSyncStatus::clone(&self.status.load());
|
2026-07-06 11:23:17 +03:00
|
|
|
// Log-and-fall-back rather than collapsing a store error into the same
|
|
|
|
|
// `None` as "legitimately absent": a DB read failure must not silently
|
|
|
|
|
// masquerade as "no tip yet" / "no stall recorded" in the snapshot.
|
|
|
|
|
let indexed_block_id = match self.store.get_last_block_id() {
|
|
|
|
|
Ok(id) => id,
|
|
|
|
|
Err(err) => {
|
|
|
|
|
warn!("Failed to read last indexed block id for status: {err:#}");
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let stall_reason = match self.store.get_stall_reason() {
|
|
|
|
|
Ok(reason) => reason,
|
|
|
|
|
Err(err) => {
|
|
|
|
|
warn!("Failed to read stall reason for status: {err:#}");
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-06-23 14:33:38 +03:00
|
|
|
IndexerStatus {
|
|
|
|
|
sync,
|
|
|
|
|
indexed_block_id,
|
2026-06-26 15:34:17 +03:00
|
|
|
stall_reason,
|
2026-06-23 14:33:38 +03:00
|
|
|
}
|
2026-06-22 18:36:29 +03:00
|
|
|
}
|
|
|
|
|
|
2026-06-23 14:33:38 +03:00
|
|
|
/// Atomically publish a new ingestion status for readers of `status`.
|
|
|
|
|
fn set_status(&self, status: IndexerSyncStatus) {
|
|
|
|
|
self.status.store(Arc::new(status));
|
2026-06-22 18:36:29 +03:00
|
|
|
}
|
|
|
|
|
|
2026-07-02 21:39:30 +03:00
|
|
|
/// Advances the in-memory L1 read cursor past `slot` and persists it.
|
|
|
|
|
/// A persist failure is only logged: the worst case is re-reading a batch
|
|
|
|
|
/// after a restart, which ingestion handles idempotently.
|
|
|
|
|
fn advance_cursor(&self, cursor: &mut Option<Slot>, slot: Slot) {
|
|
|
|
|
*cursor = Some(slot);
|
|
|
|
|
if let Err(err) = self.store.set_zone_cursor(&slot) {
|
|
|
|
|
warn!("Failed to persist indexer cursor: {err:#}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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.
|
2026-07-03 22:36:42 +03:00
|
|
|
fn park_undeserializable(&self, slot: Slot, error: std::io::Error) {
|
|
|
|
|
let error = anyhow::Error::new(error);
|
|
|
|
|
|
|
|
|
|
// 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()))
|
|
|
|
|
{
|
2026-07-02 21:39:30 +03:00
|
|
|
warn!("Failed to record stall reason: {err:#}");
|
|
|
|
|
}
|
|
|
|
|
self.set_status(IndexerSyncStatus::stalled(format!(
|
2026-07-03 22:36:42 +03:00
|
|
|
"failed to deserialize L2 block: {reason}"
|
2026-07-02 21:39:30 +03:00
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 11:06:05 +02:00
|
|
|
pub fn subscribe_parse_block_stream(&self) -> impl futures::Stream<Item = Result<Block>> + '_ {
|
|
|
|
|
let poll_interval = self.config.consensus_info_polling_interval;
|
2026-04-29 14:05:23 +02:00
|
|
|
let initial_cursor = self
|
|
|
|
|
.store
|
|
|
|
|
.get_zone_cursor()
|
|
|
|
|
.expect("Failed to load zone-sdk indexer cursor");
|
2026-01-30 21:37:27 +03:00
|
|
|
|
2026-04-29 11:06:05 +02:00
|
|
|
async_stream::stream! {
|
2026-04-29 14:05:23 +02:00
|
|
|
let mut cursor = initial_cursor;
|
2026-02-12 14:27:36 +02:00
|
|
|
|
2026-04-29 14:05:23 +02:00
|
|
|
if cursor.is_some() {
|
|
|
|
|
info!("Resuming indexer from cursor {cursor:?}");
|
|
|
|
|
} else {
|
|
|
|
|
info!("Starting indexer from beginning of channel");
|
|
|
|
|
}
|
2026-02-12 14:27:36 +02:00
|
|
|
|
2026-04-29 11:06:05 +02:00
|
|
|
loop {
|
|
|
|
|
let stream = match self.zone_indexer.next_messages(cursor).await {
|
|
|
|
|
Ok(s) => s,
|
|
|
|
|
Err(err) => {
|
|
|
|
|
error!("Failed to start zone-sdk next_messages stream: {err}");
|
2026-06-23 14:33:38 +03:00
|
|
|
self.set_status(IndexerSyncStatus::error(format!(
|
|
|
|
|
"cannot reach L1 / read channel: {err}"
|
|
|
|
|
)));
|
2026-04-29 11:06:05 +02:00
|
|
|
tokio::time::sleep(poll_interval).await;
|
2026-03-09 12:31:49 +00:00
|
|
|
continue;
|
2026-01-16 16:15:21 +02:00
|
|
|
}
|
2026-03-03 23:21:08 +03:00
|
|
|
};
|
2026-04-29 11:06:05 +02:00
|
|
|
let mut stream = std::pin::pin!(stream);
|
|
|
|
|
|
2026-06-22 18:36:29 +03:00
|
|
|
let mut announced_syncing = false;
|
2026-06-26 16:39:37 +03:00
|
|
|
let mut had_cycle_error = false;
|
2026-06-22 18:36:29 +03:00
|
|
|
|
2026-04-29 11:06:05 +02:00
|
|
|
while let Some((msg, slot)) = stream.next().await {
|
2026-06-22 18:36:29 +03:00
|
|
|
if !announced_syncing {
|
2026-06-23 14:33:38 +03:00
|
|
|
self.set_status(IndexerSyncStatus::syncing());
|
2026-06-22 18:36:29 +03:00
|
|
|
announced_syncing = true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 11:06:05 +02:00
|
|
|
let zone_block = match msg {
|
|
|
|
|
ZoneMessage::Block(b) => b,
|
2026-07-01 20:50:01 +03:00
|
|
|
// FIXME: will be handled in prep of decentralized sequencers
|
2026-04-29 11:06:05 +02:00
|
|
|
ZoneMessage::Deposit(_) | ZoneMessage::Withdraw(_) => continue,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let block: Block = match borsh::from_slice(&zone_block.data) {
|
|
|
|
|
Ok(b) => b,
|
2026-07-02 21:39:30 +03:00
|
|
|
Err(error) => {
|
2026-07-03 22:36:42 +03:00
|
|
|
self.park_undeserializable(slot, error);
|
2026-07-02 21:39:30 +03:00
|
|
|
// L1 proceeds regardless
|
|
|
|
|
self.advance_cursor(&mut cursor, slot);
|
2026-04-29 11:06:05 +02:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-02 21:39:30 +03:00
|
|
|
match self.store.accept_block(&block, slot).await {
|
2026-06-26 16:39:37 +03:00
|
|
|
Ok(AcceptOutcome::Applied) => {
|
|
|
|
|
info!("Indexed L2 block {}", block.header.block_id);
|
|
|
|
|
self.set_status(IndexerSyncStatus::syncing());
|
2026-07-02 21:39:30 +03:00
|
|
|
self.advance_cursor(&mut cursor, slot);
|
2026-06-26 16:39:37 +03:00
|
|
|
yield Ok(block);
|
|
|
|
|
}
|
2026-07-01 20:04:32 +03:00
|
|
|
Ok(AcceptOutcome::AlreadyApplied) => {
|
|
|
|
|
info!(
|
|
|
|
|
"Skipping already-applied block {}",
|
|
|
|
|
block.header.block_id
|
|
|
|
|
);
|
2026-07-02 21:39:30 +03:00
|
|
|
self.advance_cursor(&mut cursor, slot);
|
2026-07-01 20:04:32 +03:00
|
|
|
}
|
2026-06-26 16:39:37 +03:00
|
|
|
Ok(AcceptOutcome::Parked(ingest_err)) => {
|
|
|
|
|
error!(
|
|
|
|
|
"Parked at block {}: {ingest_err}",
|
|
|
|
|
block.header.block_id
|
|
|
|
|
);
|
|
|
|
|
self.set_status(IndexerSyncStatus::stalled(ingest_err.to_string()));
|
2026-07-02 21:39:30 +03:00
|
|
|
// L1 proceeds regardless
|
|
|
|
|
self.advance_cursor(&mut cursor, slot);
|
2026-06-26 16:39:37 +03:00
|
|
|
}
|
|
|
|
|
Err(err) => {
|
|
|
|
|
// Infrastructure error (DB read/write), not a bad block.
|
2026-07-02 21:39:30 +03:00
|
|
|
// will re-poll from the same cursor next cycle.
|
2026-06-26 16:39:37 +03:00
|
|
|
error!(
|
|
|
|
|
"Store error applying block {}: {err:#}",
|
|
|
|
|
block.header.block_id
|
|
|
|
|
);
|
|
|
|
|
self.set_status(IndexerSyncStatus::error(format!(
|
|
|
|
|
"store error: {err:#}"
|
|
|
|
|
)));
|
|
|
|
|
had_cycle_error = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
2026-04-29 11:06:05 +02:00
|
|
|
}
|
2026-06-26 16:39:37 +03:00
|
|
|
}
|
2026-02-16 17:54:54 +02:00
|
|
|
|
2026-06-26 16:39:37 +03:00
|
|
|
if had_cycle_error {
|
|
|
|
|
tokio::time::sleep(poll_interval).await;
|
|
|
|
|
continue;
|
2026-02-24 12:27:47 +02:00
|
|
|
}
|
2026-02-16 17:54:54 +02:00
|
|
|
|
2026-06-26 16:39:37 +03:00
|
|
|
// Stream drained. Stay Stalled if parked; otherwise we are caught up.
|
2026-07-06 11:23:17 +03:00
|
|
|
// A store error here must not be collapsed to "no stall recorded":
|
|
|
|
|
// that would wrongly flip us to caught-up, so we log and hold state.
|
|
|
|
|
match self.store.get_stall_reason() {
|
|
|
|
|
Ok(None) => self.set_status(IndexerSyncStatus::caught_up()),
|
|
|
|
|
Ok(Some(_)) => {}
|
|
|
|
|
Err(err) => {
|
|
|
|
|
warn!("Failed to read stall reason after draining stream; not marking caught up: {err:#}");
|
|
|
|
|
}
|
2026-06-26 16:39:37 +03:00
|
|
|
}
|
2026-04-29 11:06:05 +02:00
|
|
|
tokio::time::sleep(poll_interval).await;
|
2026-02-16 17:54:54 +02:00
|
|
|
}
|
2026-01-16 16:15:21 +02:00
|
|
|
}
|
2026-01-12 15:51:24 +02:00
|
|
|
}
|
2026-02-04 14:57:38 +02:00
|
|
|
}
|