Skip to main content

sui_core/
rpc_store_embed.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Startup orchestration for the embedded `sui-rpc-store` indexer.
5//!
6//! When a fullnode is configured with
7//! [`RpcConfig::enable_indexing`](sui_config::RpcConfig::enable_indexing), it
8//! serves the rpc-api index surface from an embedded [`sui_rpc_store`]
9//! instance. This module owns the lifecycle of that instance:
10//!
11//! 1. Open the rpc-store database under the node's `db_path()`.
12//! 2. Compare its persisted per-pipeline watermarks against the
13//!    perpetual store's currently-available checkpoint range `[L, T]`
14//!    (`L` = lowest available, `T` = highest executed) and `decide`
15//!    what to do: resume as-is, (re)seed the history cohort, or
16//!    (re)restore the live cohort.
17//! 3. Bulk-load the live cohort from the perpetual store and seed the
18//!    history cohort when needed (blocking, before the node starts
19//!    executing).
20//! 4. Build the read handle the rpc-api serves through, hand the store
21//!    to the pruner, and spawn the tip-following indexer fed by the
22//!    perpetual store ([`PerpetualStoreIngestionClient`]) and the
23//!    checkpoint executor's broadcast stream
24//!    ([`BroadcastStreamingClient`]).
25//!
26//! The live cohort (live-object-derivable indexes) is restored to the
27//! tip and follows forward. The history cohort (ledger-history bitmaps,
28//! `tx_seq` maps, per-epoch metadata) is seeded to the lowest available
29//! checkpoint and backfilled upward; the synchronizer's dynamic cohort
30//! lets it catch up to the live frontier without stalling tip
31//! snapshots.
32
33use std::sync::Arc;
34
35use anyhow::Context as _;
36use mysten_common::fatal;
37use prometheus::Registry;
38use sui_config::NodeConfig;
39use sui_config::node::AuthorityStorePruningConfig;
40use sui_consistent_store::ChainId;
41use sui_consistent_store::Db;
42use sui_consistent_store::DbOptions;
43use sui_consistent_store::PipelineTaskKey;
44use sui_consistent_store::Watermark;
45use sui_consistent_store::metrics::ColumnFamilyStatsCollector;
46use sui_consistent_store::restore::RestoreDriverConfig;
47use sui_consistent_store::restore::metrics::RestoreMetrics;
48use sui_indexer_alt_framework::IndexerArgs;
49use sui_indexer_alt_framework::ingestion::ArcStreamingClient;
50use sui_indexer_alt_framework::ingestion::IngestionConfig;
51use sui_indexer_alt_framework::ingestion::ingestion_client::IngestionClient;
52use sui_indexer_alt_framework::metrics::IngestionMetrics;
53use sui_indexer_alt_framework::pipeline::CommitterConfig;
54use sui_indexer_alt_framework::service::Service;
55use sui_rpc_store::ConsistencyConfig;
56use sui_rpc_store::HISTORY_COHORT;
57use sui_rpc_store::Indexer;
58use sui_rpc_store::LIVE_COHORT;
59use sui_rpc_store::METRICS_PREFIX;
60use sui_rpc_store::PipelineLayer;
61use sui_rpc_store::RestoreLayer;
62use sui_rpc_store::RpcStoreReader;
63use sui_rpc_store::RpcStoreSchema;
64use sui_rpc_store::Store as RpcStore;
65use sui_rpc_store::default_rocksdb_config;
66use sui_rpc_store::restore_indexes;
67use sui_rpc_store::schema::event_bitmap;
68use sui_rpc_store::schema::transaction_bitmap;
69use sui_rpc_store::seed_history_cohort;
70use sui_types::digests::ChainIdentifier;
71use sui_types::full_checkpoint_content::Checkpoint;
72use sui_types::storage::ObjectStore;
73use tokio::sync::broadcast;
74use tracing::error;
75use tracing::info;
76
77use crate::authority::authority_store::AuthorityStore;
78use crate::authority::authority_store_tables::AuthorityPerpetualTables;
79use crate::checkpoints::CheckpointStore;
80use crate::rpc_store_ingestion_client::PerpetualStoreIngestionClient;
81use crate::rpc_store_restore_source::PerpetualStoreRestoreSource;
82use crate::rpc_store_streaming_client::BroadcastStreamingClient;
83use crate::storage::RocksDbStore;
84
85/// Subdirectory of the node's `db_path()` holding the rpc-store.
86const RPC_STORE_DIR: &str = "rpc_store";
87
88const SECONDS_PER_DAY: u64 = 86_400;
89
90fn bitmap_periodic_compaction_seconds(days: u64) -> anyhow::Result<u64> {
91    days.checked_mul(SECONDS_PER_DAY).ok_or_else(|| {
92        anyhow::anyhow!("rpc-store-bitmap-periodic-compaction-days value {days} overflows seconds")
93    })
94}
95
96/// Build the embedded rpc-store's [`DbOptions`] from the node's pruning
97/// config: the bitmap CFs' periodic-compaction override, and the number
98/// of per-checkpoint snapshots to retain (zero disables snapshotting).
99fn db_options(config: &AuthorityStorePruningConfig) -> anyhow::Result<DbOptions> {
100    let mut rocksdb = default_rocksdb_config();
101    if let Some(days) = config.rpc_store_bitmap_periodic_compaction_days {
102        let seconds = bitmap_periodic_compaction_seconds(days)?;
103        for name in [transaction_bitmap::NAME, event_bitmap::NAME] {
104            rocksdb
105                .column_family
106                .get_mut(name)
107                .expect("default RocksDB config must define every bitmap CF")
108                .periodic_compaction_seconds = Some(seconds);
109        }
110    }
111    Ok(DbOptions {
112        rocksdb,
113        snapshot_capacity: config.rpc_store_num_checkpoint_snapshots_to_retain,
114    })
115}
116
117/// Open the rpc-store database at `path`.
118#[cfg(not(msim))]
119async fn open_db(
120    path: &std::path::Path,
121    options: DbOptions,
122) -> anyhow::Result<(Db, RpcStoreSchema)> {
123    Db::open::<RpcStoreSchema>(path, options).context("opening the embedded rpc-store database")
124}
125
126/// Open the rpc-store database at `path`, retrying a transient lock
127/// conflict.
128///
129/// A node restarted on the same `db_path` (the simtest restart path) can
130/// briefly observe the path as locked: the previous instance's RocksDB
131/// teardown frees its in-process lock registry entry inside RocksDB's
132/// native close, on its own threads, and that release is not synchronized
133/// with the drop of the last `Db` handle -- so even after every strong
134/// handle has dropped, the reopen can race the unfinished native teardown.
135/// Retrying is robust where a wait is not (the only operation that can
136/// observe "the path is openable again" is asking RocksDB to open it); a
137/// genuine, persistent failure surfaces once attempts are exhausted.
138/// Mirrors `typed_store::safe_drop_rocksdb`'s retry on the inverse
139/// (destroy-vs-teardown) race.
140///
141/// Simulation-only: under a real runtime `Node::stop` joins the node's
142/// thread, so the prior instance's teardown completes before the restart
143/// and the open never races.
144#[cfg(msim)]
145async fn open_db(
146    path: &std::path::Path,
147    options: DbOptions,
148) -> anyhow::Result<(Db, RpcStoreSchema)> {
149    const OPEN_ATTEMPTS: usize = 60;
150    const OPEN_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(100);
151
152    let mut attempt = 1;
153    loop {
154        match Db::open::<RpcStoreSchema>(path, options.clone()) {
155            Ok(opened) => return Ok(opened),
156            Err(e) if attempt < OPEN_ATTEMPTS => {
157                tracing::warn!(
158                    attempt,
159                    "opening the embedded rpc-store database failed, retrying: {e:?}"
160                );
161                attempt += 1;
162                tokio::time::sleep(OPEN_RETRY_DELAY).await;
163            }
164            Err(e) => return Err(e).context("opening the embedded rpc-store database"),
165        }
166    }
167}
168
169fn clear_rpc_store(db: &Db, schema: &RpcStoreSchema) -> anyhow::Result<()> {
170    db.clear_all()
171        .context("clearing the out-of-range embedded rpc-store")?;
172    schema.set_pruning_floor(0);
173    Ok(())
174}
175
176/// What the startup orchestration does with the on-disk rpc-store.
177///
178/// The action chosen at startup is retained on [`EmbeddedRpcStore`] and
179/// exposed via [`EmbeddedRpcStore::bootstrap_action`] so tests (and
180/// future introspection surfaces) can tell whether a restart resumed the
181/// existing indexes or rebuilt them.
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub enum Bootstrap {
184    /// The on-disk state resumes within the available range; open it
185    /// and follow the tip with no blocking work.
186    Resume,
187
188    /// The live cohort is fine, but the history cohort is missing or
189    /// has fallen below the available floor; (re)seed it in place
190    /// without disturbing the live cohort.
191    SeedHistory,
192
193    /// (Re)bulk-load the live cohort from the perpetual store, then
194    /// seed the history cohort. `clear` wipes the database first (for
195    /// out-of-range or wrong-chain data); otherwise the restore
196    /// resumes from any in-progress per-shard cursors.
197    Restore { clear: bool },
198}
199
200/// The persisted framework state [`decide`] consumes, read from the
201/// on-disk store at bootstrap.
202#[derive(Debug, Clone, Copy)]
203struct StoreState {
204    /// `Some(c)` when every [`LIVE_COHORT`] pipeline has a committed
205    /// watermark, where `c` is the lowest checkpoint tip indexing
206    /// would resume from across them
207    /// (`min(checkpoint_hi_inclusive) + 1`); `None` when any live
208    /// pipeline lacks a watermark.
209    live_resume: Option<u64>,
210
211    /// The same for the [`HISTORY_COHORT`], but a missing watermark
212    /// maps to `0`: an unwatermarked pipeline resumes at
213    /// `first_checkpoint`, which the embedded path leaves at its `0`
214    /// default, so the history cohort backfills from genesis.
215    history_resume: u64,
216
217    /// `Some(false)` when the database is bound to a different chain,
218    /// `Some(true)` when it matches, and `None` when no chain id has
219    /// been recorded yet.
220    chain_matches: Option<bool>,
221
222    /// A bulk restore crashed mid-run and its per-shard cursors are
223    /// resumable ([`sui_rpc_store::restore_in_progress`]).
224    restore_in_progress: bool,
225
226    /// The store is in the exact post-restore, pre-seed state that
227    /// [`seed_history_cohort`] repairs
228    /// ([`sui_rpc_store::history_seed_pending`]).
229    history_seed_pending: bool,
230}
231
232/// Decide what bootstrap action the embedded store needs from the
233/// persisted framework state and `lowest_available` — `L`, the lowest
234/// checkpoint the perpetual store can still serve.
235fn decide(state: StoreState, lowest_available: u64) -> Bootstrap {
236    // A database bound to another chain is unusable; wipe and rebuild.
237    if state.chain_matches == Some(false) {
238        return Bootstrap::Restore { clear: true };
239    }
240
241    let Some(live_resume) = state.live_resume else {
242        // No complete live cohort. Resume in place only when a bulk
243        // restore is actually mid-run (per-shard cursors on disk).
244        // Anything else that lacks a live watermark -- a fresh store,
245        // or a from-genesis run that crashed between the live
246        // pipelines' first commits -- must start from a clean slate:
247        // the bulk restore merges and puts on top of whatever rows
248        // exist, so running it over partially tip-indexed CFs would
249        // double-count balances and leave stale owner and type rows.
250        // (On a genuinely fresh store the clear is a no-op.)
251        return Bootstrap::Restore {
252            clear: !state.restore_in_progress,
253        };
254    };
255
256    // The live cohort's indexes reference checkpoints the perpetual
257    // store has since pruned; the bulk-loaded data is unusable.
258    if live_resume < lowest_available {
259        return Bootstrap::Restore { clear: true };
260    }
261
262    if state.history_resume < lowest_available {
263        // The history cohort sits below the available floor. Seeding
264        // it in place is sound only in the post-restore, pre-seed
265        // state (a crash between the restore's finalize and the
266        // history seed): there the floor pipelines' restored rows
267        // anchor reads at the target and nothing else has committed.
268        // Any other coverage below the floor sits behind a pruned,
269        // unfillable gap -- e.g. pruning advanced while indexing was
270        // disabled -- and seeding over it would advertise `[L, tip]`
271        // as served while stale sub-floor rows resolve
272        // checkpoint-pinned reads to pre-gap versions. Wipe and
273        // rebuild instead.
274        return if state.history_seed_pending {
275            Bootstrap::SeedHistory
276        } else {
277            Bootstrap::Restore { clear: true }
278        };
279    }
280
281    Bootstrap::Resume
282}
283
284/// A bootstrapped embedded rpc-store, ready to hand to the pruner and
285/// the rpc-api read path and to start tip indexing.
286pub struct EmbeddedRpcStore {
287    /// Shared store handle. Cloned for the pruner (via [`Self::store`])
288    /// and for the tip indexer.
289    store: RpcStore,
290
291    /// Read handle exposing the rpc-store's index surface to
292    /// `sui-rpc-api`.
293    reader: RpcStoreReader,
294
295    /// Local checkpoint source for the tip ingestion client.
296    ingestion_source: RocksDbStore,
297
298    chain_id: ChainIdentifier,
299
300    /// The bootstrap action [`decide`] selected for the on-disk store at
301    /// startup. Retained for introspection (see
302    /// [`Self::bootstrap_action`]); does not affect runtime behavior.
303    action: Bootstrap,
304
305    /// Service wrapping the background task that builds and runs the tip
306    /// indexer, populated by [`Self::spawn_indexer`]. Its single primary
307    /// task builds the indexer's inner [`Service`] and joins it; dropping
308    /// this service on node shutdown aborts that task, which in turn drops
309    /// and aborts the inner service's pipeline tasks. This is what keeps
310    /// the indexer from leaking (notably across e2e tests sharing a
311    /// process), so no explicit [`Drop`] is needed.
312    indexer_service: Option<Service>,
313}
314
315impl EmbeddedRpcStore {
316    /// Open the rpc-store, bring it in line with the perpetual store's
317    /// available range (restoring / seeding as needed), and build the
318    /// store and read handles.
319    ///
320    /// Blocks while restoring the live cohort. Call before the node
321    /// starts executing checkpoints, so the perpetual store's range is
322    /// stable for the duration of the restore.
323    pub async fn bootstrap(
324        config: &NodeConfig,
325        authority_store: &Arc<AuthorityStore>,
326        checkpoint_store: &Arc<CheckpointStore>,
327        ingestion_source: RocksDbStore,
328        chain_identifier: ChainIdentifier,
329        registry: &Registry,
330    ) -> anyhow::Result<Self> {
331        let perpetual = authority_store.perpetual_tables.clone();
332        let path = config.db_path().join(RPC_STORE_DIR);
333        let options = db_options(&config.authority_store_pruning_config)?;
334        let (db, schema) = open_db(&path, options).await?;
335        let schema = Arc::new(schema);
336
337        // Expose per-CF RocksDB stats (sizes, compaction backlog,
338        // write-stall state) for the store's database.
339        registry
340            .register(Box::new(ColumnFamilyStatsCollector::new(
341                Some(METRICS_PREFIX),
342                &db,
343            )))
344            .context("registering the embedded rpc-store RocksDB stats collector")?;
345
346        // The highest checkpoint whose transaction outputs are durably
347        // committed to the perpetual store. This is the live cohort's restore
348        // target: the bulk restore reads the live object set, so the target
349        // must match the checkpoint that set reflects. We use the perpetual
350        // store's `highest_committed` watermark (written atomically with the
351        // objects) rather than the checkpoint store's `highest_executed`
352        // (bumped separately afterward), so an unclean stop cannot leave the
353        // restore reading objects beyond its target and double-counting them
354        // against the forward indexer. `None` only on a node's very first boot
355        // (genesis is executed later in startup), in which case there is
356        // nothing to bulk-load and the indexer builds both cohorts from genesis
357        // as the node executes.
358        let highest_committed = perpetual
359            .get_highest_committed_checkpoint()
360            .context("reading highest committed checkpoint")?
361            // Fall back to the checkpoint store's executed watermark for a
362            // database written before the atomic `highest_committed` watermark
363            // existed: it has no stamp yet, so this preserves the prior restore
364            // target until the next committed checkpoint stamps the consistent
365            // one. In normal operation `highest_committed` is written before
366            // `highest_executed` is bumped, so it is never absent while the
367            // executed watermark is present.
368            .or(checkpoint_store
369                .get_highest_executed_checkpoint_seq_number()
370                .context("reading highest executed checkpoint")?);
371        let lowest_available = lowest_available_checkpoint(&perpetual, checkpoint_store)?;
372
373        let chain_id = ChainId(*chain_identifier.as_bytes());
374        let state = StoreState {
375            live_resume: cohort_resume(&db, LIVE_COHORT)?,
376            history_resume: cohort_resume(&db, HISTORY_COHORT)?.unwrap_or(0),
377            chain_matches: stored_chain_id(&db)?.map(|stored| stored == chain_id),
378            restore_in_progress: sui_rpc_store::restore_in_progress(&db)?,
379            history_seed_pending: sui_rpc_store::history_seed_pending(&db)?,
380        };
381
382        let action = decide(state, lowest_available);
383        info!(
384            ?action,
385            lowest_available,
386            ?highest_committed,
387            "bootstrapping embedded rpc-store",
388        );
389
390        match action {
391            Bootstrap::Resume => {}
392            Bootstrap::SeedHistory => {
393                seed_history(
394                    &db,
395                    &schema,
396                    &perpetual,
397                    checkpoint_store,
398                    lowest_available,
399                    chain_id,
400                )?;
401            }
402            Bootstrap::Restore { clear } => {
403                if clear {
404                    clear_rpc_store(&db, &schema)?;
405                }
406                // A synced node enabling the embedded store for the
407                // first time (or recovering an out-of-range one):
408                // bulk-load the live cohort, then seed the history cohort
409                // so it backfills `(L, T]`. When `highest_committed` is
410                // `None` (a fresh node, nothing committed yet) there is
411                // nothing to load -- every pipeline stays unwatermarked
412                // so the indexer builds both cohorts from genesis as
413                // checkpoints execute.
414                if let Some(target) = highest_committed {
415                    // At `L > 0` the history cohort backfills only
416                    // `(L, T]`, so the restore also bulk-loads the
417                    // `object_version_by_checkpoint` and
418                    // `package_versions` floor rows at `T` (the seed
419                    // below rewinds their watermarks to `L-1`). At
420                    // `L == 0` the backfill replays the whole chain
421                    // from genesis and rebuilds both CFs in full, so
422                    // those pipelines must not be restored at all --
423                    // the `__watermark = T` rows the restore would
424                    // stamp on them would make them skip `(0, T]`
425                    // entirely.
426                    let layer = if lowest_available > 0 {
427                        RestoreLayer::indexes_only()
428                    } else {
429                        RestoreLayer::live_only()
430                    };
431                    restore_live(
432                        db.clone(),
433                        schema.clone(),
434                        perpetual.clone(),
435                        target,
436                        chain_id,
437                        layer,
438                        registry,
439                    )
440                    .await?;
441                    // `L == 0` means genesis is still available, so the
442                    // history cohort backfills from checkpoint 0 with no
443                    // seed (every history pipeline -- including the two
444                    // the restore skipped -- is unwatermarked and
445                    // resumes at `first_checkpoint = 0`).
446                    if lowest_available > 0 {
447                        seed_history(
448                            &db,
449                            &schema,
450                            &perpetual,
451                            checkpoint_store,
452                            lowest_available,
453                            chain_id,
454                        )?;
455                    }
456                }
457            }
458        }
459
460        let store = sui_consistent_store::Store::new(db.clone(), schema.clone());
461        let reader = RpcStoreReader::new(db, schema);
462
463        Ok(Self {
464            store,
465            reader,
466            ingestion_source,
467            chain_id: chain_identifier,
468            action,
469            indexer_service: None,
470        })
471    }
472
473    /// The bootstrap action selected for the on-disk store at startup:
474    /// whether this run resumed the existing indexes, re-seeded the
475    /// history cohort, or rebuilt the live cohort. Read-only
476    /// introspection; primarily for tests.
477    pub fn bootstrap_action(&self) -> Bootstrap {
478        self.action
479    }
480
481    /// The highest checkpoint the live cohort has committed
482    /// (`min(checkpoint_hi_inclusive)` across its pipelines), i.e. how
483    /// far the live-object indexes have caught up to the tip. `None`
484    /// until every live pipeline has a watermark. Read-only
485    /// introspection; primarily for tests.
486    pub fn live_committed_checkpoint(&self) -> Option<u64> {
487        cohort_committed(self.store.db(), LIVE_COHORT)
488            .ok()
489            .flatten()
490    }
491
492    /// The highest checkpoint the history cohort has committed, i.e. how
493    /// far the ledger-history backfill has progressed. `None` until every
494    /// history pipeline has a watermark. Read-only introspection;
495    /// primarily for tests.
496    pub fn history_committed_checkpoint(&self) -> Option<u64> {
497        cohort_committed(self.store.db(), HISTORY_COHORT)
498            .ok()
499            .flatten()
500    }
501
502    /// A clone of the store handle, for the pruner's history-cohort
503    /// pruning ([`sui_rpc_store::prune_history_cohort`]).
504    pub fn store(&self) -> RpcStore {
505        self.store.clone()
506    }
507
508    /// A clone of the read handle, for the rpc-api read path
509    /// ([`crate::storage::RpcStoreReadStore`]).
510    pub fn reader(&self) -> RpcStoreReader {
511        self.reader.clone()
512    }
513
514    /// A callback reading the highest checkpoint the live cohort has
515    /// committed, for the subscription service's index gate (so a
516    /// checkpoint is not delivered to clients until its indexed state is
517    /// readable).
518    ///
519    /// Reads only the live cohort: the history cohort backfills
520    /// independently from the lowest available checkpoint, so gating on it
521    /// would hold back delivery on a restored node for the duration of the
522    /// backfill. On a node indexing from genesis the synchronizer keeps the
523    /// cohorts in lockstep, so the live cohort's progress implies the
524    /// history cohort's.
525    pub fn indexed_checkpoint_fn(&self) -> Arc<dyn Fn() -> Option<u64> + Send + Sync> {
526        let db = self.store.db().clone();
527        Arc::new(move || cohort_committed(&db, LIVE_COHORT).ok().flatten())
528    }
529
530    /// Spawn a background task that builds and runs the tip-following
531    /// indexer over the embedded store.
532    ///
533    /// The indexer is built on a background task -- rather than inline --
534    /// because the framework reads the starting tip via
535    /// `latest_checkpoint_number`, which on a node booting from genesis
536    /// blocks until the first checkpoint has been *executed*. At this
537    /// point genesis is only *synced* (the executor has not run yet), so
538    /// the read retries until the checkpoint executor catches up; that
539    /// executor only starts once `start_async` returns, so building
540    /// inline would deadlock node startup against a checkpoint that
541    /// cannot arrive until startup completes. (A resuming node has an
542    /// executed tip on disk and would not block, but the genesis case
543    /// forces the deferral unconditionally.) The follower catches up once
544    /// checkpoints begin to flow.
545    ///
546    /// The background task is held as a single-task [`Service`] so the
547    /// node owns the indexer's lifetime: dropping [`EmbeddedRpcStore`]
548    /// drops this service, which aborts the task and, with it, the inner
549    /// indexer service it is joining.
550    ///
551    /// A failure to *build* the indexer is fatal to the node
552    /// ([`mysten_common::fatal!`]): it will not heal on its own, and a
553    /// node whose index surface silently never advances is worse than a
554    /// crashed one.
555    ///
556    /// `checkpoint_sender` is the checkpoint executor's broadcast
557    /// stream; when present it drives a low-latency
558    /// [`BroadcastStreamingClient`], with the perpetual-store ingestion
559    /// client filling any gap. When absent (e.g. on a node that does
560    /// not run the rpc servers) the ingestion client polls the
561    /// perpetual store alone.
562    pub fn spawn_indexer(
563        &mut self,
564        checkpoint_sender: Option<broadcast::Sender<Arc<Checkpoint>>>,
565        registry: Registry,
566    ) {
567        let store = self.store.clone();
568        let ingestion_source = self.ingestion_source.clone();
569        let chain_id = self.chain_id;
570
571        let service = Service::new().spawn(async move {
572            // A node whose embedded indexer never starts would keep
573            // serving RPC from index CFs that never advance -- a frozen
574            // (or, on a fresh node, permanently empty) index surface
575            // with nothing but a log line to say why. Build failure is
576            // a configuration or database problem that will not heal on
577            // its own, so fail the node instead of limping.
578            let mut service = match build_indexer(
579                store,
580                ingestion_source,
581                chain_id,
582                checkpoint_sender,
583                &registry,
584            )
585            .await
586            {
587                Ok(service) => service,
588                Err(e) => {
589                    fatal!("failed to build the embedded rpc-store indexer: {e:#}");
590                }
591            };
592            // Hold the service for the task's lifetime; `join` only
593            // returns if an indexer task exits (it otherwise runs for the
594            // node's lifetime). Deliberately not fatal: an error here can
595            // be a teardown race with node shutdown (this task is aborted
596            // on drop, but the inner pipelines may observe the closing
597            // stores first), and a genuine mid-run death is surfaced by
598            // the health check as an ever-growing gap between the
599            // executed tip and the frozen live-index watermark.
600            if let Err(e) = service.join().await {
601                error!("the embedded rpc-store indexer exited with an error: {e:#}");
602            }
603            Ok(())
604        });
605        self.indexer_service = Some(service);
606    }
607}
608
609/// Build the tip-following indexer over `store`, register the embedded
610/// cohort pipelines, and run it. Returns the composed [`Service`]
611/// driving ingestion, the synchronizer, and the committers.
612async fn build_indexer(
613    store: RpcStore,
614    ingestion_source: RocksDbStore,
615    chain_id: ChainIdentifier,
616    checkpoint_sender: Option<broadcast::Sender<Arc<Checkpoint>>>,
617    registry: &Registry,
618) -> anyhow::Result<Service> {
619    let ingestion_metrics = IngestionMetrics::new(Some(METRICS_PREFIX), registry);
620    let ingestion_client = IngestionClient::from_trait(
621        Arc::new(PerpetualStoreIngestionClient::new(
622            ingestion_source.clone(),
623            chain_id,
624        )),
625        ingestion_metrics,
626    );
627    // The broadcast streaming client follows the tip with low latency; it
628    // reads the current tip from the same local store the ingestion
629    // client uses (so the framework's `peek()` resolves immediately even
630    // on an idle chain), and the ingestion client backfills any gap.
631    let streaming_client: Option<ArcStreamingClient> = checkpoint_sender.map(|sender| {
632        Arc::new(BroadcastStreamingClient::new(
633            sender,
634            chain_id,
635            ingestion_source,
636        )) as ArcStreamingClient
637    });
638
639    let mut indexer = Indexer::from_store(
640        store,
641        IndexerArgs::default(),
642        ingestion_client,
643        streaming_client,
644        None,
645        ConsistencyConfig::default(),
646        // Pruning is driven by the validator's `AuthorityStorePruner`
647        // (history cohort only), not the rpc-store's own pruner.
648        None,
649        IngestionConfig::default(),
650        registry,
651    )
652    .await
653    .context("constructing the embedded rpc-store indexer")?;
654    indexer
655        .add_pipelines(PipelineLayer::embedded(), CommitterConfig::default())
656        .await
657        .context("registering embedded rpc-store pipelines")?;
658    indexer
659        .run()
660        .await
661        .context("starting the embedded rpc-store indexer")
662}
663
664/// The lowest checkpoint the perpetual store can still serve: one past
665/// the higher of the object-store and checkpoint-store pruned
666/// watermarks (both inclusive). `0` when nothing has been pruned.
667fn lowest_available_checkpoint(
668    perpetual: &AuthorityPerpetualTables,
669    checkpoint_store: &CheckpointStore,
670) -> anyhow::Result<u64> {
671    let object_pruned = perpetual
672        .get_highest_pruned_checkpoint()
673        .context("reading object store pruned watermark")?;
674    let checkpoint_pruned = checkpoint_store
675        .get_highest_pruned_checkpoint_seq_number()
676        .context("reading checkpoint store pruned watermark")?;
677    Ok(object_pruned
678        .into_iter()
679        .chain(checkpoint_pruned)
680        .max()
681        .map(|pruned| pruned + 1)
682        .unwrap_or(0))
683}
684
685/// The highest checkpoint every pipeline in `cohort` has committed
686/// (`min(checkpoint_hi_inclusive)`). `None` if any pipeline in the
687/// cohort has no committed watermark.
688fn cohort_committed(db: &Db, cohort: &[&str]) -> anyhow::Result<Option<u64>> {
689    let framework = db.framework();
690    let mut min_hi: Option<u64> = None;
691    for name in cohort {
692        let key = PipelineTaskKey::new(*name);
693        let Some(watermark) = framework
694            .watermarks
695            .get(&key)
696            .with_context(|| format!("reading watermark for {name}"))?
697        else {
698            return Ok(None);
699        };
700        min_hi = Some(match min_hi {
701            Some(hi) => hi.min(watermark.checkpoint_hi_inclusive),
702            None => watermark.checkpoint_hi_inclusive,
703        });
704    }
705    Ok(min_hi)
706}
707
708/// The lowest checkpoint tip indexing would resume from across a
709/// cohort: `min(checkpoint_hi_inclusive) + 1`. `None` if any pipeline
710/// in the cohort has no committed watermark.
711fn cohort_resume(db: &Db, cohort: &[&str]) -> anyhow::Result<Option<u64>> {
712    Ok(cohort_committed(db, cohort)?.map(|hi| hi + 1))
713}
714
715/// The chain id the database is bound to, read from the first pipeline
716/// that has one recorded. All pipelines pin the same chain, so any one
717/// is representative.
718fn stored_chain_id(db: &Db) -> anyhow::Result<Option<ChainId>> {
719    let framework = db.framework();
720    for name in LIVE_COHORT.iter().chain(HISTORY_COHORT) {
721        let key = PipelineTaskKey::new(*name);
722        if let Some(id) = framework
723            .chain_ids
724            .get(&key)
725            .with_context(|| format!("reading chain id for {name}"))?
726        {
727            return Ok(Some(id));
728        }
729    }
730    Ok(None)
731}
732
733/// Bulk-load the live cohort from the perpetual store up to
734/// `target_checkpoint`, blocking until the restore completes.
735///
736/// `layer` selects whether the `object_version_by_checkpoint` and
737/// `package_versions` floor rows are also bulk-loaded
738/// ([`RestoreLayer::indexes_only`], for `L > 0`) or left entirely to
739/// the from-genesis backfill ([`RestoreLayer::live_only`], for
740/// `L == 0`).
741async fn restore_live(
742    db: Db,
743    schema: Arc<RpcStoreSchema>,
744    perpetual: Arc<AuthorityPerpetualTables>,
745    target_checkpoint: u64,
746    chain_id: ChainId,
747    layer: RestoreLayer,
748    registry: &Registry,
749) -> anyhow::Result<()> {
750    let source = PerpetualStoreRestoreSource::new(perpetual, target_checkpoint, chain_id);
751    let metrics = RestoreMetrics::new(Some(METRICS_PREFIX), registry);
752    let mut service = restore_indexes(
753        db,
754        schema,
755        source,
756        RestoreDriverConfig::default(),
757        layer,
758        metrics,
759    )
760    .context("starting the live-cohort restore")?;
761    service
762        .join()
763        .await
764        .context("restoring the live cohort from the perpetual store")?;
765    Ok(())
766}
767
768/// Seed the history cohort to `L - 1` so the backfill resumes at the
769/// lowest available checkpoint `L`. The seed watermark's `tx_hi`,
770/// epoch, and timestamp come from checkpoint `L - 1`'s summary, so the
771/// seeded pruning floor lines up with the first checkpoint the backfill
772/// will index.
773fn seed_history(
774    db: &Db,
775    schema: &RpcStoreSchema,
776    perpetual: &AuthorityPerpetualTables,
777    checkpoint_store: &CheckpointStore,
778    lowest_available: u64,
779    chain_id: ChainId,
780) -> anyhow::Result<()> {
781    debug_assert!(lowest_available > 0, "seed_history requires L > 0");
782    let anchor = lowest_available - 1;
783    let checkpoint = checkpoint_store
784        .get_checkpoint_by_sequence_number(anchor)
785        .context("reading the history seed-anchor checkpoint")?
786        .with_context(|| format!("history seed-anchor checkpoint {anchor} is unavailable"))?;
787    let summary = checkpoint.data();
788    let watermark = Watermark {
789        epoch_hi_inclusive: summary.epoch,
790        checkpoint_hi_inclusive: anchor,
791        tx_hi: summary.network_total_transactions,
792        timestamp_ms_hi_inclusive: summary.timestamp_ms,
793    };
794    seed_history_cohort(
795        db,
796        schema,
797        watermark,
798        chain_id,
799        Some(perpetual as &dyn ObjectStore),
800    )
801    .context("seeding the history cohort")
802}
803
804#[cfg(test)]
805mod tests {
806    use sui_rpc_store::schema::pruning_watermark;
807
808    use super::*;
809
810    /// A [`StoreState`] with neither a mid-run restore nor a pending
811    /// history seed — the shape of every store that was not stopped
812    /// inside the bootstrap's own restore/seed sequence.
813    fn state(
814        live_resume: Option<u64>,
815        history_resume: u64,
816        chain_matches: Option<bool>,
817    ) -> StoreState {
818        StoreState {
819            live_resume,
820            history_resume,
821            chain_matches,
822            restore_in_progress: false,
823            history_seed_pending: false,
824        }
825    }
826
827    #[test]
828    fn bitmap_periodic_compaction_days_convert_to_seconds() {
829        assert_eq!(bitmap_periodic_compaction_seconds(0).unwrap(), 0);
830        assert_eq!(bitmap_periodic_compaction_seconds(30).unwrap(), 2_592_000);
831        let error = bitmap_periodic_compaction_seconds(u64::MAX).unwrap_err();
832        let message = format!("{error:#}");
833        assert!(
834            message.contains("rpc-store-bitmap-periodic-compaction-days"),
835            "{message}"
836        );
837        assert!(message.contains(&u64::MAX.to_string()), "{message}");
838    }
839
840    #[test]
841    fn bitmap_periodic_compaction_defaults_and_overrides_target_only_bitmap_cfs() {
842        let defaults = db_options(&AuthorityStorePruningConfig::default())
843            .unwrap()
844            .rocksdb;
845        assert_eq!(
846            defaults.column_family[transaction_bitmap::NAME].periodic_compaction_seconds,
847            Some(7 * SECONDS_PER_DAY)
848        );
849        assert_eq!(
850            defaults.column_family[event_bitmap::NAME].periodic_compaction_seconds,
851            Some(7 * SECONDS_PER_DAY)
852        );
853
854        let config = db_options(&AuthorityStorePruningConfig {
855            rpc_store_bitmap_periodic_compaction_days: Some(17),
856            ..Default::default()
857        })
858        .unwrap()
859        .rocksdb;
860        assert_eq!(
861            config.column_family[transaction_bitmap::NAME].periodic_compaction_seconds,
862            Some(17 * SECONDS_PER_DAY)
863        );
864        assert_eq!(
865            config.column_family[event_bitmap::NAME].periodic_compaction_seconds,
866            Some(17 * SECONDS_PER_DAY)
867        );
868        assert_eq!(config.default_cf.periodic_compaction_seconds, None);
869        for (name, tuning) in &config.column_family {
870            if tuning.periodic_compaction_seconds.is_some() {
871                assert!(
872                    [transaction_bitmap::NAME, event_bitmap::NAME].contains(&name.as_str()),
873                    "periodic compaction unexpectedly configured for {name}",
874                );
875            }
876        }
877    }
878
879    #[test]
880    fn snapshot_retention_follows_pruning_config() {
881        let take_snapshots = |config: &AuthorityStorePruningConfig| {
882            let dir = tempfile::tempdir().unwrap();
883            let (db, _schema) =
884                Db::open::<RpcStoreSchema>(dir.path(), db_options(config).unwrap()).unwrap();
885            for checkpoint in 1..=5 {
886                db.take_snapshot(Watermark::for_checkpoint(checkpoint));
887            }
888            db.snapshot_range()
889        };
890
891        assert_eq!(
892            take_snapshots(&AuthorityStorePruningConfig::default()),
893            None
894        );
895
896        let retain_three = AuthorityStorePruningConfig {
897            rpc_store_num_checkpoint_snapshots_to_retain: 3,
898            ..Default::default()
899        };
900        assert_eq!(take_snapshots(&retain_three), Some(3..=5));
901    }
902
903    #[test]
904    fn clear_rpc_store_resets_the_bitmap_floor() {
905        let dir = tempfile::tempdir().unwrap();
906        let (db, schema) =
907            Db::open::<RpcStoreSchema>(dir.path(), db_options(&Default::default()).unwrap())
908                .unwrap();
909        let floor = transaction_bitmap::TX_BUCKET_SIZE;
910        let (watermark_key, watermark_value) =
911            pruning_watermark::store(&pruning_watermark::Watermarks {
912                tx_seq_lo: floor,
913                checkpoint_lo: 1,
914            });
915        let mut batch = db.batch();
916        batch
917            .put(&schema.pruning_watermark, &watermark_key, &watermark_value)
918            .unwrap();
919        batch.commit().unwrap();
920        schema.set_pruning_floor(floor);
921
922        clear_rpc_store(&db, &schema).unwrap();
923        assert!(schema.get_pruning_watermarks().unwrap().is_none());
924
925        let dimension = b"after-clear".to_vec();
926        let (tx_key, tx_value) = transaction_bitmap::store_match(dimension.clone(), 5);
927        let (event_key, event_value) = event_bitmap::store_match(dimension.clone(), 5, 0);
928        let mut batch = db.batch();
929        batch
930            .put(&schema.transaction_bitmap, &tx_key, &tx_value)
931            .unwrap();
932        batch
933            .put(&schema.event_bitmap, &event_key, &event_value)
934            .unwrap();
935        batch.commit().unwrap();
936        db.flush().unwrap();
937        db.compact_range_cf(transaction_bitmap::NAME, None, None)
938            .unwrap();
939        db.compact_range_cf(event_bitmap::NAME, None, None).unwrap();
940
941        assert!(
942            schema
943                .get_transaction_bitmap(dimension.clone(), tx_key.bucket)
944                .unwrap()
945                .is_some()
946        );
947        assert!(
948            schema
949                .get_event_bitmap(dimension, event_key.bucket)
950                .unwrap()
951                .is_some()
952        );
953    }
954
955    // `L = 0` (nothing pruned): an unseeded history cohort backfills
956    // from genesis, so a complete live cohort is enough to resume.
957    #[test]
958    fn resumes_from_genesis_when_nothing_pruned() {
959        assert_eq!(decide(state(Some(10), 0, Some(true)), 0), Bootstrap::Resume);
960        // History never seeded (resume 0) is fine at L = 0.
961        assert_eq!(decide(state(Some(10), 0, None), 0), Bootstrap::Resume);
962    }
963
964    // Both cohorts resume at or above the available floor.
965    #[test]
966    fn resumes_when_in_range() {
967        assert_eq!(
968            decide(state(Some(100), 100, Some(true)), 100),
969            Bootstrap::Resume
970        );
971        assert_eq!(
972            decide(state(Some(200), 100, Some(true)), 100),
973            Bootstrap::Resume
974        );
975    }
976
977    // A bulk restore crashed mid-run: resume it in place from its
978    // per-shard cursors rather than discarding the partial progress.
979    #[test]
980    fn resumes_restore_in_progress_without_clearing() {
981        assert_eq!(
982            decide(
983                StoreState {
984                    restore_in_progress: true,
985                    ..state(None, 0, None)
986                },
987                0,
988            ),
989            Bootstrap::Restore { clear: false }
990        );
991        assert_eq!(
992            decide(
993                StoreState {
994                    restore_in_progress: true,
995                    ..state(None, 50, Some(true))
996                },
997                100,
998            ),
999            Bootstrap::Restore { clear: false }
1000        );
1001    }
1002
1003    // No live watermark and no restore in progress: a fresh store (the
1004    // clear is a no-op) or a from-genesis run that crashed between the
1005    // live pipelines' first commits (whose partial rows the bulk
1006    // restore would otherwise merge on top of, double-counting
1007    // balances). Either way, start from a clean slate.
1008    #[test]
1009    fn clears_when_live_unwatermarked_without_restore_in_progress() {
1010        assert_eq!(
1011            decide(state(None, 0, None), 0),
1012            Bootstrap::Restore { clear: true }
1013        );
1014        assert_eq!(
1015            decide(state(None, 50, Some(true)), 100),
1016            Bootstrap::Restore { clear: true }
1017        );
1018    }
1019
1020    // The live cohort references checkpoints the perpetual store has
1021    // pruned away: wipe and rebuild.
1022    #[test]
1023    fn clears_and_restores_when_live_out_of_range() {
1024        assert_eq!(
1025            decide(state(Some(50), 200, Some(true)), 100),
1026            Bootstrap::Restore { clear: true }
1027        );
1028    }
1029
1030    // A database bound to a different chain is always wiped.
1031    #[test]
1032    fn clears_and_restores_on_chain_mismatch() {
1033        assert_eq!(
1034            decide(state(Some(200), 200, Some(false)), 100),
1035            Bootstrap::Restore { clear: true }
1036        );
1037        // Chain mismatch dominates even an otherwise-resumable state.
1038        assert_eq!(
1039            decide(state(Some(200), 200, Some(false)), 0),
1040            Bootstrap::Restore { clear: true }
1041        );
1042    }
1043
1044    // The crash window between the restore's finalize and the history
1045    // seed: the floor pipelines hold the restore watermark, nothing
1046    // else in the history cohort has committed, and re-running the
1047    // seed alone repairs the store.
1048    #[test]
1049    fn seeds_history_only_in_post_restore_state() {
1050        assert_eq!(
1051            decide(
1052                StoreState {
1053                    history_seed_pending: true,
1054                    ..state(Some(200), 0, Some(true))
1055                },
1056                100,
1057            ),
1058            Bootstrap::SeedHistory
1059        );
1060        // History exactly at the floor resumes.
1061        assert_eq!(
1062            decide(state(Some(200), 100, Some(true)), 100),
1063            Bootstrap::Resume
1064        );
1065    }
1066
1067    // Committed history coverage below the available floor (e.g.
1068    // pruning advanced while indexing was disabled): the gap up to the
1069    // floor is pruned and unfillable, so seeding in place would leave
1070    // stale sub-floor rows poisoning checkpoint-pinned reads above the
1071    // floor. Wipe and rebuild instead.
1072    #[test]
1073    fn clears_when_history_coverage_behind_floor() {
1074        // The whole history cohort committed below the floor.
1075        assert_eq!(
1076            decide(state(Some(200), 50, Some(true)), 100),
1077            Bootstrap::Restore { clear: true }
1078        );
1079        // Partially unwatermarked history (resume 0) that is not the
1080        // post-restore state — e.g. a live_only bootstrap whose
1081        // history cohort never committed before pruning advanced.
1082        assert_eq!(
1083            decide(state(Some(200), 0, Some(true)), 100),
1084            Bootstrap::Restore { clear: true }
1085        );
1086    }
1087}