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 ®istry,
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}