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