Skip to main content

sui_rpc_store/
config.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Runtime configuration for the `sui-rpc-store` indexer.
5//!
6//! The indexer is driven by [`ServiceConfig`], which groups the
7//! ingestion, consistency, RocksDB, committer, and per-pipeline
8//! settings the orchestrator needs.
9//!
10//! Per-pipeline enable/disable is expressed through
11//! [`PipelineLayer`]: every pipeline maps to an
12//! `Option<CommitterLayer>` field; `Some(_)` means the pipeline is
13//! registered (with the supplied committer overrides), `None` means
14//! it is skipped. The standalone binary populates the layer from
15//! its TOML config; the embedded-fullnode caller builds it
16//! programmatically via [`PipelineLayer::embedded`] so the raw
17//! chain CFs (served by the fullnode's perpetual store) are not
18//! double-written by this indexer.
19
20use std::time::Duration;
21
22use serde::Deserialize;
23use serde::Serialize;
24use sui_indexer_alt_framework::config::warn_ignored_config_key;
25use sui_indexer_alt_framework::pipeline::CommitterConfig;
26
27/// Top-level configuration for the `sui-rpc-store` indexer
28/// service. Parses from TOML; every field has a sensible default
29/// for tests and for the embedded use case where most knobs are
30/// supplied programmatically.
31#[derive(Default, Deserialize, Serialize)]
32#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
33pub struct ServiceConfig {
34    /// Cross-pipeline consistency knobs: how often to take
35    /// snapshots and how deep the per-pipeline write buffer is.
36    pub consistency: ConsistencyConfig,
37
38    /// Default committer settings shared by all pipelines.
39    /// Per-pipeline entries in [`PipelineLayer`] can override
40    /// individual fields.
41    pub committer: CommitterLayer,
42
43    /// Per-pipeline enable/disable plus optional committer
44    /// overrides.
45    pub pipeline: PipelineLayer,
46
47    /// Pruning policy for the historical CFs. Absent (the default)
48    /// disables pruning entirely — the store retains all history.
49    pub pruner: Option<PrunerConfig>,
50}
51
52/// Cross-pipeline consistency knobs surfaced to operators. The
53/// indexer threads these into the [`Synchronizer`] at startup.
54///
55/// Snapshot *retention* (how many in-memory snapshots are kept, and
56/// thus how far back consistent reads can reach) is not configured
57/// here: it is an open-time property of the database, set via
58/// [`DbOptions::snapshot_capacity`]. Because a snapshot is taken at
59/// every checkpoint boundary, the effective consistent-read window
60/// is roughly `snapshot_capacity` checkpoints.
61///
62/// [`Synchronizer`]: sui_consistent_store::Synchronizer
63/// [`DbOptions::snapshot_capacity`]: sui_consistent_store::DbOptions::snapshot_capacity
64#[derive(Clone, Deserialize, Serialize)]
65#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
66pub struct ConsistencyConfig {
67    /// Per-pipeline mpsc capacity for batches waiting to be
68    /// committed. The synchronizer's slowest pipeline gates
69    /// progress; this buffer absorbs short bursts of slack between
70    /// peer pipelines before back-pressure kicks in.
71    pub buffer_size: usize,
72}
73
74/// Pruning policy for the historical column families.
75///
76/// Retention is expressed in epochs, mirroring the validator's
77/// perpetual-store pruner: the `retention_epochs` most-recent
78/// epochs (including the current one) are retained in full, and
79/// everything in older epochs becomes eligible for deletion. The
80/// resulting floor is additionally clamped so it never advances past
81/// the oldest in-memory snapshot, keeping point-in-time reads
82/// coherent even under an aggressively small retention.
83///
84/// The pruner advances the floor toward its target in chunks of at
85/// most `max_chunk_checkpoints` checkpoints, persisting the new
86/// watermark after each chunk so progress survives a restart. Each
87/// tick advances the floor by at most `max_checkpoints_per_tick`
88/// checkpoints so a large backlog drains across many ticks rather
89/// than one long blocking pass.
90///
91/// Only the historical CFs are pruned: the per-transaction
92/// (`transactions`, `effects`, `events`, `tx_metadata_by_seq`),
93/// per-checkpoint (`checkpoint_summary`, `checkpoint_contents`),
94/// digest-reverse-index (`tx_seq_by_digest`,
95/// `checkpoint_seq_by_digest`), superseded-`objects`-version,
96/// checkpoint-pinned `object_version_by_checkpoint`, and
97/// ledger-history bitmap CFs. The live-set-bounded indexes
98/// (`object_by_owner`, `object_by_type`, `balance`,
99/// `package_versions`) and the tiny `epochs` CF are never pruned.
100#[derive(Clone, Deserialize, Serialize)]
101#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
102pub struct PrunerConfig {
103    /// Number of most-recent epochs to retain in full. Data in
104    /// epochs older than this is eligible for pruning. Must be at
105    /// least `1`; the pruner refuses to start otherwise, since a
106    /// value of `0` would prune the current epoch.
107    pub retention_epochs: u64,
108
109    /// How often the pruner wakes to recompute the target floor and
110    /// advance toward it, in milliseconds.
111    pub interval_ms: u64,
112
113    /// Maximum number of checkpoints whose data is deleted in a
114    /// single write batch. Bounds the per-batch work (and the number
115    /// of effects rows scanned for object/digest deletes) when a
116    /// whole epoch ages out at once.
117    pub max_chunk_checkpoints: u64,
118
119    /// Maximum number of checkpoints whose history is pruned in a
120    /// single tick. Bounds the per-tick (blocking) work so that a
121    /// large backlog — for example when pruning is first enabled on
122    /// an old database — drains across many ticks rather than one
123    /// long pass that occupies a blocking thread for minutes. The
124    /// floor still converges to its retention target over subsequent
125    /// ticks; `interval_ms` and this bound together set the drain
126    /// rate. Must be at least `1`; the pruner refuses to start
127    /// otherwise, since a value of `0` would never make progress.
128    pub max_checkpoints_per_tick: u64,
129}
130
131impl Default for PrunerConfig {
132    fn default() -> Self {
133        Self {
134            retention_epochs: 30,
135            interval_ms: 300_000,
136            max_chunk_checkpoints: 100,
137            // 100 chunks per tick at the default chunk size. Far above
138            // the steady-state rate at which a single epoch ages out,
139            // so retention is honored without intervention, while a
140            // first-run backlog on an old database is still bounded
141            // per tick rather than drained in one blocking pass.
142            max_checkpoints_per_tick: 10_000,
143        }
144    }
145}
146
147impl PrunerConfig {
148    /// The pruner's wake interval as a [`Duration`].
149    pub fn interval(&self) -> Duration {
150        Duration::from_millis(self.interval_ms)
151    }
152}
153
154/// Per-pipeline registration + override map. Every pipeline that
155/// writes to a CF in [`RpcStoreSchema`] has a corresponding
156/// `Option<CommitterLayer>` field here.
157///
158/// `Some(layer)` registers the pipeline with the supplied committer
159/// overrides folded onto the shared [`CommitterLayer`] default;
160/// `None` skips the pipeline entirely (e.g. the raw chain CFs in
161/// the embedded-fullnode case, where the fullnode populates them
162/// through a separate path).
163///
164/// Grouped in the struct for documentation only — serde sees each
165/// field as a top-level key.
166///
167/// [`RpcStoreSchema`]: crate::RpcStoreSchema
168#[derive(Default, Deserialize, Serialize)]
169#[serde(default, rename_all = "kebab-case")]
170pub struct PipelineLayer {
171    // --- Raw chain data ---
172    pub epochs: Option<CommitterLayer>,
173    pub checkpoint_summary: Option<CommitterLayer>,
174    pub checkpoint_contents: Option<CommitterLayer>,
175    pub checkpoint_seq_by_digest: Option<CommitterLayer>,
176    pub transactions: Option<CommitterLayer>,
177    pub tx_seq_by_digest: Option<CommitterLayer>,
178    pub tx_metadata_by_seq: Option<CommitterLayer>,
179    pub effects: Option<CommitterLayer>,
180    pub events: Option<CommitterLayer>,
181    pub objects: Option<CommitterLayer>,
182    pub object_version_by_checkpoint: Option<CommitterLayer>,
183
184    // --- Indexes ---
185    pub object_by_owner: Option<CommitterLayer>,
186    pub object_by_type: Option<CommitterLayer>,
187    pub balance: Option<CommitterLayer>,
188    pub package_versions: Option<CommitterLayer>,
189    pub transaction_bitmap: Option<CommitterLayer>,
190    pub event_bitmap: Option<CommitterLayer>,
191}
192
193/// Per-pipeline committer overrides. Every field is optional; an
194/// unset field inherits from the shared committer default the
195/// orchestrator passes through to
196/// [`CommitterLayer::finish`](Self::finish).
197#[derive(Default, Deserialize, Serialize)]
198#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
199pub struct CommitterLayer {
200    pub write_concurrency: Option<usize>,
201    pub watermark_interval_ms: Option<u64>,
202
203    /// Minimum number of pending rows the collector waits for before cutting a batch. `0` cuts a
204    /// batch as soon as the committer has room for it.
205    pub min_batch_rows: Option<usize>,
206
207    /// Longest time, in milliseconds, the oldest pending row waits for `min-batch-rows` to
208    /// accumulate before a batch is cut anyway.
209    pub max_batch_delay_ms: Option<u64>,
210
211    /// Deprecated and ignored: batches are cut as soon as the committer has room; see
212    /// `min-batch-rows` and `max-batch-delay-ms`.
213    pub collect_interval_ms: Option<u64>,
214}
215
216impl ServiceConfig {
217    /// Configuration matching [`Self::default()`] but with every
218    /// pipeline explicitly enabled and the committer layer
219    /// initialised from [`CommitterConfig::default()`]. Suitable
220    /// for surfacing as a TOML example.
221    pub fn example() -> Self {
222        Self {
223            consistency: ConsistencyConfig::default(),
224            committer: CommitterConfig::default().into(),
225            pipeline: PipelineLayer::all(),
226            pruner: Some(PrunerConfig::default()),
227        }
228    }
229}
230
231impl PipelineLayer {
232    /// Every pipeline enabled with default committer overrides
233    /// (`Some(CommitterLayer::default())`). The standalone-binary
234    /// default.
235    pub fn all() -> Self {
236        Self {
237            epochs: Some(CommitterLayer::default()),
238            checkpoint_summary: Some(CommitterLayer::default()),
239            checkpoint_contents: Some(CommitterLayer::default()),
240            checkpoint_seq_by_digest: Some(CommitterLayer::default()),
241            transactions: Some(CommitterLayer::default()),
242            tx_seq_by_digest: Some(CommitterLayer::default()),
243            tx_metadata_by_seq: Some(CommitterLayer::default()),
244            effects: Some(CommitterLayer::default()),
245            events: Some(CommitterLayer::default()),
246            objects: Some(CommitterLayer::default()),
247            object_version_by_checkpoint: Some(CommitterLayer::default()),
248            object_by_owner: Some(CommitterLayer::default()),
249            object_by_type: Some(CommitterLayer::default()),
250            balance: Some(CommitterLayer::default()),
251            package_versions: Some(CommitterLayer::default()),
252            transaction_bitmap: Some(CommitterLayer::default()),
253            event_bitmap: Some(CommitterLayer::default()),
254        }
255    }
256
257    /// The embedded-fullnode cohort: every pipeline this indexer owns
258    /// when it runs inside a Sui fullnode beside the validator's
259    /// perpetual store.
260    ///
261    /// The raw chain-data CFs (`transactions`, `effects`, `events`,
262    /// `objects`, `checkpoint_summary`, `checkpoint_contents`,
263    /// `checkpoint_seq_by_digest`) are left `None`: the perpetual store
264    /// already holds that data and serves it directly, so this indexer
265    /// must not double-write it.
266    ///
267    /// The enabled pipelines form two cohorts, both simply registered
268    /// here. The [`Indexer`](crate::Indexer) makes every live-cohort
269    /// pipeline a required member of each snapshot; the
270    /// [`Synchronizer`](sui_consistent_store::Synchronizer) admits
271    /// history-cohort pipelines to snapshots by their persisted
272    /// watermark, once they catch up:
273    ///
274    /// - **Live cohort** — restored to the fullnode's tip and
275    ///   following live from there: `object_by_owner`,
276    ///   `object_by_type`, `balance`.
277    /// - **History cohort** — seeded to the lowest available
278    ///   checkpoint and backfilling upward: `epochs`,
279    ///   `object_version_by_checkpoint`, `package_versions`,
280    ///   `tx_seq_by_digest`, `tx_metadata_by_seq`, `transaction_bitmap`,
281    ///   `event_bitmap`. These back the ledger-history list APIs (the
282    ///   bitmaps plus the `tx_seq` <-> digest maps needed to interpret
283    ///   bitmap results) and the per-epoch protocol/committee reads
284    ///   (`epochs`). `object_version_by_checkpoint` and
285    ///   `package_versions` are additionally restored at the tip for
286    ///   their floor rows, then backfill the per-checkpoint detail over
287    ///   `(L, T]` (see the cohort docs in
288    ///   [`restore`](crate::indexer::restore)).
289    pub fn embedded() -> Self {
290        Self {
291            // Live cohort: restored to the tip, follows live.
292            object_by_owner: Some(CommitterLayer::default()),
293            object_by_type: Some(CommitterLayer::default()),
294            balance: Some(CommitterLayer::default()),
295            // History cohort: seeded to L, backfills upward.
296            // `object_version_by_checkpoint` and `package_versions` are
297            // additionally restored at the tip for their floor rows (see
298            // the cohort docs in `restore.rs`).
299            epochs: Some(CommitterLayer::default()),
300            object_version_by_checkpoint: Some(CommitterLayer::default()),
301            package_versions: Some(CommitterLayer::default()),
302            tx_seq_by_digest: Some(CommitterLayer::default()),
303            tx_metadata_by_seq: Some(CommitterLayer::default()),
304            transaction_bitmap: Some(CommitterLayer::default()),
305            event_bitmap: Some(CommitterLayer::default()),
306            ..Self::default()
307        }
308    }
309}
310
311/// Per-pipeline registration toggles for
312/// [`restore_indexes`](crate::restore_indexes).
313///
314/// The live-cohort pipelines (`object_by_owner`, `object_by_type`,
315/// `balance`) are always restored — they cannot be reconstructed
316/// from anywhere else. The rest is conditional:
317///
318/// - The history floor rows (`object_version_by_checkpoint`,
319///   `package_versions`) matter only when some prefix of the chain
320///   is unavailable for backfill. A caller whose history cohort
321///   replays from genesis skips them — the backfill rebuilds both
322///   CFs in full, so restoring them would waste bulk-load work and
323///   stamp watermarks the backfill would have to be rewound past.
324/// - The raw `objects` CF is needed by the standalone deployment so
325///   version-keyed reads are served by the restored snapshot; the
326///   embedded-fullnode deployment already has every object version
327///   in the validator's perpetual store and skips the duplicate
328///   write.
329#[derive(Default, Clone, Debug)]
330pub struct RestoreLayer {
331    /// If true, register the `objects` pipeline with the restore
332    /// driver so each live object lands as an
333    /// `(ObjectID, version) → StoredObject` row.
334    pub objects: bool,
335
336    /// If true, register the `object_version_by_checkpoint` and
337    /// `package_versions` pipelines so their floor rows at the
338    /// restore target are bulk-loaded (see
339    /// [`HISTORY_COHORT`](crate::HISTORY_COHORT)'s docs for how the
340    /// history seed then rewinds their watermarks).
341    pub history_floors: bool,
342}
343
344impl RestoreLayer {
345    /// Restore every pipeline, including the raw `objects` CF.
346    /// The standalone-binary default.
347    pub fn all() -> Self {
348        Self {
349            objects: true,
350            history_floors: true,
351        }
352    }
353
354    /// Restore only the derived-index pipelines. The embedded-
355    /// fullnode default when the perpetual store has pruned
356    /// (`L > 0`) — the fullnode's perpetual store already holds
357    /// every object version, so the `objects` CF is left untouched
358    /// here.
359    pub fn indexes_only() -> Self {
360        Self {
361            objects: false,
362            history_floors: true,
363        }
364    }
365
366    /// Restore only the live-cohort index pipelines. The embedded-
367    /// fullnode choice when nothing has been pruned (`L == 0`): the
368    /// history cohort backfills from genesis and rebuilds
369    /// `object_version_by_checkpoint` and `package_versions` in
370    /// full, so their floor rows (and the restore watermarks that
371    /// come with them) must not be written.
372    pub fn live_only() -> Self {
373        Self {
374            objects: false,
375            history_floors: false,
376        }
377    }
378}
379
380impl CommitterLayer {
381    /// Fold the override layer onto a shared default
382    /// [`CommitterConfig`]. Unset fields inherit from `base`.
383    pub fn finish(self, base: CommitterConfig) -> CommitterConfig {
384        if self.collect_interval_ms.is_some() {
385            warn_ignored_config_key("collect-interval-ms");
386        }
387
388        CommitterConfig {
389            write_concurrency: self.write_concurrency.unwrap_or(base.write_concurrency),
390            watermark_interval_ms: self
391                .watermark_interval_ms
392                .unwrap_or(base.watermark_interval_ms),
393            min_batch_rows: self.min_batch_rows.unwrap_or(base.min_batch_rows),
394            max_batch_delay_ms: self.max_batch_delay_ms.unwrap_or(base.max_batch_delay_ms),
395            ..base
396        }
397    }
398}
399
400impl From<CommitterConfig> for CommitterLayer {
401    fn from(config: CommitterConfig) -> Self {
402        Self {
403            write_concurrency: Some(config.write_concurrency),
404            watermark_interval_ms: Some(config.watermark_interval_ms),
405            min_batch_rows: Some(config.min_batch_rows),
406            max_batch_delay_ms: Some(config.max_batch_delay_ms),
407            collect_interval_ms: None,
408        }
409    }
410}
411
412impl Default for ConsistencyConfig {
413    fn default() -> Self {
414        Self { buffer_size: 5_000 }
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn embedded_enables_only_cohort_pipelines() {
424        let layer = PipelineLayer::embedded();
425        // Live cohort.
426        assert!(layer.object_by_owner.is_some());
427        assert!(layer.object_by_type.is_some());
428        assert!(layer.balance.is_some());
429        // History cohort (object_version_by_checkpoint and
430        // package_versions are also restored).
431        assert!(layer.epochs.is_some());
432        assert!(layer.object_version_by_checkpoint.is_some());
433        assert!(layer.package_versions.is_some());
434        assert!(layer.tx_seq_by_digest.is_some());
435        assert!(layer.tx_metadata_by_seq.is_some());
436        assert!(layer.transaction_bitmap.is_some());
437        assert!(layer.event_bitmap.is_some());
438        // Deactivated: served directly by the perpetual store.
439        assert!(layer.objects.is_none());
440        assert!(layer.transactions.is_none());
441        assert!(layer.effects.is_none());
442        assert!(layer.events.is_none());
443        assert!(layer.checkpoint_summary.is_none());
444        assert!(layer.checkpoint_contents.is_none());
445        assert!(layer.checkpoint_seq_by_digest.is_none());
446    }
447
448    #[test]
449    fn all_enables_every_pipeline() {
450        let layer = PipelineLayer::all();
451        assert!(layer.epochs.is_some());
452        assert!(layer.checkpoint_summary.is_some());
453        assert!(layer.transactions.is_some());
454        assert!(layer.objects.is_some());
455        assert!(layer.object_by_owner.is_some());
456        assert!(layer.balance.is_some());
457        assert!(layer.event_bitmap.is_some());
458    }
459
460    #[test]
461    fn pruning_disabled_by_default() {
462        // A default ServiceConfig (the embedded-fullnode shape)
463        // leaves pruning off; `example()` surfaces it populated.
464        assert!(ServiceConfig::default().pruner.is_none());
465        assert!(ServiceConfig::example().pruner.is_some());
466    }
467
468    #[test]
469    fn pruner_config_interval_round_trips() {
470        let cfg = PrunerConfig {
471            interval_ms: 1_500,
472            ..PrunerConfig::default()
473        };
474        assert_eq!(cfg.interval(), std::time::Duration::from_millis(1_500));
475    }
476
477    #[test]
478    fn committer_layer_overrides_base() {
479        let base = CommitterConfig {
480            write_concurrency: 4,
481            watermark_interval_ms: 200,
482            min_batch_rows: 100,
483            max_batch_delay_ms: 200,
484            ..Default::default()
485        };
486        let layer = CommitterLayer {
487            write_concurrency: Some(8),
488            watermark_interval_ms: Some(500),
489            min_batch_rows: Some(50),
490            ..Default::default()
491        };
492        let merged = layer.finish(base);
493        assert_eq!(merged.write_concurrency, 8);
494        assert_eq!(merged.watermark_interval_ms, 500);
495        assert_eq!(merged.min_batch_rows, 50);
496        // Unset fields inherit from `base`.
497        assert_eq!(merged.max_batch_delay_ms, 200);
498    }
499}