Skip to main content

sui_rpc_store/indexer/
mod.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Indexer pipelines that populate the `sui-rpc-store` schema
5//! from observed [`Checkpoint`]s, plus the orchestrator
6//! ([`Indexer`]) that wires them up against a shared
7//! [`Synchronizer`].
8//!
9//! Each pipeline submodule implements the
10//! `Processor` + `sequential::Handler` pair the
11//! `sui-indexer-alt-framework` drives: `process` turns a checkpoint
12//! into a `Vec<Value>` (with the heavy lifting done in the
13//! processor-pool, off the commit hot path), `batch` folds many
14//! values into a single `Batch`, and `commit` stages the batch's
15//! writes against a [`sui_consistent_store::Connection`] from
16//! [`sui_consistent_store::Store`].
17//!
18//! Every pipeline targets the same backing [`RpcStoreSchema`].
19
20pub mod balance;
21pub mod checkpoint_broadcast;
22pub mod checkpoint_contents;
23pub mod checkpoint_seq_by_digest;
24pub mod checkpoint_summary;
25pub mod effects;
26pub mod epochs;
27pub mod event_bitmap;
28pub mod events;
29pub mod object_by_owner;
30pub mod object_by_type;
31pub mod object_version_by_checkpoint;
32pub mod objects;
33pub mod package_versions;
34pub mod pruner;
35pub mod restore;
36pub mod transaction_bitmap;
37pub mod transactions;
38pub mod tx_metadata_by_seq;
39pub mod tx_seq_by_digest;
40
41use std::collections::BTreeMap;
42use std::collections::HashSet;
43use std::collections::btree_map::Entry;
44use std::path::Path;
45use std::sync::Arc;
46
47use anyhow::Context as _;
48use prometheus::Registry;
49use sui_consistent_store::Db;
50use sui_consistent_store::DbOptions;
51use sui_consistent_store::PipelineTaskKey;
52use sui_consistent_store::Synchronizer;
53use sui_consistent_store::restore_state;
54use sui_indexer_alt_framework as framework;
55use sui_indexer_alt_framework::IndexerArgs;
56use sui_indexer_alt_framework::ingestion::ArcStreamingClient;
57use sui_indexer_alt_framework::ingestion::IngestionConfig;
58use sui_indexer_alt_framework::ingestion::ingestion_client::IngestionClient;
59use sui_indexer_alt_framework::pipeline::CommitterConfig;
60use sui_indexer_alt_framework::pipeline::sequential::SequentialConfig;
61use sui_indexer_alt_framework::pipeline::sequential::{self};
62use sui_indexer_alt_framework::service::Service;
63use sui_types::base_types::ObjectID;
64use sui_types::digests::ObjectDigest;
65use sui_types::effects::TransactionEffectsAPI;
66use sui_types::full_checkpoint_content::Checkpoint;
67use sui_types::object::Object;
68
69use crate::RpcStoreSchema;
70use crate::config::PipelineLayer;
71use crate::config::PrunerConfig;
72use crate::indexer::pruner::PrunerMetrics;
73use crate::indexer::restore::LIVE_COHORT;
74
75/// Metrics prefix used for both the framework indexer and the
76/// underlying ingestion service. Surfaced as a constant so the
77/// prefix is consistent across the metrics built in [`Indexer::new`]
78/// and the ones the standalone-binary entry point builds when it
79/// constructs the [`IngestionClient`] from `ClientArgs`.
80pub const METRICS_PREFIX: &str = "rpc_store_indexer";
81
82/// The schema parameter the framework's `Store` / pipelines bind
83/// to.
84pub type Schema = RpcStoreSchema;
85
86/// The store type pipelines commit through.
87pub type Store = sui_consistent_store::Store<Schema>;
88
89/// The sequence number of the first transaction in `checkpoint`.
90///
91/// `network_total_transactions` is the cumulative network-wide tx
92/// count *after* this checkpoint executes, so subtracting the
93/// number of transactions the checkpoint contains gives the
94/// `tx_seq` of its first entry.
95pub fn first_tx_seq(checkpoint: &Checkpoint) -> u64 {
96    checkpoint.summary.network_total_transactions - checkpoint.transactions.len() as u64
97}
98
99/// The `tx_seq` of the transaction at index `i` within
100/// `checkpoint`.
101pub fn tx_seq_at(checkpoint: &Checkpoint, i: usize) -> u64 {
102    first_tx_seq(checkpoint) + i as u64
103}
104
105/// First-seen input version of every object that existed before
106/// the checkpoint and was used as an input to some transaction in
107/// it. Mirrors the helper of the same name in
108/// `sui-indexer-alt-consistent-store::handlers`.
109///
110/// Objects created or unwrapped within the checkpoint are
111/// excluded. Used by the diff-based indexes
112/// ([`object_by_owner`] etc.) to
113/// remove the rows that the *prior* state contributed before
114/// re-inserting the rows that the *posterior* state contributes.
115pub fn checkpoint_input_objects(
116    checkpoint: &Checkpoint,
117) -> anyhow::Result<BTreeMap<ObjectID, (&Object, ObjectDigest)>> {
118    let mut from_this_checkpoint = HashSet::new();
119    let mut input_objects = BTreeMap::new();
120    for tx in &checkpoint.transactions {
121        let input_objects_map: BTreeMap<_, _> = tx
122            .input_objects(&checkpoint.object_set)
123            .map(|obj| ((obj.id(), obj.version()), obj))
124            .collect();
125
126        for change in tx.effects.object_changes() {
127            let id = change.id;
128
129            let Some(version) = change.input_version else {
130                continue;
131            };
132
133            if from_this_checkpoint.contains(&id) {
134                continue;
135            }
136
137            let Entry::Vacant(entry) = input_objects.entry(id) else {
138                continue;
139            };
140
141            let input_object = *input_objects_map
142                .get(&(id, version))
143                .with_context(|| format!("{id} at {version} in effects, not in input_objects"))?;
144
145            // Input digests are only populated in Effects V2. For Effects V1, we need to
146            // compute the digest from the input object's contents.
147            let digest = change.input_digest.unwrap_or_else(|| input_object.digest());
148            entry.insert((input_object, digest));
149        }
150
151        for change in tx.effects.object_changes() {
152            if change.output_version.is_some() {
153                from_this_checkpoint.insert(change.id);
154            }
155        }
156    }
157    Ok(input_objects)
158}
159
160/// Last-seen output version of every object that was created or
161/// modified by some transaction in the checkpoint and is still
162/// live at the end. Mirrors the helper of the same name in
163/// `sui-indexer-alt-consistent-store::handlers`.
164///
165/// Used to populate the checkpoint-pinned
166/// [`object_version_by_checkpoint`] index and the diff-based indexes
167/// once the prior state has been retracted.
168pub fn checkpoint_output_objects(
169    checkpoint: &Checkpoint,
170) -> anyhow::Result<BTreeMap<ObjectID, (&Object, ObjectDigest)>> {
171    let mut output_objects = BTreeMap::new();
172    for tx in &checkpoint.transactions {
173        let output_objects_map: BTreeMap<_, _> = tx
174            .output_objects(&checkpoint.object_set)
175            .map(|obj| ((obj.id(), obj.version()), obj))
176            .collect();
177
178        for change in tx.effects.object_changes() {
179            let id = change.id;
180
181            // Clear the previous entry, in case it was created within this checkpoint.
182            output_objects.remove(&id);
183
184            let (Some(version), Some(digest)) = (change.output_version, change.output_digest)
185            else {
186                continue;
187            };
188
189            let output_object = *output_objects_map
190                .get(&(id, version))
191                .with_context(|| format!("{id} at {version} in effects, not in output_objects"))?;
192
193            output_objects.insert(id, (output_object, digest));
194        }
195    }
196    Ok(output_objects)
197}
198
199/// Top-level orchestrator. Wraps a [`framework::Indexer`] over the
200/// [`Store`] for [`RpcStoreSchema`] together with a
201/// [`Synchronizer`] coordinating cross-pipeline snapshots, and
202/// exposes the per-pipeline registration shape this crate needs.
203///
204/// Construct one of two ways:
205///
206/// - [`Indexer::new`] opens the [`Db`] / [`Store`] internally —
207///   typical for the standalone binary path.
208/// - [`Indexer::from_store`] takes an already-opened [`Store`] —
209///   typical for the embedded-fullnode path where the fullnode
210///   shares the underlying database with this indexer for direct
211///   reads (and possibly for its own raw-chain-data writes).
212///
213/// Pipelines are registered through [`Self::add_pipelines`], which
214/// honours the per-pipeline enable/disable knobs encoded in a
215/// [`PipelineLayer`]. Disabled pipelines are skipped entirely —
216/// the [`Synchronizer`] only barriers across pipelines that were
217/// actually registered, so leaving the raw-chain-data pipelines
218/// off does not stall snapshots. The layer is the only pipeline
219/// selector: the framework's `IndexerArgs::pipeline` filter is
220/// refused, because a pipeline it skipped would still be registered
221/// with the [`Synchronizer`] and hold snapshots back forever.
222///
223/// Every registered [`LIVE_COHORT`] pipeline is a required snapshot
224/// member ([`Synchronizer::register_required_pipeline`]): each
225/// snapshot includes every live index's writes through its
226/// checkpoint, even when a crash left their watermarks uneven, so
227/// the newest snapshot is always a valid point for live-index reads.
228/// Lagging history pipelines do not hold snapshots back.
229///
230/// After pipelines are registered, [`Self::run`] installs the
231/// synchronizer onto the store and starts the framework indexer.
232pub struct Indexer {
233    indexer: framework::Indexer<Store>,
234
235    /// Synchronizer coordinating per-pipeline writes against
236    /// cross-pipeline snapshots. Owned here until [`Self::run`]
237    /// hands it to [`sui_consistent_store::Store::install_sync`].
238    sync: Synchronizer,
239
240    /// Pruning policy and its metrics, present when pruning is
241    /// enabled. [`Self::run`] starts the background pruner from
242    /// these and attaches it to the composed service. `None` leaves
243    /// pruning off (the embedded-fullnode and test defaults).
244    pruner: Option<(PrunerConfig, Arc<PrunerMetrics>)>,
245}
246
247impl Indexer {
248    /// Open the database at `path` with [`RpcStoreSchema`] and
249    /// construct an [`Indexer`] backed by it.
250    ///
251    /// `ingestion_client` is the pull-side checkpoint source; the
252    /// optional `streaming_client` is the live-tail source, and the
253    /// optional `gap_fill_client` fetches the checkpoints in front of
254    /// a newly connected stream instead of `ingestion_client`. Callers
255    /// (standalone binary, embedded fullnode) build the
256    /// [`IngestionClient`] via [`IngestionClient::new`] (driven by
257    /// `ClientArgs`) or [`IngestionClient::from_trait`] (wrapping
258    /// a custom [`IngestionClientTrait`]), depending on where
259    /// checkpoints come from. The [`IngestionMetrics`] handle the
260    /// service shares with the client is reused from
261    /// [`IngestionClient::metrics`], avoiding double-registration
262    /// against `registry`.
263    ///
264    /// [`IngestionClientTrait`]: sui_indexer_alt_framework::ingestion::ingestion_client::IngestionClientTrait
265    /// [`IngestionMetrics`]: sui_indexer_alt_framework::metrics::IngestionMetrics
266    #[allow(clippy::too_many_arguments)]
267    pub async fn new(
268        path: impl AsRef<Path>,
269        indexer_args: IndexerArgs,
270        ingestion_client: IngestionClient,
271        streaming_client: Option<ArcStreamingClient>,
272        gap_fill_client: Option<IngestionClient>,
273        consistency_config: crate::config::ConsistencyConfig,
274        pruner_config: Option<PrunerConfig>,
275        ingestion_config: IngestionConfig,
276        db_options: DbOptions,
277        registry: &Registry,
278    ) -> anyhow::Result<Self> {
279        let (db, schema) = Db::open::<RpcStoreSchema>(path, db_options)
280            .context("Failed to open sui-rpc-store database")?;
281        let store = sui_consistent_store::Store::new(db, Arc::new(schema));
282        Self::from_store(
283            store,
284            indexer_args,
285            ingestion_client,
286            streaming_client,
287            gap_fill_client,
288            consistency_config,
289            pruner_config,
290            ingestion_config,
291            registry,
292        )
293        .await
294    }
295
296    /// Variant of [`Self::new`] that takes an already-opened
297    /// [`Store`]. Useful when the caller wants to share the
298    /// underlying [`Db`] with other code in the same process (e.g.
299    /// a fullnode that reads through [`RpcStoreSchema`] directly,
300    /// or writes to the raw-chain-data CFs through a separate
301    /// path).
302    ///
303    /// Fails if `indexer_args.pipeline` is non-empty; select
304    /// pipelines through the [`PipelineLayer`] passed to
305    /// [`Self::add_pipelines`] instead.
306    #[allow(clippy::too_many_arguments)]
307    pub async fn from_store(
308        store: Store,
309        indexer_args: IndexerArgs,
310        ingestion_client: IngestionClient,
311        streaming_client: Option<ArcStreamingClient>,
312        gap_fill_client: Option<IngestionClient>,
313        consistency_config: crate::config::ConsistencyConfig,
314        pruner_config: Option<PrunerConfig>,
315        ingestion_config: IngestionConfig,
316        registry: &Registry,
317    ) -> anyhow::Result<Self> {
318        let metrics_prefix = Some(METRICS_PREFIX);
319
320        anyhow::ensure!(
321            indexer_args.pipeline.is_empty(),
322            "The rpc-store indexer selects pipelines through its pipeline layer; \
323             IndexerArgs::pipeline must be empty, got {:?}",
324            indexer_args.pipeline,
325        );
326
327        let sync = Synchronizer::new(
328            store.db().clone(),
329            consistency_config.buffer_size,
330            indexer_args.first_checkpoint,
331        );
332
333        let indexer = framework::Indexer::with_ingestion_clients(
334            store,
335            indexer_args,
336            ingestion_client,
337            streaming_client,
338            gap_fill_client,
339            ingestion_config,
340            metrics_prefix,
341            registry,
342        )
343        .await
344        .context("Failed to construct framework indexer")?;
345
346        // Register the pruner's metrics under its own prefix when
347        // pruning is enabled; `run` starts the task from this.
348        let pruner = pruner_config.map(|config| (config, PrunerMetrics::new(None, registry)));
349
350        Ok(Self {
351            indexer,
352            sync,
353            pruner,
354        })
355    }
356
357    /// Borrow the wrapped framework indexer's store. Useful for
358    /// embedded callers that want a read handle pointed at the
359    /// same [`RpcStoreSchema`] this orchestrator is writing to.
360    pub fn store(&self) -> &Store {
361        self.indexer.store()
362    }
363
364    /// Iterate over the names of every pipeline that has been
365    /// registered with this indexer and is enabled (i.e. not
366    /// filtered out by `IndexerArgs::pipeline`). Useful for
367    /// asserting which pipelines are active before [`Self::run`]
368    /// is called.
369    pub fn pipelines(&self) -> impl Iterator<Item = &'static str> + '_ {
370        self.indexer.pipelines()
371    }
372
373    /// Register every pipeline that is `Some(_)` in `layer`. The
374    /// supplied [`CommitterConfig`] acts as the shared base; each
375    /// pipeline's [`CommitterLayer`] overrides individual fields.
376    ///
377    /// Skipped (`None`) pipelines are not registered with the
378    /// [`Synchronizer`] either, so its snapshot barrier still
379    /// proceeds without them.
380    ///
381    /// [`CommitterLayer`]: crate::config::CommitterLayer
382    pub async fn add_pipelines(
383        &mut self,
384        layer: PipelineLayer,
385        committer: CommitterConfig,
386    ) -> anyhow::Result<()> {
387        let PipelineLayer {
388            epochs,
389            checkpoint_summary,
390            checkpoint_contents,
391            checkpoint_seq_by_digest,
392            transactions,
393            tx_seq_by_digest,
394            tx_metadata_by_seq,
395            effects,
396            events,
397            objects,
398            object_version_by_checkpoint,
399            object_by_owner,
400            object_by_type,
401            balance,
402            package_versions,
403            transaction_bitmap,
404            event_bitmap,
405        } = layer;
406
407        macro_rules! add {
408            ($handler:expr, $cfg:expr) => {
409                if let Some(layer) = $cfg {
410                    self.sequential_pipeline(
411                        $handler,
412                        SequentialConfig {
413                            committer: layer.finish(committer.clone()),
414                            // The synchronizer requires one
415                            // checkpoint per write batch; folding
416                            // multiple checkpoints into one batch
417                            // trips its out-of-order check.
418                            max_batch_checkpoints: Some(1),
419                            ..Default::default()
420                        },
421                    )
422                    .await?
423                }
424            };
425        }
426
427        // Raw chain data.
428        add!(self::epochs::Epochs, epochs);
429        add!(
430            self::checkpoint_summary::CheckpointSummary,
431            checkpoint_summary
432        );
433        add!(
434            self::checkpoint_contents::CheckpointContents,
435            checkpoint_contents
436        );
437        add!(
438            self::checkpoint_seq_by_digest::CheckpointSeqByDigest,
439            checkpoint_seq_by_digest
440        );
441        add!(self::transactions::Transactions, transactions);
442        add!(self::tx_seq_by_digest::TxSeqByDigest, tx_seq_by_digest);
443        add!(
444            self::tx_metadata_by_seq::TxMetadataBySeq,
445            tx_metadata_by_seq
446        );
447        add!(self::effects::Effects, effects);
448        add!(self::events::Events, events);
449        add!(self::objects::Objects, objects);
450        // `object_version_by_checkpoint` needs its restore anchor `T` so
451        // its processor scopes floor candidates to the backfill window
452        // `[L, T]`. The restore (if any) has already run by the time
453        // pipelines are registered, so read it once here.
454        let ovbc_anchor = self::object_version_by_checkpoint::restored_anchor(self.store().db())?;
455        add!(
456            self::object_version_by_checkpoint::ObjectVersionByCheckpoint::with_anchor(ovbc_anchor),
457            object_version_by_checkpoint
458        );
459
460        // Indexes.
461        add!(self::object_by_owner::ObjectByOwner, object_by_owner);
462        add!(self::object_by_type::ObjectByType, object_by_type);
463        add!(self::balance::Balance, balance);
464        add!(self::package_versions::PackageVersions, package_versions);
465        add!(
466            self::transaction_bitmap::TransactionBitmap,
467            transaction_bitmap
468        );
469        add!(self::event_bitmap::EventBitmap, event_bitmap);
470
471        Ok(())
472    }
473
474    /// Register the checkpoint-broadcast pipeline, which re-publishes
475    /// each committed checkpoint to `sender` in checkpoint order (see
476    /// [`checkpoint_broadcast`]). Call alongside [`Self::add_pipelines`]
477    /// and before [`Self::run`].
478    ///
479    /// Kept separate from [`Self::add_pipelines`] because it carries a
480    /// runtime `broadcast::Sender` rather than a [`PipelineLayer`]
481    /// toggle — only the standalone `sui-rpc-node`, which hosts the
482    /// subscription service, registers it; the embedded fullnode feeds
483    /// its subscription service from the checkpoint executor instead.
484    ///
485    /// As with every synchronizer-coordinated pipeline, registers with
486    /// `max_batch_checkpoints = 1` so each `commit` (and thus each
487    /// broadcast) is exactly one checkpoint, written as soon as it is
488    /// next in order and the committer has room for it.
489    pub async fn add_checkpoint_broadcast(
490        &mut self,
491        sender: tokio::sync::broadcast::Sender<Arc<Checkpoint>>,
492        committer: CommitterConfig,
493    ) -> anyhow::Result<()> {
494        self.sequential_pipeline(
495            self::checkpoint_broadcast::CheckpointBroadcast::new(sender),
496            SequentialConfig {
497                committer,
498                max_batch_checkpoints: Some(1),
499                ..Default::default()
500            },
501        )
502        .await
503    }
504
505    /// Register a single sequential pipeline. The pipeline is
506    /// announced to the synchronizer before being handed to the
507    /// framework indexer so that, by the time the first batch
508    /// flows through, the synchronizer task is already waiting on
509    /// the pipeline's queue. A [`LIVE_COHORT`] pipeline is
510    /// registered as a required snapshot member.
511    ///
512    /// Refuses to register a pipeline whose persisted [`RestoreState`]
513    /// is still `InProgress`: tip-mode indexing of a pipeline that
514    /// has not finished restoring would commit checkpoints atop a
515    /// partial bulk-load, producing an inconsistent CF. Pipelines
516    /// with no restore row (never restored) and pipelines marked
517    /// `Complete` are allowed.
518    ///
519    /// [`RestoreState`]: sui_consistent_store::RestoreState
520    async fn sequential_pipeline<H>(
521        &mut self,
522        handler: H,
523        config: SequentialConfig,
524    ) -> anyhow::Result<()>
525    where
526        H: sequential::Handler<Store = Store> + Send + Sync + 'static,
527    {
528        let restore_state = self
529            .store()
530            .db()
531            .framework()
532            .restore
533            .get(&PipelineTaskKey::new(H::NAME))
534            .with_context(|| format!("Reading restore state for pipeline {:?}", H::NAME))?;
535
536        if let Some(state) = restore_state.as_ref().and_then(|s| s.state.as_ref()) {
537            match state {
538                restore_state::State::InProgress(_) => {
539                    anyhow::bail!("Restoration in progress for pipeline {:?}", H::NAME);
540                }
541                restore_state::State::Complete(_) => {
542                    // Restore finished — tip indexing may proceed.
543                }
544            }
545        }
546
547        if LIVE_COHORT.contains(&H::NAME) {
548            self.sync.register_required_pipeline(H::NAME)
549        } else {
550            self.sync.register_pipeline(H::NAME)
551        }
552        .with_context(|| format!("Failed to add pipeline {:?} to synchronizer", H::NAME))?;
553
554        self.indexer
555            .sequential_pipeline(handler, config)
556            .await
557            .with_context(|| format!("Failed to add pipeline {:?} to indexer", H::NAME))?;
558
559        Ok(())
560    }
561
562    /// Install the synchronizer onto the store and start the
563    /// framework indexer. Returns a composed [`Service`] handle
564    /// that drives both for the lifetime of the indexer.
565    pub async fn run(self) -> anyhow::Result<Service> {
566        let Self {
567            indexer,
568            sync,
569            pruner: pruner_setup,
570        } = self;
571
572        // Capture the store for the pruner before `indexer.run`
573        // consumes the framework indexer.
574        let store = indexer.store().clone();
575
576        let mut sync_join_set = indexer
577            .store()
578            .install_sync(sync)
579            .context("Failed to install synchronizer onto store")?;
580
581        // Wrap the synchronizer's JoinSet in a `Service` task so it
582        // composes with the framework indexer's service via
583        // `attach`. Per-pipeline tasks exit naturally once their
584        // mpsc senders (held in the store's `Queue`) are dropped,
585        // which happens on the framework indexer's shutdown.
586        let s_sync = Service::new().spawn(async move {
587            while let Some(res) = sync_join_set.join_next().await {
588                res.context("Synchronizer task panicked")??;
589            }
590            Ok(())
591        });
592
593        let s_indexer = indexer.run().await?;
594        let mut service = s_indexer.attach(s_sync);
595
596        // Attach the background pruner as a secondary task when
597        // configured: it advances the retention floor and deletes
598        // history without extending the indexer's lifetime.
599        if let Some((config, metrics)) = pruner_setup {
600            let s_pruner = pruner::start_pruner(store, config, metrics)
601                .context("Failed to start the rpc-store pruner")?;
602            service = service.attach(s_pruner);
603        }
604
605        Ok(service)
606    }
607}
608
609#[cfg(test)]
610mod tests {
611    use async_trait::async_trait;
612    use sui_indexer_alt_framework::ingestion::ingestion_client::CheckpointError;
613    use sui_indexer_alt_framework::ingestion::ingestion_client::CheckpointResult;
614    use sui_indexer_alt_framework::ingestion::ingestion_client::IngestionClientTrait;
615    use sui_indexer_alt_framework::metrics::IngestionMetrics;
616    use sui_types::digests::ChainIdentifier;
617
618    use super::*;
619
620    /// Serves a fixed set of checkpoints and reports the highest as the
621    /// network tip (0 when empty); every other checkpoint is not found.
622    /// The empty default suits tests that only construct the indexer
623    /// and never run its ingestion loop.
624    #[derive(Default)]
625    struct FixedIngestionClient {
626        checkpoints: BTreeMap<u64, Checkpoint>,
627    }
628
629    #[async_trait]
630    impl IngestionClientTrait for FixedIngestionClient {
631        async fn chain_id(&self) -> anyhow::Result<ChainIdentifier> {
632            Ok(ChainIdentifier::from(
633                sui_types::digests::CheckpointDigest::new([0u8; 32]),
634            ))
635        }
636
637        async fn checkpoint(&self, checkpoint: u64) -> CheckpointResult {
638            self.checkpoints
639                .get(&checkpoint)
640                .cloned()
641                .ok_or(CheckpointError::NotFound)
642        }
643
644        async fn latest_checkpoint_number(&self) -> anyhow::Result<u64> {
645            Ok(self.checkpoints.keys().next_back().copied().unwrap_or(0))
646        }
647    }
648
649    async fn build_indexer(layer: PipelineLayer) -> Indexer {
650        let dir = tempfile::tempdir().unwrap();
651        let registry = Registry::new();
652        let ingestion_metrics = IngestionMetrics::new(Some(METRICS_PREFIX), &registry);
653        let ingestion_client = IngestionClient::from_trait(
654            Arc::new(FixedIngestionClient::default()),
655            ingestion_metrics,
656        );
657        let mut indexer = Indexer::new(
658            dir.path().join("db"),
659            IndexerArgs::default(),
660            ingestion_client,
661            None,
662            None,
663            crate::config::ConsistencyConfig::default(),
664            None,
665            IngestionConfig::default(),
666            DbOptions::default(),
667            &registry,
668        )
669        .await
670        .expect("Indexer::new");
671
672        indexer
673            .add_pipelines(layer, CommitterConfig::default())
674            .await
675            .expect("add_pipelines");
676
677        // Keep the tempdir alive for the duration of the test by
678        // leaking it — the Indexer holds the DB open, and we want
679        // the path to survive until the Indexer is dropped.
680        std::mem::forget(dir);
681        indexer
682    }
683
684    /// `embedded` registers exactly the ten embedded-cohort
685    /// pipelines (three live + seven history) and none of the
686    /// deactivated raw-chain-data ones, so the synchronizer's
687    /// snapshot cohort covers exactly those. Pinned to the
688    /// [`LIVE_COHORT`] / [`HISTORY_COHORT`] constants (via the real
689    /// `Processor::NAME`s the indexer registers) so the layer and the
690    /// restore/seed cohorts cannot drift apart.
691    ///
692    /// [`LIVE_COHORT`]: crate::indexer::restore::LIVE_COHORT
693    /// [`HISTORY_COHORT`]: crate::indexer::restore::HISTORY_COHORT
694    #[tokio::test]
695    async fn embedded_registers_only_cohort_pipelines() {
696        let indexer = build_indexer(PipelineLayer::embedded()).await;
697        let names: std::collections::BTreeSet<_> = indexer.pipelines().collect();
698        let expected: std::collections::BTreeSet<_> = crate::indexer::restore::LIVE_COHORT
699            .iter()
700            .chain(crate::indexer::restore::HISTORY_COHORT)
701            .copied()
702            .collect();
703        assert_eq!(names, expected);
704    }
705
706    /// `all` registers every pipeline (raw chain data + indexes).
707    #[tokio::test]
708    async fn all_registers_every_pipeline() {
709        let indexer = build_indexer(PipelineLayer::all()).await;
710        let names: std::collections::BTreeSet<_> = indexer.pipelines().collect();
711        assert_eq!(
712            names,
713            std::collections::BTreeSet::from([
714                // Raw chain data.
715                "epochs",
716                "checkpoint_summary",
717                "checkpoint_contents",
718                "checkpoint_seq_by_digest",
719                "transactions",
720                "tx_seq_by_digest",
721                "tx_metadata_by_seq",
722                "effects",
723                "events",
724                "objects",
725                "object_version_by_checkpoint",
726                // Indexes.
727                "object_by_owner",
728                "object_by_type",
729                "balance",
730                "package_versions",
731                "transaction_bitmap",
732                "event_bitmap",
733            ])
734        );
735    }
736
737    /// Open the rpc-store DB at `path` and write a single
738    /// pre-existing `RestoreState` entry for `pipeline` directly
739    /// to the framework's `__restore` CF. Returns the [`Store`]
740    /// the orchestrator should pick up. Used by the restore-guard
741    /// tests below to seed an `InProgress` or `Complete` state
742    /// before [`Indexer::from_store`] runs.
743    fn open_with_seeded_restore(
744        path: &std::path::Path,
745        pipeline: &str,
746        state: sui_consistent_store::RestoreState,
747    ) -> Store {
748        let (db, schema) = Db::open::<RpcStoreSchema>(path, DbOptions::default()).unwrap();
749        let framework = sui_consistent_store::FrameworkSchema::new(db.clone());
750        let mut batch = db.batch();
751        batch
752            .put(&framework.restore, &PipelineTaskKey::new(pipeline), &state)
753            .unwrap();
754        batch.commit().unwrap();
755        sui_consistent_store::Store::new(db, Arc::new(schema))
756    }
757
758    async fn build_indexer_with_store(store: Store) -> anyhow::Result<Indexer> {
759        let registry = Registry::new();
760        let ingestion_metrics = IngestionMetrics::new(Some(METRICS_PREFIX), &registry);
761        let ingestion_client = IngestionClient::from_trait(
762            Arc::new(FixedIngestionClient::default()),
763            ingestion_metrics,
764        );
765        Indexer::from_store(
766            store,
767            IndexerArgs::default(),
768            ingestion_client,
769            None,
770            None,
771            crate::config::ConsistencyConfig::default(),
772            None,
773            IngestionConfig::default(),
774            &registry,
775        )
776        .await
777    }
778
779    #[tokio::test]
780    async fn add_pipelines_refuses_pipeline_with_in_progress_restore() {
781        let dir = tempfile::tempdir().unwrap();
782        let in_progress = sui_consistent_store::RestoreState {
783            state: Some(restore_state::State::InProgress(
784                restore_state::InProgress::default(),
785            )),
786        };
787        let store = open_with_seeded_restore(&dir.path().join("db"), "balance", in_progress);
788
789        let mut indexer = build_indexer_with_store(store).await.unwrap();
790        let err = indexer
791            .add_pipelines(
792                PipelineLayer {
793                    balance: Some(crate::config::CommitterLayer::default()),
794                    ..PipelineLayer::default()
795                },
796                CommitterConfig::default(),
797            )
798            .await
799            .unwrap_err();
800        assert!(
801            format!("{err:#}").contains("Restoration in progress for pipeline"),
802            "expected restore-in-progress error, got: {err:#}",
803        );
804    }
805
806    #[tokio::test]
807    async fn add_pipelines_allows_pipeline_with_completed_restore() {
808        let dir = tempfile::tempdir().unwrap();
809        let complete = sui_consistent_store::RestoreState {
810            state: Some(restore_state::State::Complete(restore_state::Complete {
811                restored_at: 42,
812            })),
813        };
814        let store = open_with_seeded_restore(&dir.path().join("db"), "balance", complete);
815
816        let mut indexer = build_indexer_with_store(store).await.unwrap();
817        indexer
818            .add_pipelines(
819                PipelineLayer {
820                    balance: Some(crate::config::CommitterLayer::default()),
821                    ..PipelineLayer::default()
822                },
823                CommitterConfig::default(),
824            )
825            .await
826            .unwrap();
827        let names: std::collections::BTreeSet<_> = indexer.pipelines().collect();
828        assert_eq!(names, std::collections::BTreeSet::from(["balance"]));
829    }
830
831    /// The checkpoint the live pipelines resume from in the restart
832    /// tests, and the network tip they index up to.
833    const RESTART: u64 = 10;
834    const TIP: u64 = 13;
835
836    /// Seed `watermarks` into a fresh store at `path`, then run an
837    /// indexer over it with the live cohort plus the
838    /// `tx_seq_by_digest` history pipeline, fed checkpoints
839    /// `RESTART..=TIP` only. Returns once snapshot `TIP` exists.
840    async fn run_until_tip_snapshot(
841        path: &std::path::Path,
842        watermarks: &[(&str, u64)],
843    ) -> (Db, Service) {
844        let (db, schema) = Db::open::<RpcStoreSchema>(path, DbOptions::default()).unwrap();
845        let framework = sui_consistent_store::FrameworkSchema::new(db.clone());
846        let mut batch = db.batch();
847        for &(pipeline, checkpoint) in watermarks {
848            batch
849                .put(
850                    &framework.watermarks,
851                    &PipelineTaskKey::new(pipeline),
852                    &sui_consistent_store::Watermark::for_checkpoint(checkpoint),
853                )
854                .unwrap();
855        }
856        batch.commit().unwrap();
857        let store = sui_consistent_store::Store::new(db.clone(), Arc::new(schema));
858
859        let mut builder =
860            sui_types::test_checkpoint_data_builder::TestCheckpointBuilder::new(RESTART);
861        let checkpoints = (RESTART..=TIP)
862            .map(|checkpoint| (checkpoint, builder.build_checkpoint()))
863            .collect();
864
865        let registry = Registry::new();
866        let ingestion_metrics = IngestionMetrics::new(Some(METRICS_PREFIX), &registry);
867        let ingestion_client = IngestionClient::from_trait(
868            Arc::new(FixedIngestionClient { checkpoints }),
869            ingestion_metrics,
870        );
871        // Keep a stuck history pipeline on its own ingestion service so
872        // its missing checkpoints do not stall the live pipelines' feed.
873        let ingestion_config = IngestionConfig {
874            min_cohort_boundary: 1,
875            ..IngestionConfig::default()
876        };
877        let mut indexer = Indexer::from_store(
878            store,
879            IndexerArgs::default(),
880            ingestion_client,
881            None,
882            None,
883            crate::config::ConsistencyConfig::default(),
884            None,
885            ingestion_config,
886            &registry,
887        )
888        .await
889        .unwrap();
890        indexer
891            .add_pipelines(
892                PipelineLayer {
893                    object_by_owner: Some(crate::config::CommitterLayer::default()),
894                    object_by_type: Some(crate::config::CommitterLayer::default()),
895                    balance: Some(crate::config::CommitterLayer::default()),
896                    tx_seq_by_digest: Some(crate::config::CommitterLayer::default()),
897                    ..PipelineLayer::default()
898                },
899                CommitterConfig::default(),
900            )
901            .await
902            .unwrap();
903        let service = indexer.run().await.unwrap();
904
905        tokio::time::timeout(std::time::Duration::from_secs(60), async {
906            while db.at_snapshot(TIP).is_none() {
907                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
908            }
909        })
910        .await
911        .expect("live snapshots should reach the tip");
912
913        (db, service)
914    }
915
916    /// `pipeline`'s committed checkpoint as captured by snapshot
917    /// `checkpoint`.
918    fn snapshot_watermark(db: &Db, checkpoint: u64, pipeline: &str) -> Option<u64> {
919        db.at_snapshot(checkpoint)
920            .unwrap()
921            .framework()
922            .watermarks
923            .get(&PipelineTaskKey::new(pipeline))
924            .unwrap()
925            .map(|w| w.checkpoint_hi_inclusive)
926    }
927
928    #[tokio::test]
929    async fn restart_with_uneven_live_watermarks_snapshots_only_complete_live_state() {
930        // A crash left `object_by_owner` and `object_by_type` at 10 but
931        // `balance` at 9. The history pipeline `tx_seq_by_digest` sits at
932        // 0 and checkpoints below 10 are unavailable, so it cannot make
933        // progress and must not hold live snapshots back.
934        let dir = tempfile::tempdir().unwrap();
935        let (db, service) = run_until_tip_snapshot(
936            dir.path(),
937            &[
938                ("object_by_owner", RESTART),
939                ("object_by_type", RESTART),
940                ("balance", RESTART - 1),
941                ("tx_seq_by_digest", 0),
942            ],
943        )
944        .await;
945
946        // Every snapshot, starting at the restart checkpoint, holds every
947        // live pipeline committed through that snapshot's checkpoint.
948        assert_eq!(db.snapshot_range(), Some(RESTART..=TIP));
949        for checkpoint in RESTART..=TIP {
950            for pipeline in LIVE_COHORT {
951                assert_eq!(
952                    snapshot_watermark(&db, checkpoint, pipeline),
953                    Some(checkpoint),
954                    "snapshot {checkpoint} is missing {pipeline}'s writes",
955                );
956            }
957        }
958
959        drop(service);
960    }
961
962    #[tokio::test]
963    async fn from_store_refuses_pipeline_filter() {
964        // A pipeline the framework's filter skips would never send a
965        // batch yet still be registered with the synchronizer, so the
966        // filter is refused up front.
967        let dir = tempfile::tempdir().unwrap();
968        let (db, schema) = Db::open::<RpcStoreSchema>(dir.path(), DbOptions::default()).unwrap();
969        let registry = Registry::new();
970        let ingestion_metrics = IngestionMetrics::new(Some(METRICS_PREFIX), &registry);
971        let ingestion_client = IngestionClient::from_trait(
972            Arc::new(FixedIngestionClient::default()),
973            ingestion_metrics,
974        );
975        let Err(err) = Indexer::from_store(
976            sui_consistent_store::Store::new(db, Arc::new(schema)),
977            IndexerArgs {
978                pipeline: vec!["object_by_owner".to_owned()],
979                ..IndexerArgs::default()
980            },
981            ingestion_client,
982            None,
983            None,
984            crate::config::ConsistencyConfig::default(),
985            None,
986            IngestionConfig::default(),
987            &registry,
988        )
989        .await
990        else {
991            panic!("a pipeline filter must be refused");
992        };
993        assert!(
994            format!("{err:#}").contains("IndexerArgs::pipeline must be empty"),
995            "unexpected error: {err:#}",
996        );
997    }
998}