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;
73
74/// Metrics prefix used for both the framework indexer and the
75/// underlying ingestion service. Surfaced as a constant so the
76/// prefix is consistent across the metrics built in [`Indexer::new`]
77/// and the ones the standalone-binary entry point builds when it
78/// constructs the [`IngestionClient`] from `ClientArgs`.
79pub const METRICS_PREFIX: &str = "rpc_store_indexer";
80
81/// The schema parameter the framework's `Store` / pipelines bind
82/// to.
83pub type Schema = RpcStoreSchema;
84
85/// The store type pipelines commit through.
86pub type Store = sui_consistent_store::Store<Schema>;
87
88/// The sequence number of the first transaction in `checkpoint`.
89///
90/// `network_total_transactions` is the cumulative network-wide tx
91/// count *after* this checkpoint executes, so subtracting the
92/// number of transactions the checkpoint contains gives the
93/// `tx_seq` of its first entry.
94pub fn first_tx_seq(checkpoint: &Checkpoint) -> u64 {
95    checkpoint.summary.network_total_transactions - checkpoint.transactions.len() as u64
96}
97
98/// The `tx_seq` of the transaction at index `i` within
99/// `checkpoint`.
100pub fn tx_seq_at(checkpoint: &Checkpoint, i: usize) -> u64 {
101    first_tx_seq(checkpoint) + i as u64
102}
103
104/// First-seen input version of every object that existed before
105/// the checkpoint and was used as an input to some transaction in
106/// it. Mirrors the helper of the same name in
107/// `sui-indexer-alt-consistent-store::handlers`.
108///
109/// Objects created or unwrapped within the checkpoint are
110/// excluded. Used by the diff-based indexes
111/// ([`object_by_owner`] etc.) to
112/// remove the rows that the *prior* state contributed before
113/// re-inserting the rows that the *posterior* state contributes.
114pub fn checkpoint_input_objects(
115    checkpoint: &Checkpoint,
116) -> anyhow::Result<BTreeMap<ObjectID, (&Object, ObjectDigest)>> {
117    let mut from_this_checkpoint = HashSet::new();
118    let mut input_objects = BTreeMap::new();
119    for tx in &checkpoint.transactions {
120        let input_objects_map: BTreeMap<_, _> = tx
121            .input_objects(&checkpoint.object_set)
122            .map(|obj| ((obj.id(), obj.version()), obj))
123            .collect();
124
125        for change in tx.effects.object_changes() {
126            let id = change.id;
127
128            let Some(version) = change.input_version else {
129                continue;
130            };
131
132            if from_this_checkpoint.contains(&id) {
133                continue;
134            }
135
136            let Entry::Vacant(entry) = input_objects.entry(id) else {
137                continue;
138            };
139
140            let input_object = *input_objects_map
141                .get(&(id, version))
142                .with_context(|| format!("{id} at {version} in effects, not in input_objects"))?;
143
144            // Input digests are only populated in Effects V2. For Effects V1, we need to
145            // compute the digest from the input object's contents.
146            let digest = change.input_digest.unwrap_or_else(|| input_object.digest());
147            entry.insert((input_object, digest));
148        }
149
150        for change in tx.effects.object_changes() {
151            if change.output_version.is_some() {
152                from_this_checkpoint.insert(change.id);
153            }
154        }
155    }
156    Ok(input_objects)
157}
158
159/// Last-seen output version of every object that was created or
160/// modified by some transaction in the checkpoint and is still
161/// live at the end. Mirrors the helper of the same name in
162/// `sui-indexer-alt-consistent-store::handlers`.
163///
164/// Used to populate the checkpoint-pinned
165/// [`object_version_by_checkpoint`] index and the diff-based indexes
166/// once the prior state has been retracted.
167pub fn checkpoint_output_objects(
168    checkpoint: &Checkpoint,
169) -> anyhow::Result<BTreeMap<ObjectID, (&Object, ObjectDigest)>> {
170    let mut output_objects = BTreeMap::new();
171    for tx in &checkpoint.transactions {
172        let output_objects_map: BTreeMap<_, _> = tx
173            .output_objects(&checkpoint.object_set)
174            .map(|obj| ((obj.id(), obj.version()), obj))
175            .collect();
176
177        for change in tx.effects.object_changes() {
178            let id = change.id;
179
180            // Clear the previous entry, in case it was created within this checkpoint.
181            output_objects.remove(&id);
182
183            let (Some(version), Some(digest)) = (change.output_version, change.output_digest)
184            else {
185                continue;
186            };
187
188            let output_object = *output_objects_map
189                .get(&(id, version))
190                .with_context(|| format!("{id} at {version} in effects, not in output_objects"))?;
191
192            output_objects.insert(id, (output_object, digest));
193        }
194    }
195    Ok(output_objects)
196}
197
198/// Top-level orchestrator. Wraps a [`framework::Indexer`] over the
199/// [`Store`] for [`RpcStoreSchema`] together with a
200/// [`Synchronizer`] coordinating cross-pipeline snapshots, and
201/// exposes the per-pipeline registration shape this crate needs.
202///
203/// Construct one of two ways:
204///
205/// - [`Indexer::new`] opens the [`Db`] / [`Store`] internally —
206///   typical for the standalone binary path.
207/// - [`Indexer::from_store`] takes an already-opened [`Store`] —
208///   typical for the embedded-fullnode path where the fullnode
209///   shares the underlying database with this indexer for direct
210///   reads (and possibly for its own raw-chain-data writes).
211///
212/// Pipelines are registered through [`Self::add_pipelines`], which
213/// honours the per-pipeline enable/disable knobs encoded in a
214/// [`PipelineLayer`]. Disabled pipelines are skipped entirely —
215/// the [`Synchronizer`] only barriers across pipelines that were
216/// actually registered, so leaving the raw-chain-data pipelines
217/// off does not stall snapshots.
218///
219/// After pipelines are registered, [`Self::run`] installs the
220/// synchronizer onto the store and starts the framework indexer.
221pub struct Indexer {
222    indexer: framework::Indexer<Store>,
223
224    /// Synchronizer coordinating per-pipeline writes against
225    /// cross-pipeline snapshots. Owned here until [`Self::run`]
226    /// hands it to [`sui_consistent_store::Store::install_sync`].
227    sync: Synchronizer,
228
229    /// Pruning policy and its metrics, present when pruning is
230    /// enabled. [`Self::run`] starts the background pruner from
231    /// these and attaches it to the composed service. `None` leaves
232    /// pruning off (the embedded-fullnode and test defaults).
233    pruner: Option<(PrunerConfig, Arc<PrunerMetrics>)>,
234}
235
236impl Indexer {
237    /// Open the database at `path` with [`RpcStoreSchema`] and
238    /// construct an [`Indexer`] backed by it.
239    ///
240    /// `ingestion_client` is the pull-side checkpoint source; the
241    /// optional `streaming_client` is the live-tail source. Callers
242    /// (standalone binary, embedded fullnode) build the
243    /// [`IngestionClient`] via [`IngestionClient::new`] (driven by
244    /// `ClientArgs`) or [`IngestionClient::from_trait`] (wrapping
245    /// a custom [`IngestionClientTrait`]), depending on where
246    /// checkpoints come from. The [`IngestionMetrics`] handle the
247    /// service shares with the client is reused from
248    /// [`IngestionClient::metrics`], avoiding double-registration
249    /// against `registry`.
250    ///
251    /// [`IngestionClientTrait`]: sui_indexer_alt_framework::ingestion::ingestion_client::IngestionClientTrait
252    /// [`IngestionMetrics`]: sui_indexer_alt_framework::metrics::IngestionMetrics
253    #[allow(clippy::too_many_arguments)]
254    pub async fn new(
255        path: impl AsRef<Path>,
256        indexer_args: IndexerArgs,
257        ingestion_client: IngestionClient,
258        streaming_client: Option<ArcStreamingClient>,
259        consistency_config: crate::config::ConsistencyConfig,
260        pruner_config: Option<PrunerConfig>,
261        ingestion_config: IngestionConfig,
262        db_options: DbOptions,
263        registry: &Registry,
264    ) -> anyhow::Result<Self> {
265        let (db, schema) = Db::open::<RpcStoreSchema>(path, db_options)
266            .context("Failed to open sui-rpc-store database")?;
267        let store = sui_consistent_store::Store::new(db, Arc::new(schema));
268        Self::from_store(
269            store,
270            indexer_args,
271            ingestion_client,
272            streaming_client,
273            consistency_config,
274            pruner_config,
275            ingestion_config,
276            registry,
277        )
278        .await
279    }
280
281    /// Variant of [`Self::new`] that takes an already-opened
282    /// [`Store`]. Useful when the caller wants to share the
283    /// underlying [`Db`] with other code in the same process (e.g.
284    /// a fullnode that reads through [`RpcStoreSchema`] directly,
285    /// or writes to the raw-chain-data CFs through a separate
286    /// path).
287    #[allow(clippy::too_many_arguments)]
288    pub async fn from_store(
289        store: Store,
290        indexer_args: IndexerArgs,
291        ingestion_client: IngestionClient,
292        streaming_client: Option<ArcStreamingClient>,
293        consistency_config: crate::config::ConsistencyConfig,
294        pruner_config: Option<PrunerConfig>,
295        ingestion_config: IngestionConfig,
296        registry: &Registry,
297    ) -> anyhow::Result<Self> {
298        let metrics_prefix = Some(METRICS_PREFIX);
299
300        let sync = Synchronizer::new(
301            store.db().clone(),
302            consistency_config.buffer_size,
303            indexer_args.first_checkpoint,
304        );
305
306        let indexer = framework::Indexer::with_ingestion_clients(
307            store,
308            indexer_args,
309            ingestion_client,
310            streaming_client,
311            ingestion_config,
312            metrics_prefix,
313            registry,
314        )
315        .await
316        .context("Failed to construct framework indexer")?;
317
318        // Register the pruner's metrics under its own prefix when
319        // pruning is enabled; `run` starts the task from this.
320        let pruner = pruner_config.map(|config| (config, PrunerMetrics::new(None, registry)));
321
322        Ok(Self {
323            indexer,
324            sync,
325            pruner,
326        })
327    }
328
329    /// Borrow the wrapped framework indexer's store. Useful for
330    /// embedded callers that want a read handle pointed at the
331    /// same [`RpcStoreSchema`] this orchestrator is writing to.
332    pub fn store(&self) -> &Store {
333        self.indexer.store()
334    }
335
336    /// Iterate over the names of every pipeline that has been
337    /// registered with this indexer and is enabled (i.e. not
338    /// filtered out by `IndexerArgs::pipeline`). Useful for
339    /// asserting which pipelines are active before [`Self::run`]
340    /// is called.
341    pub fn pipelines(&self) -> impl Iterator<Item = &'static str> + '_ {
342        self.indexer.pipelines()
343    }
344
345    /// Register every pipeline that is `Some(_)` in `layer`. The
346    /// supplied [`CommitterConfig`] acts as the shared base; each
347    /// pipeline's [`CommitterLayer`] overrides individual fields.
348    ///
349    /// Skipped (`None`) pipelines are not registered with the
350    /// [`Synchronizer`] either, so its snapshot barrier still
351    /// proceeds without them.
352    ///
353    /// [`CommitterLayer`]: crate::config::CommitterLayer
354    pub async fn add_pipelines(
355        &mut self,
356        layer: PipelineLayer,
357        committer: CommitterConfig,
358    ) -> anyhow::Result<()> {
359        let PipelineLayer {
360            epochs,
361            checkpoint_summary,
362            checkpoint_contents,
363            checkpoint_seq_by_digest,
364            transactions,
365            tx_seq_by_digest,
366            tx_metadata_by_seq,
367            effects,
368            events,
369            objects,
370            object_version_by_checkpoint,
371            object_by_owner,
372            object_by_type,
373            balance,
374            package_versions,
375            transaction_bitmap,
376            event_bitmap,
377        } = layer;
378
379        macro_rules! add {
380            ($handler:expr, $cfg:expr) => {
381                if let Some(layer) = $cfg {
382                    self.sequential_pipeline(
383                        $handler,
384                        SequentialConfig {
385                            committer: layer.finish(committer.clone()),
386                            // The synchronizer requires one
387                            // checkpoint per write batch; folding
388                            // multiple checkpoints into one batch
389                            // trips its out-of-order check.
390                            max_batch_checkpoints: Some(1),
391                            ..Default::default()
392                        },
393                    )
394                    .await?
395                }
396            };
397        }
398
399        // Raw chain data.
400        add!(self::epochs::Epochs, epochs);
401        add!(
402            self::checkpoint_summary::CheckpointSummary,
403            checkpoint_summary
404        );
405        add!(
406            self::checkpoint_contents::CheckpointContents,
407            checkpoint_contents
408        );
409        add!(
410            self::checkpoint_seq_by_digest::CheckpointSeqByDigest,
411            checkpoint_seq_by_digest
412        );
413        add!(self::transactions::Transactions, transactions);
414        add!(self::tx_seq_by_digest::TxSeqByDigest, tx_seq_by_digest);
415        add!(
416            self::tx_metadata_by_seq::TxMetadataBySeq,
417            tx_metadata_by_seq
418        );
419        add!(self::effects::Effects, effects);
420        add!(self::events::Events, events);
421        add!(self::objects::Objects, objects);
422        // `object_version_by_checkpoint` needs its restore anchor `T` so
423        // its processor scopes floor candidates to the backfill window
424        // `[L, T]`. The restore (if any) has already run by the time
425        // pipelines are registered, so read it once here.
426        let ovbc_anchor = self::object_version_by_checkpoint::restored_anchor(self.store().db())?;
427        add!(
428            self::object_version_by_checkpoint::ObjectVersionByCheckpoint::with_anchor(ovbc_anchor),
429            object_version_by_checkpoint
430        );
431
432        // Indexes.
433        add!(self::object_by_owner::ObjectByOwner, object_by_owner);
434        add!(self::object_by_type::ObjectByType, object_by_type);
435        add!(self::balance::Balance, balance);
436        add!(self::package_versions::PackageVersions, package_versions);
437        add!(
438            self::transaction_bitmap::TransactionBitmap,
439            transaction_bitmap
440        );
441        add!(self::event_bitmap::EventBitmap, event_bitmap);
442
443        Ok(())
444    }
445
446    /// Register the checkpoint-broadcast pipeline, which re-publishes
447    /// each committed checkpoint to `sender` in checkpoint order (see
448    /// [`checkpoint_broadcast`]). Call alongside [`Self::add_pipelines`]
449    /// and before [`Self::run`].
450    ///
451    /// Kept separate from [`Self::add_pipelines`] because it carries a
452    /// runtime `broadcast::Sender` rather than a [`PipelineLayer`]
453    /// toggle — only the standalone `sui-rpc-node`, which hosts the
454    /// subscription service, registers it; the embedded fullnode feeds
455    /// its subscription service from the checkpoint executor instead.
456    ///
457    /// As with every synchronizer-coordinated pipeline, registers with
458    /// `max_batch_checkpoints = 1` so each `commit` (and thus each
459    /// broadcast) is exactly one checkpoint.
460    pub async fn add_checkpoint_broadcast(
461        &mut self,
462        sender: tokio::sync::broadcast::Sender<Arc<Checkpoint>>,
463        committer: CommitterConfig,
464    ) -> anyhow::Result<()> {
465        self.sequential_pipeline(
466            self::checkpoint_broadcast::CheckpointBroadcast::new(sender),
467            SequentialConfig {
468                committer,
469                max_batch_checkpoints: Some(1),
470                ..Default::default()
471            },
472        )
473        .await
474    }
475
476    /// Register a single sequential pipeline. The pipeline is
477    /// announced to the synchronizer before being handed to the
478    /// framework indexer so that, by the time the first batch
479    /// flows through, the synchronizer task is already waiting on
480    /// the pipeline's queue.
481    ///
482    /// Refuses to register a pipeline whose persisted [`RestoreState`]
483    /// is still `InProgress`: tip-mode indexing of a pipeline that
484    /// has not finished restoring would commit checkpoints atop a
485    /// partial bulk-load, producing an inconsistent CF. Pipelines
486    /// with no restore row (never restored) and pipelines marked
487    /// `Complete` are allowed.
488    ///
489    /// [`RestoreState`]: sui_consistent_store::RestoreState
490    async fn sequential_pipeline<H>(
491        &mut self,
492        handler: H,
493        config: SequentialConfig,
494    ) -> anyhow::Result<()>
495    where
496        H: sequential::Handler<Store = Store> + Send + Sync + 'static,
497    {
498        let restore_state = self
499            .store()
500            .db()
501            .framework()
502            .restore
503            .get(&PipelineTaskKey::new(H::NAME))
504            .with_context(|| format!("Reading restore state for pipeline {:?}", H::NAME))?;
505
506        if let Some(state) = restore_state.as_ref().and_then(|s| s.state.as_ref()) {
507            match state {
508                restore_state::State::InProgress(_) => {
509                    anyhow::bail!("Restoration in progress for pipeline {:?}", H::NAME);
510                }
511                restore_state::State::Complete(_) => {
512                    // Restore finished — tip indexing may proceed.
513                }
514            }
515        }
516
517        self.sync
518            .register_pipeline(H::NAME)
519            .with_context(|| format!("Failed to add pipeline {:?} to synchronizer", H::NAME))?;
520
521        self.indexer
522            .sequential_pipeline(handler, config)
523            .await
524            .with_context(|| format!("Failed to add pipeline {:?} to indexer", H::NAME))?;
525
526        Ok(())
527    }
528
529    /// Install the synchronizer onto the store and start the
530    /// framework indexer. Returns a composed [`Service`] handle
531    /// that drives both for the lifetime of the indexer.
532    pub async fn run(self) -> anyhow::Result<Service> {
533        let Self {
534            indexer,
535            sync,
536            pruner: pruner_setup,
537        } = self;
538
539        // Capture the store for the pruner before `indexer.run`
540        // consumes the framework indexer.
541        let store = indexer.store().clone();
542
543        let mut sync_join_set = indexer
544            .store()
545            .install_sync(sync)
546            .context("Failed to install synchronizer onto store")?;
547
548        // Wrap the synchronizer's JoinSet in a `Service` task so it
549        // composes with the framework indexer's service via
550        // `attach`. Per-pipeline tasks exit naturally once their
551        // mpsc senders (held in the store's `Queue`) are dropped,
552        // which happens on the framework indexer's shutdown.
553        let s_sync = Service::new().spawn(async move {
554            while let Some(res) = sync_join_set.join_next().await {
555                res.context("Synchronizer task panicked")??;
556            }
557            Ok(())
558        });
559
560        let s_indexer = indexer.run().await?;
561        let mut service = s_indexer.attach(s_sync);
562
563        // Attach the background pruner as a secondary task when
564        // configured: it advances the retention floor and deletes
565        // history without extending the indexer's lifetime.
566        if let Some((config, metrics)) = pruner_setup {
567            let s_pruner = pruner::start_pruner(store, config, metrics)
568                .context("Failed to start the rpc-store pruner")?;
569            service = service.attach(s_pruner);
570        }
571
572        Ok(service)
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    use async_trait::async_trait;
579    use sui_indexer_alt_framework::ingestion::ingestion_client::CheckpointError;
580    use sui_indexer_alt_framework::ingestion::ingestion_client::CheckpointResult;
581    use sui_indexer_alt_framework::ingestion::ingestion_client::IngestionClientTrait;
582    use sui_indexer_alt_framework::metrics::IngestionMetrics;
583    use sui_types::digests::ChainIdentifier;
584
585    use super::*;
586
587    /// Stub [`IngestionClientTrait`] for orchestrator wiring
588    /// tests. Reports a fixed chain id and latest checkpoint and
589    /// fails any actual fetch; suitable for tests that only need
590    /// `Indexer::from_store` to construct (which probes
591    /// `latest_checkpoint_number` once) and never run the
592    /// ingestion loop.
593    struct StubIngestionClient;
594
595    #[async_trait]
596    impl IngestionClientTrait for StubIngestionClient {
597        async fn chain_id(&self) -> anyhow::Result<ChainIdentifier> {
598            Ok(ChainIdentifier::from(
599                sui_types::digests::CheckpointDigest::new([0u8; 32]),
600            ))
601        }
602
603        async fn checkpoint(&self, _checkpoint: u64) -> CheckpointResult {
604            Err(CheckpointError::NotFound)
605        }
606
607        async fn latest_checkpoint_number(&self) -> anyhow::Result<u64> {
608            Ok(0)
609        }
610    }
611
612    async fn build_indexer(layer: PipelineLayer) -> Indexer {
613        let dir = tempfile::tempdir().unwrap();
614        let registry = Registry::new();
615        let ingestion_metrics = IngestionMetrics::new(Some(METRICS_PREFIX), &registry);
616        let ingestion_client =
617            IngestionClient::from_trait(Arc::new(StubIngestionClient), ingestion_metrics);
618        let mut indexer = Indexer::new(
619            dir.path().join("db"),
620            IndexerArgs::default(),
621            ingestion_client,
622            None,
623            crate::config::ConsistencyConfig::default(),
624            None,
625            IngestionConfig::default(),
626            DbOptions::default(),
627            &registry,
628        )
629        .await
630        .expect("Indexer::new");
631
632        indexer
633            .add_pipelines(layer, CommitterConfig::default())
634            .await
635            .expect("add_pipelines");
636
637        // Keep the tempdir alive for the duration of the test by
638        // leaking it — the Indexer holds the DB open, and we want
639        // the path to survive until the Indexer is dropped.
640        std::mem::forget(dir);
641        indexer
642    }
643
644    /// `embedded` registers exactly the ten embedded-cohort
645    /// pipelines (three live + seven history) and none of the
646    /// deactivated raw-chain-data ones, so the synchronizer's
647    /// snapshot cohort covers exactly those. Pinned to the
648    /// [`LIVE_COHORT`] / [`HISTORY_COHORT`] constants (via the real
649    /// `Processor::NAME`s the indexer registers) so the layer and the
650    /// restore/seed cohorts cannot drift apart.
651    ///
652    /// [`LIVE_COHORT`]: crate::indexer::restore::LIVE_COHORT
653    /// [`HISTORY_COHORT`]: crate::indexer::restore::HISTORY_COHORT
654    #[tokio::test]
655    async fn embedded_registers_only_cohort_pipelines() {
656        let indexer = build_indexer(PipelineLayer::embedded()).await;
657        let names: std::collections::BTreeSet<_> = indexer.pipelines().collect();
658        let expected: std::collections::BTreeSet<_> = crate::indexer::restore::LIVE_COHORT
659            .iter()
660            .chain(crate::indexer::restore::HISTORY_COHORT)
661            .copied()
662            .collect();
663        assert_eq!(names, expected);
664    }
665
666    /// `all` registers every pipeline (raw chain data + indexes).
667    #[tokio::test]
668    async fn all_registers_every_pipeline() {
669        let indexer = build_indexer(PipelineLayer::all()).await;
670        let names: std::collections::BTreeSet<_> = indexer.pipelines().collect();
671        assert_eq!(
672            names,
673            std::collections::BTreeSet::from([
674                // Raw chain data.
675                "epochs",
676                "checkpoint_summary",
677                "checkpoint_contents",
678                "checkpoint_seq_by_digest",
679                "transactions",
680                "tx_seq_by_digest",
681                "tx_metadata_by_seq",
682                "effects",
683                "events",
684                "objects",
685                "object_version_by_checkpoint",
686                // Indexes.
687                "object_by_owner",
688                "object_by_type",
689                "balance",
690                "package_versions",
691                "transaction_bitmap",
692                "event_bitmap",
693            ])
694        );
695    }
696
697    /// Open the rpc-store DB at `path` and write a single
698    /// pre-existing `RestoreState` entry for `pipeline` directly
699    /// to the framework's `__restore` CF. Returns the [`Store`]
700    /// the orchestrator should pick up. Used by the restore-guard
701    /// tests below to seed an `InProgress` or `Complete` state
702    /// before [`Indexer::from_store`] runs.
703    fn open_with_seeded_restore(
704        path: &std::path::Path,
705        pipeline: &str,
706        state: sui_consistent_store::RestoreState,
707    ) -> Store {
708        let (db, schema) = Db::open::<RpcStoreSchema>(path, DbOptions::default()).unwrap();
709        let framework = sui_consistent_store::FrameworkSchema::new(db.clone());
710        let mut batch = db.batch();
711        batch
712            .put(&framework.restore, &PipelineTaskKey::new(pipeline), &state)
713            .unwrap();
714        batch.commit().unwrap();
715        sui_consistent_store::Store::new(db, Arc::new(schema))
716    }
717
718    async fn build_indexer_with_store(store: Store) -> anyhow::Result<Indexer> {
719        let registry = Registry::new();
720        let ingestion_metrics = IngestionMetrics::new(Some(METRICS_PREFIX), &registry);
721        let ingestion_client =
722            IngestionClient::from_trait(Arc::new(StubIngestionClient), ingestion_metrics);
723        Indexer::from_store(
724            store,
725            IndexerArgs::default(),
726            ingestion_client,
727            None,
728            crate::config::ConsistencyConfig::default(),
729            None,
730            IngestionConfig::default(),
731            &registry,
732        )
733        .await
734    }
735
736    #[tokio::test]
737    async fn add_pipelines_refuses_pipeline_with_in_progress_restore() {
738        let dir = tempfile::tempdir().unwrap();
739        let in_progress = sui_consistent_store::RestoreState {
740            state: Some(restore_state::State::InProgress(
741                restore_state::InProgress::default(),
742            )),
743        };
744        let store = open_with_seeded_restore(&dir.path().join("db"), "balance", in_progress);
745
746        let mut indexer = build_indexer_with_store(store).await.unwrap();
747        let err = indexer
748            .add_pipelines(
749                PipelineLayer {
750                    balance: Some(crate::config::CommitterLayer::default()),
751                    ..PipelineLayer::default()
752                },
753                CommitterConfig::default(),
754            )
755            .await
756            .unwrap_err();
757        assert!(
758            format!("{err:#}").contains("Restoration in progress for pipeline"),
759            "expected restore-in-progress error, got: {err:#}",
760        );
761    }
762
763    #[tokio::test]
764    async fn add_pipelines_allows_pipeline_with_completed_restore() {
765        let dir = tempfile::tempdir().unwrap();
766        let complete = sui_consistent_store::RestoreState {
767            state: Some(restore_state::State::Complete(restore_state::Complete {
768                restored_at: 42,
769            })),
770        };
771        let store = open_with_seeded_restore(&dir.path().join("db"), "balance", complete);
772
773        let mut indexer = build_indexer_with_store(store).await.unwrap();
774        indexer
775            .add_pipelines(
776                PipelineLayer {
777                    balance: Some(crate::config::CommitterLayer::default()),
778                    ..PipelineLayer::default()
779                },
780                CommitterConfig::default(),
781            )
782            .await
783            .unwrap();
784        let names: std::collections::BTreeSet<_> = indexer.pipelines().collect();
785        assert_eq!(names, std::collections::BTreeSet::from(["balance"]));
786    }
787}