2026-06-23 14:33:38 +03:00
|
|
|
use std::{path::Path, sync::Arc};
|
2026-02-12 14:27:36 +02:00
|
|
|
|
2026-06-26 20:37:35 +03:00
|
|
|
use anyhow::{Context as _, Result};
|
2026-06-23 14:33:38 +03:00
|
|
|
use arc_swap::ArcSwap;
|
2026-06-27 17:50:08 +03:00
|
|
|
use common::block::Block;
|
|
|
|
|
// 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-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-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-06-27 19:22:54 +03:00
|
|
|
/// Result of comparing the indexer's stored tip against the channel.
|
2026-06-27 17:50:08 +03:00
|
|
|
enum ChainIdentityOutcome {
|
2026-06-27 19:22:54 +03:00
|
|
|
/// Proceed from the persisted cursor. Either the channel still serves our tip
|
|
|
|
|
/// (same chain), there is nothing to compare (empty store / parked / no cursor),
|
|
|
|
|
/// or the check was inconclusive (L1 unreadable, or bedrock's LIB still behind
|
|
|
|
|
/// our tip slot) — none of which prove a reset.
|
2026-06-26 20:37:35 +03:00
|
|
|
Consistent,
|
2026-06-27 19:22:54 +03:00
|
|
|
/// The channel serves a *different* block at the tip's id — a chain reset.
|
2026-06-27 17:50:08 +03:00
|
|
|
Mismatch { detail: String },
|
|
|
|
|
}
|
|
|
|
|
|
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-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 {
|
feat(indexer)!: make storage location caller-driven, not config-driven
The indexer's storage location was the `home` field of IndexerConfig, used only to derive the RocksDB dir. Defaulting to "." meant it landed in the process CWD — fine for the standalone service, but wrong when the indexer runs embedded in a logos_host subprocess (RocksDB ended up in an arbitrary/unwritable dir). Storage location is an operational concern the host should own, not something baked into a user-editable config.
Remove `home` from IndexerConfig and pass the storage directory explicitly:
- core: `IndexerCore::new(config, storage_dir)` derives `<storage_dir>/rocksdb`.
- ffi: `start_indexer(runtime, config_path, storage_dir)`; null/empty storage_dir falls back to ".". Lets a host (e.g. a Logos module's instance persistence path) own where state lives.
- service: `run_server(config, storage_dir, port)` + a `--data-dir` flag (default ".") on the binary, preserving current behaviour.
- drop `home` from the committed indexer config JSONs and the test fixtures.
BREAKING CHANGE: `start_indexer` gains a `storage_dir` parameter and IndexerConfig no longer has a `home` field.
2026-06-19 23:18:41 +03:00
|
|
|
pub fn new(config: IndexerConfig, storage_dir: &Path) -> Result<Self> {
|
2026-06-22 13:16:35 +03:00
|
|
|
// 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));
|
2026-02-03 11:36:07 +02:00
|
|
|
|
2026-04-29 14:05:23 +02:00
|
|
|
let basic_auth = config.bedrock_config.auth.clone().map(Into::into);
|
2026-04-29 10:33:07 +02:00
|
|
|
let node = NodeHttpClient::new(
|
|
|
|
|
CommonHttpClient::new(basic_auth),
|
2026-04-29 14:05:23 +02:00
|
|
|
config.bedrock_config.addr.clone(),
|
2026-03-09 12:31:49 +00:00
|
|
|
);
|
2026-04-29 10:33:07 +02:00
|
|
|
let zone_indexer = ZoneIndexer::new(config.channel_id, node);
|
2026-03-09 12:31:49 +00:00
|
|
|
|
2026-01-12 15:51:24 +02:00
|
|
|
Ok(Self {
|
2026-03-09 12:31:49 +00:00
|
|
|
zone_indexer: Arc::new(zone_indexer),
|
2026-01-12 15:51:24 +02:00
|
|
|
config,
|
2026-04-10 20:23:25 +03:00
|
|
|
store: IndexerStore::open_db(&home)?,
|
2026-06-23 14:33:38 +03:00
|
|
|
status: Arc::new(ArcSwap::from_pointee(IndexerSyncStatus::starting())),
|
2026-01-12 15:51:24 +02:00
|
|
|
})
|
2026-01-09 15:10:38 +02:00
|
|
|
}
|
2026-01-12 15:51:24 +02:00
|
|
|
|
2026-06-27 17:50:08 +03:00
|
|
|
/// Builds the core, then verifies the stored chain matches the channel's by
|
2026-06-27 19:22:54 +03:00
|
|
|
/// re-reading the channel at the stored tip's position. On mismatch: refuse
|
|
|
|
|
/// (error) unless `allow_reset`, in which case wipe the store and re-index from
|
|
|
|
|
/// scratch. Used at service/FFI startup in place of `new`.
|
2026-06-27 17:50:08 +03:00
|
|
|
pub async fn new_with_chain_check(
|
2026-06-26 20:37:35 +03:00
|
|
|
config: IndexerConfig,
|
|
|
|
|
storage_dir: &Path,
|
|
|
|
|
allow_reset: bool,
|
|
|
|
|
) -> Result<Self> {
|
|
|
|
|
let home = storage_dir.join(format!("rocksdb-{}", config.channel_id));
|
|
|
|
|
let core = Self::new(config.clone(), storage_dir)?;
|
2026-06-27 17:50:08 +03:00
|
|
|
match core.chain_identity_outcome().await? {
|
|
|
|
|
ChainIdentityOutcome::Consistent => Ok(core),
|
|
|
|
|
ChainIdentityOutcome::Mismatch { detail } if allow_reset => {
|
2026-06-26 20:37:35 +03:00
|
|
|
warn!(
|
2026-06-27 17:50:08 +03:00
|
|
|
"Chain reset detected ({detail}). 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)?;
|
|
|
|
|
Self::new(config, storage_dir)
|
|
|
|
|
}
|
2026-06-27 17:50:08 +03:00
|
|
|
ChainIdentityOutcome::Mismatch { detail } => Err(anyhow::anyhow!(
|
2026-06-26 20:37:35 +03:00
|
|
|
"Indexer store at {} holds a different chain than the channel now serves \
|
2026-06-27 17:50:08 +03:00
|
|
|
({detail}). Run `just clean`, point at a fresh storage dir, or set \
|
|
|
|
|
`allow_chain_reset` in the indexer config.",
|
2026-06-26 20:37:35 +03:00
|
|
|
home.display()
|
|
|
|
|
)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 19:22:54 +03:00
|
|
|
/// Verifies the channel still serves our chain at the tip's L1 slot (the persisted
|
|
|
|
|
/// cursor), by comparing the first block it serves there against our stored block
|
|
|
|
|
/// of the *same id*.
|
|
|
|
|
///
|
|
|
|
|
/// This is mostly a development convenience: it lets the indexer notice that its
|
|
|
|
|
/// local state belongs to a different chain than the one it is now connected to
|
|
|
|
|
/// (e.g. a wiped/restarted bedrock) and reset instead of silently diverging. It
|
|
|
|
|
/// will not trigger during normal live indexing. Reading at the cursor — which is
|
|
|
|
|
/// recent — keeps this to ~one batch rather than a scan from genesis (L1 slots
|
|
|
|
|
/// are wall-clock-derived, so genesis is millions of empty slots away).
|
2026-06-27 17:50:08 +03:00
|
|
|
async fn chain_identity_outcome(&self) -> Result<ChainIdentityOutcome> {
|
2026-06-27 19:22:54 +03:00
|
|
|
// We deliberately do NOT skip parked stores: a parked store must still be able
|
|
|
|
|
// to detect a reset, or it stays parked across reboots forever (its persisted
|
|
|
|
|
// stall would short-circuit the check every boot). A same-chain park is still
|
|
|
|
|
// safe — the parked block sits at an id we never applied, so the lookup below
|
|
|
|
|
// misses and we stay Consistent.
|
|
|
|
|
let Some(cursor) = self.store.get_zone_cursor()? else {
|
|
|
|
|
return Ok(ChainIdentityOutcome::Consistent); // empty / cold store: nothing to verify
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Compare the first block the channel serves at/after the cursor against our
|
|
|
|
|
// stored block of the same id. On the same chain that is our tip and matches;
|
|
|
|
|
// a reset serves a different block here — crucially including a freshly
|
|
|
|
|
// restarted, *shorter* chain whose low-id block at this slot differs from ours
|
|
|
|
|
// (the old "look for our tip id" approach missed this: a short chain has no
|
|
|
|
|
// block at our tip id).
|
|
|
|
|
//
|
|
|
|
|
// Anything inconclusive stays Consistent (proceed, let ingest park if truly
|
|
|
|
|
// divergent) rather than wiping a valid store: an empty/unreadable read (most
|
|
|
|
|
// importantly bedrock's LIB still behind our tip slot), or a channel block at
|
|
|
|
|
// an id we don't hold. Blind spot: a store holding only genesis can't be
|
|
|
|
|
// distinguished (genesis is deterministic across chains), but that window is
|
|
|
|
|
// transient.
|
|
|
|
|
let Some(channel_block) = self.fetch_channel_block_from(cursor).await? else {
|
2026-06-27 17:50:08 +03:00
|
|
|
return Ok(ChainIdentityOutcome::Consistent);
|
|
|
|
|
};
|
2026-06-27 19:22:54 +03:00
|
|
|
let Some(ours) = self.store.get_block_at_id(channel_block.header.block_id)? else {
|
|
|
|
|
return Ok(ChainIdentityOutcome::Consistent);
|
|
|
|
|
};
|
|
|
|
|
Ok(compare_block(&ours, &channel_block))
|
2026-06-26 20:37:35 +03:00
|
|
|
}
|
|
|
|
|
|
2026-06-27 19:22:54 +03:00
|
|
|
/// Reads the channel starting at the tip's L1 slot (the `cursor`) and returns the
|
|
|
|
|
/// first block it serves there. `next_messages` is exclusive of its argument, so
|
|
|
|
|
/// `cursor - 1` is passed to include the tip's own slot.
|
2026-06-26 21:55:54 +03:00
|
|
|
///
|
2026-06-27 19:22:54 +03:00
|
|
|
/// Cheap: the cursor is recent, so this reads roughly one batch. `None` covers the
|
|
|
|
|
/// inconclusive cases — a slow/unreachable L1 (timeout/error) or bedrock's LIB
|
|
|
|
|
/// still behind our tip slot (empty stream) — neither of which proves a reset.
|
|
|
|
|
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);
|
|
|
|
|
// A slot-0 cursor is degenerate (real inscriptions live at wall-clock slots);
|
|
|
|
|
// bail rather than let `next_messages(None)` fall back to a from-genesis scan.
|
|
|
|
|
let Some(from_slot) = cursor.into_inner().checked_sub(1) else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
2026-06-26 20:37:35 +03:00
|
|
|
let fetch = async {
|
2026-06-27 19:22:54 +03:00
|
|
|
let stream = self
|
|
|
|
|
.zone_indexer
|
|
|
|
|
.next_messages(Some(Slot::from(from_slot)))
|
|
|
|
|
.await?;
|
2026-06-26 20:37:35 +03:00
|
|
|
let mut stream = std::pin::pin!(stream);
|
|
|
|
|
while let Some((msg, _slot)) = stream.next().await {
|
2026-06-27 19:22:54 +03:00
|
|
|
if let ZoneMessage::Block(zone_block) = msg {
|
|
|
|
|
let block: Block = borsh::from_slice(&zone_block.data)
|
|
|
|
|
.context("Failed to deserialize channel block")?;
|
|
|
|
|
return Ok::<Option<Block>, anyhow::Error>(Some(block));
|
2026-06-26 20:37:35 +03:00
|
|
|
}
|
|
|
|
|
}
|
2026-06-27 19:22:54 +03:00
|
|
|
Ok(None)
|
2026-06-26 20:37:35 +03:00
|
|
|
};
|
2026-06-27 19:22:54 +03:00
|
|
|
match tokio::time::timeout(TIP_FETCH_TIMEOUT, fetch).await {
|
|
|
|
|
Ok(Ok(found)) => Ok(found),
|
2026-06-26 21:55:54 +03:00
|
|
|
Ok(Err(err)) => {
|
2026-06-27 19:22:54 +03:00
|
|
|
warn!("Failed to read channel tip for the consistency check; proceeding: {err:#}");
|
|
|
|
|
Ok(None)
|
2026-06-26 21:55:54 +03:00
|
|
|
}
|
2026-06-26 20:37:35 +03:00
|
|
|
Err(_elapsed) => {
|
2026-06-27 19:22:54 +03:00
|
|
|
warn!("Timed out reading channel tip for the consistency check; proceeding");
|
|
|
|
|
Ok(None)
|
2026-06-26 20:37:35 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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());
|
|
|
|
|
let indexed_block_id = self.store.get_last_block_id().ok().flatten();
|
2026-06-26 15:34:17 +03:00
|
|
|
let stall_reason = self.store.get_stall_reason().ok().flatten();
|
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-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,
|
|
|
|
|
ZoneMessage::Deposit(_) | ZoneMessage::Withdraw(_) => continue,
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-26 16:41:39 +03:00
|
|
|
let l1_slot = serde_json::to_value(slot).unwrap_or(serde_json::Value::Null);
|
2026-06-26 16:39:37 +03:00
|
|
|
|
2026-04-29 11:06:05 +02:00
|
|
|
let block: Block = match borsh::from_slice(&zone_block.data) {
|
|
|
|
|
Ok(b) => b,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
error!("Failed to deserialize L2 block from zone-sdk: {e}");
|
2026-06-26 16:39:37 +03:00
|
|
|
if let Err(err) =
|
|
|
|
|
self.store.record_deserialize_stall(l1_slot, e.to_string())
|
|
|
|
|
{
|
|
|
|
|
warn!("Failed to record stall reason: {err:#}");
|
|
|
|
|
}
|
|
|
|
|
self.set_status(IndexerSyncStatus::stalled(format!(
|
|
|
|
|
"failed to deserialize L2 block: {e}"
|
|
|
|
|
)));
|
|
|
|
|
// Advance the L1 read cursor past the broken inscription;
|
|
|
|
|
// the validated tip stays frozen.
|
2026-05-27 00:17:26 +03:00
|
|
|
cursor = Some(slot);
|
|
|
|
|
if let Err(err) = self.store.set_zone_cursor(&slot) {
|
2026-04-29 14:05:23 +02:00
|
|
|
warn!("Failed to persist indexer cursor: {err:#}");
|
|
|
|
|
}
|
2026-04-29 11:06:05 +02:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-26 16:39:37 +03:00
|
|
|
match self.store.accept_block(&block, l1_slot).await {
|
|
|
|
|
Ok(AcceptOutcome::Applied) => {
|
|
|
|
|
info!("Indexed L2 block {}", block.header.block_id);
|
|
|
|
|
self.set_status(IndexerSyncStatus::syncing());
|
|
|
|
|
cursor = Some(slot);
|
|
|
|
|
if let Err(err) = self.store.set_zone_cursor(&slot) {
|
|
|
|
|
warn!("Failed to persist indexer cursor: {err:#}");
|
|
|
|
|
}
|
|
|
|
|
yield Ok(block);
|
|
|
|
|
}
|
|
|
|
|
Ok(AcceptOutcome::Parked(ingest_err)) => {
|
|
|
|
|
error!(
|
|
|
|
|
"Parked at block {}: {ingest_err}",
|
|
|
|
|
block.header.block_id
|
|
|
|
|
);
|
|
|
|
|
self.set_status(IndexerSyncStatus::stalled(ingest_err.to_string()));
|
|
|
|
|
// Advance the L1 read cursor; tip stays frozen, no yield.
|
|
|
|
|
cursor = Some(slot);
|
|
|
|
|
if let Err(err) = self.store.set_zone_cursor(&slot) {
|
|
|
|
|
warn!("Failed to persist indexer cursor: {err:#}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(err) => {
|
|
|
|
|
// Infrastructure error (DB read/write), not a bad block.
|
|
|
|
|
// Keep the cursor put; re-poll the same position next cycle.
|
|
|
|
|
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.
|
|
|
|
|
if self.store.get_stall_reason().ok().flatten().is_none() {
|
|
|
|
|
self.set_status(IndexerSyncStatus::caught_up());
|
|
|
|
|
}
|
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
|
|
|
}
|
2026-06-26 20:37:35 +03:00
|
|
|
|
2026-06-27 19:22:54 +03:00
|
|
|
/// Pure comparison of our stored block against the channel's block at the same id:
|
|
|
|
|
/// a mismatch is differing hashes. Missing/unreadable cases are handled upstream.
|
|
|
|
|
fn compare_block(ours: &Block, channel: &Block) -> ChainIdentityOutcome {
|
|
|
|
|
if ours.header.hash == channel.header.hash {
|
2026-06-27 17:50:08 +03:00
|
|
|
ChainIdentityOutcome::Consistent
|
|
|
|
|
} else {
|
|
|
|
|
ChainIdentityOutcome::Mismatch {
|
|
|
|
|
detail: format!(
|
2026-06-27 19:22:54 +03:00
|
|
|
"stored block {} {} != channel block {} {}",
|
|
|
|
|
ours.header.block_id,
|
|
|
|
|
ours.header.hash,
|
|
|
|
|
channel.header.block_id,
|
|
|
|
|
channel.header.hash
|
2026-06-27 17:50:08 +03:00
|
|
|
),
|
|
|
|
|
}
|
2026-06-26 20:37:35 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
2026-06-27 17:50:08 +03:00
|
|
|
mod chain_identity_tests {
|
2026-06-26 20:37:35 +03:00
|
|
|
use common::{HashType, block::Block, test_utils::produce_dummy_block};
|
|
|
|
|
|
2026-06-27 19:22:54 +03:00
|
|
|
use super::{ChainIdentityOutcome, compare_block};
|
2026-06-26 20:37:35 +03:00
|
|
|
|
2026-06-27 19:22:54 +03:00
|
|
|
fn block_with_prev(prev_seed: u8) -> Block {
|
|
|
|
|
produce_dummy_block(5, Some(HashType([prev_seed; 32])), vec![])
|
2026-06-26 20:37:35 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-06-27 19:22:54 +03:00
|
|
|
fn matching_block_is_consistent() {
|
|
|
|
|
let b = block_with_prev(1);
|
2026-06-26 20:37:35 +03:00
|
|
|
assert!(matches!(
|
2026-06-27 19:22:54 +03:00
|
|
|
compare_block(&b, &b),
|
2026-06-27 17:50:08 +03:00
|
|
|
ChainIdentityOutcome::Consistent
|
2026-06-26 20:37:35 +03:00
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-06-27 19:22:54 +03:00
|
|
|
fn differing_block_is_mismatch() {
|
|
|
|
|
let stored = block_with_prev(1);
|
|
|
|
|
let current = block_with_prev(2);
|
2026-06-26 20:37:35 +03:00
|
|
|
assert!(matches!(
|
2026-06-27 19:22:54 +03:00
|
|
|
compare_block(&stored, ¤t),
|
2026-06-27 17:50:08 +03:00
|
|
|
ChainIdentityOutcome::Mismatch { .. }
|
2026-06-26 20:37:35 +03:00
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|