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