Skip to main content

sui_rpc_store/indexer/
restore.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Entry point for bulk-loading the [`RpcStoreSchema`]'s
5//! derived-index CFs from a [`RestoreSource`].
6//!
7//! Registers the three live-object-derivable index pipelines
8//! ([`ObjectByOwner`], [`ObjectByType`], [`Balance`]) plus the
9//! [`ObjectVersionByCheckpoint`] and [`PackageVersions`] floor rows —
10//! and, when the caller's [`RestoreLayer`] opts in, the raw
11//! [`Objects`] CF — against a single [`RestoreDriver`] and returns a
12//! [`Service`] driving the restore through to completion. Once
13//! finished, every registered
14//! pipeline's `__restore` row is `Complete` and its `__watermark`
15//! row is set to the source's target, so the regular
16//! [`Indexer::add_pipelines`] path will accept them for tip
17//! indexing.
18//!
19//! Restoration is run separately from tip indexing — open the
20//! database, call [`restore_indexes`] to populate the indexes,
21//! then construct an [`Indexer`] over the same store to start
22//! tip-following.
23//!
24//! [`Indexer`]: crate::Indexer
25//! [`Indexer::add_pipelines`]: crate::Indexer::add_pipelines
26
27use std::sync::Arc;
28
29use anyhow::Context as _;
30use sui_consistent_store::Batch;
31use sui_consistent_store::ChainId;
32use sui_consistent_store::Db;
33use sui_consistent_store::FrameworkSchema;
34use sui_consistent_store::PipelineTaskKey;
35use sui_consistent_store::Watermark;
36use sui_consistent_store::restore::RestoreDriver;
37use sui_consistent_store::restore::RestoreDriverConfig;
38use sui_consistent_store::restore::RestoreSource;
39use sui_consistent_store::restore::metrics::RestoreMetrics;
40use sui_consistent_store::restore_state;
41use sui_futures::service::Service;
42use sui_indexer_alt_framework::pipeline::Processor;
43use sui_types::storage::ObjectStore;
44use sui_types::sui_system_state::SuiSystemStateTrait;
45use sui_types::sui_system_state::get_sui_system_state;
46use tracing::info;
47use tracing::warn;
48
49use crate::RestoreLayer;
50use crate::RpcStoreReader;
51use crate::RpcStoreSchema;
52use crate::indexer::balance::Balance;
53use crate::indexer::checkpoint_contents::CheckpointContents;
54use crate::indexer::checkpoint_seq_by_digest::CheckpointSeqByDigest;
55use crate::indexer::checkpoint_summary::CheckpointSummary;
56use crate::indexer::effects::Effects;
57use crate::indexer::epochs::Epochs;
58use crate::indexer::event_bitmap::EventBitmap;
59use crate::indexer::events::Events;
60use crate::indexer::object_by_owner::ObjectByOwner;
61use crate::indexer::object_by_type::ObjectByType;
62use crate::indexer::object_version_by_checkpoint::ObjectVersionByCheckpoint;
63use crate::indexer::objects::Objects;
64use crate::indexer::package_versions::PackageVersions;
65use crate::indexer::transaction_bitmap::TransactionBitmap;
66use crate::indexer::transactions::Transactions;
67use crate::indexer::tx_metadata_by_seq::TxMetadataBySeq;
68use crate::indexer::tx_seq_by_digest::TxSeqByDigest;
69use crate::schema::epochs;
70use crate::schema::primitives::U64Be;
71use crate::schema::pruning_watermark;
72
73/// The embedded fullnode's **live cohort**: the pipelines that
74/// [`restore_indexes`] bulk-loads and that are restored to the
75/// perpetual store's tip `T`, then follow live from there. They are
76/// bounded by the live object set, so a snapshot restore reproduces
77/// them exactly.
78///
79/// Matches the live half of
80/// [`PipelineLayer::embedded`](crate::config::PipelineLayer::embedded);
81/// the `embedded_registers_only_cohort_pipelines` test pins the two
82/// together.
83pub const LIVE_COHORT: &[&str] = &[ObjectByOwner::NAME, ObjectByType::NAME, Balance::NAME];
84
85/// The embedded fullnode's **history cohort**: the pipelines seeded to
86/// the lowest available checkpoint `L` and backfilled upward from the
87/// perpetual store, then followed live.
88///
89/// Most cannot be reconstructed from a live-object snapshot at all --
90/// they record ledger history (`tx_seq` <-> digest maps, the
91/// transaction and event bitmaps) and per-epoch metadata (`epochs`) --
92/// so they are seeded, never restored.
93///
94/// `object_version_by_checkpoint` and `package_versions` are the
95/// exceptions: when some prefix of the chain has been pruned
96/// (`L > 0`) they are *both* restored and backfilled.
97/// [`restore_indexes`] bulk-loads their floor rows at the tip `T` (the
98/// versions live in the snapshot but predate the available window, so a
99/// checkpoint-bounded read treats them as having always existed), and
100/// the history seed then rewinds their `__watermark` to `L-1` so they
101/// also backfill the per-checkpoint detail over `(L, T]` --
102/// `object_version_by_checkpoint`'s per-checkpoint changes, and
103/// `package_versions`'s real publish checkpoint for versions published
104/// in the window. The embedded bootstrap runs the restore before the
105/// seed, so the `L-1` watermark wins. When nothing has been pruned
106/// (`L == 0`) the backfill covers the whole chain and rebuilds both
107/// CFs in full, so the embedded bootstrap skips restoring them
108/// entirely ([`RestoreLayer::live_only`]) -- restoring them would
109/// waste bulk-load work and stamp `__watermark = T` rows that would
110/// make them skip `(0, T]`.
111///
112/// [`RestoreLayer::live_only`]: crate::RestoreLayer::live_only
113///
114/// Matches the history half of
115/// [`PipelineLayer::embedded`](crate::config::PipelineLayer::embedded).
116pub const HISTORY_COHORT: &[&str] = &[
117    Epochs::NAME,
118    ObjectVersionByCheckpoint::NAME,
119    PackageVersions::NAME,
120    TxSeqByDigest::NAME,
121    TxMetadataBySeq::NAME,
122    TransactionBitmap::NAME,
123    EventBitmap::NAME,
124];
125
126/// The two [`HISTORY_COHORT`] members whose floor rows the bulk
127/// restore also loads when a prefix of the chain has been pruned
128/// (`layer.history_floors`; see [`HISTORY_COHORT`]'s docs).
129const HISTORY_FLOORS: &[&str] = &[ObjectVersionByCheckpoint::NAME, PackageVersions::NAME];
130
131/// Register every [`Restore`]-implementing pipeline opted in by
132/// `layer` on a [`RestoreDriver`] bound to `db` / `schema` and
133/// `source`, then run the resulting [`Service`].
134///
135/// The live-cohort pipelines are always registered.
136/// `object_version_by_checkpoint` and `package_versions` -- history-
137/// cohort members whose floor rows are bulk-loaded from the live set
138/// (the history seed separately rewinds their watermarks so they also
139/// backfill `(L, T]`) -- are registered only when
140/// `layer.history_floors` is set; a caller whose history cohort
141/// replays from genesis leaves them to the backfill. The raw
142/// [`Objects`] pipeline is only registered when `layer.objects` is
143/// set. The returned `Service`'s primary task completes once every
144/// registered pipeline transitions to [`RestoreState::Complete`].
145///
146/// [`Restore`]: sui_consistent_store::Restore
147/// [`RestoreState::Complete`]: sui_consistent_store::restore_state::Complete
148pub fn restore_indexes<Src: RestoreSource>(
149    db: Db,
150    schema: Arc<RpcStoreSchema>,
151    source: Src,
152    config: RestoreDriverConfig,
153    layer: RestoreLayer,
154    metrics: Arc<RestoreMetrics>,
155) -> anyhow::Result<Service> {
156    // Capture the anchor before the driver consumes `source`: the
157    // checkpoint-pinned object index attributes every restored live
158    // object to it.
159    let target_checkpoint = source.target_checkpoint();
160    let mut driver = RestoreDriver::new(db, schema, source, config, metrics);
161    if layer.history_floors {
162        // History-cohort members, but their floor rows are restored
163        // from the live set; the embedded history seed later rewinds
164        // their watermarks so they also backfill `(L, T]`.
165        driver.register(ObjectVersionByCheckpoint::for_restore(target_checkpoint))?;
166        driver.register(PackageVersions)?;
167    }
168    driver.register(ObjectByOwner)?;
169    driver.register(ObjectByType)?;
170    driver.register(Balance)?;
171    if layer.objects {
172        driver.register(Objects)?;
173    }
174    driver.run()
175}
176
177/// After [`restore_indexes`] returns, prime the framework state of
178/// every pipeline that the restore did *not* cover so tip indexing
179/// resumes from `target_watermark.checkpoint_hi_inclusive + 1`
180/// across the board instead of replaying from genesis for the
181/// raw-chain-data and bitmap pipelines.
182///
183/// Specifically, for every pipeline not in `layer`'s restored
184/// set, writes:
185///
186/// - `__watermark = target_watermark` — the framework's
187///   tip-resume reads this and starts at
188///   `checkpoint_hi_inclusive + 1`.
189/// - `__chain_id = target_chain_id` — pins the pipeline to the
190///   chain the snapshot was taken from, matching what
191///   [`restore_indexes`]'s finalize step already wrote for the
192///   restored pipelines.
193///
194/// Also writes the singleton `pruning_watermark` so
195/// `available_range` queries and the bitmap CFs' compaction
196/// filters reflect that data only starts at the post-restore
197/// floor (`tx_seq_lo = target_watermark.tx_hi`,
198/// `checkpoint_lo = checkpoint_hi_inclusive + 1`).
199///
200/// Idempotent: re-running after a successful restore overwrites
201/// the unrestored pipelines' watermarks with the same values and
202/// re-writes the pruning row.
203pub fn floor_unrestored_pipelines(
204    db: &Db,
205    schema: &Arc<RpcStoreSchema>,
206    target_watermark: Watermark,
207    target_chain_id: ChainId,
208    layer: &RestoreLayer,
209) -> anyhow::Result<()> {
210    // The set of pipelines `restore_indexes` registered for this
211    // `layer` -- their watermarks were written by the restore driver's
212    // finalize step and must not be clobbered here.
213    let mut restored: Vec<&'static str> =
214        vec![ObjectByOwner::NAME, ObjectByType::NAME, Balance::NAME];
215    if layer.history_floors {
216        restored.push(ObjectVersionByCheckpoint::NAME);
217        restored.push(PackageVersions::NAME);
218    }
219    if layer.objects {
220        restored.push(Objects::NAME);
221    }
222
223    // Every rpc-store pipeline. Kept exhaustive so any new
224    // pipeline added to `PipelineLayer` needs an explicit
225    // decision here about whether it's a restore-time pipeline
226    // or a tip-only one.
227    let all: &[&'static str] = &[
228        Epochs::NAME,
229        CheckpointSummary::NAME,
230        CheckpointContents::NAME,
231        CheckpointSeqByDigest::NAME,
232        Transactions::NAME,
233        TxSeqByDigest::NAME,
234        TxMetadataBySeq::NAME,
235        Effects::NAME,
236        Events::NAME,
237        Objects::NAME,
238        ObjectVersionByCheckpoint::NAME,
239        ObjectByOwner::NAME,
240        ObjectByType::NAME,
241        Balance::NAME,
242        PackageVersions::NAME,
243        TransactionBitmap::NAME,
244        EventBitmap::NAME,
245    ];
246
247    // Use the owned `FrameworkSchema` over `Db` (rather than the
248    // borrowed view from `Db::framework`) so the `DbMap`s line up
249    // with `Batch::put`'s `R = Db` expectation.
250    let framework = FrameworkSchema::new(db.clone());
251    let mut batch = db.batch();
252    for name in all.iter().filter(|n| !restored.contains(n)) {
253        let key = PipelineTaskKey::new(*name);
254        batch
255            .put(&framework.watermarks, &key, &target_watermark)
256            .with_context(|| format!("stage __watermark for {name:?}"))?;
257        batch
258            .put(&framework.chain_ids, &key, &target_chain_id)
259            .with_context(|| format!("stage __chain_id for {name:?}"))?;
260    }
261
262    let (k, v) = pruning_watermark::store(&pruning_watermark::Watermarks {
263        tx_seq_lo: target_watermark.tx_hi,
264        checkpoint_lo: target_watermark.checkpoint_hi_inclusive.saturating_add(1),
265    });
266    batch
267        .put(&schema.pruning_watermark, &k, &v)
268        .context("stage pruning_watermark row")?;
269
270    // Seed the `epochs` row for the epoch the snapshot lands in. The
271    // chain advanced to it at the anchor's end-of-epoch checkpoint,
272    // but the `epochs` pipeline only emits a start record while
273    // processing such a checkpoint, which tip indexing skips on
274    // resume (it starts at anchor + 1). Without this seed, the
275    // current epoch's row would never get its protocol version, gas
276    // price, start timestamp, start checkpoint, or system state.
277    // Requires the raw objects to read the on-chain `SuiSystemState`,
278    // so it only runs when the `objects` CF was restored; failure to
279    // read it is logged and skipped rather than failing the restore.
280    if layer.objects {
281        let reader = RpcStoreReader::new(db.clone(), schema.clone());
282        let start_checkpoint = target_watermark.checkpoint_hi_inclusive.saturating_add(1);
283        match seed_current_epoch_start(schema, &reader, Some(start_checkpoint), &mut batch) {
284            Ok(epoch) => info!(
285                epoch,
286                start_checkpoint, "seeded start record for restore epoch"
287            ),
288            Err(e) => warn!(
289                error = %e,
290                "could not seed the restore epoch's start record; get_epoch / \
291                 get_committee / Move type-layout resolution for the current epoch \
292                 will be unavailable until the next epoch boundary",
293            ),
294        }
295    }
296
297    batch.commit().context("commit floor batch")?;
298
299    // The watermark is durable; publish it to this database's
300    // bitmap compaction filters.
301    schema.set_pruning_floor(target_watermark.tx_hi);
302
303    Ok(())
304}
305
306/// Stage a start record for the epoch reflected by the on-chain
307/// `SuiSystemState` in `objects`, keyed by that epoch.
308///
309/// The `epochs` pipeline derives a start record only from an
310/// end-of-epoch checkpoint's `epoch_info`, which a restore-then-tip
311/// flow never processes (tip indexing resumes at `anchor + 1`), so a
312/// freshly restored database has no start record for the epoch it
313/// landed in. This reconstructs that record straight from the
314/// restored object set: protocol version, reference gas price, and
315/// epoch-start timestamp come from the `SuiSystemState`, the BCS of
316/// which is stored so `get_committee` and Move type-layout resolution
317/// work too.
318///
319/// `start_checkpoint` is supplied by the caller and may be `None`.
320/// The formal-snapshot restore lands at an epoch boundary, so it
321/// passes `Some(anchor + 1)`. The embedded-fullnode restore lands at
322/// a *mid-epoch* tip, so the epoch's first checkpoint is unknown and
323/// it passes `None`; the upward backfill fills `start_checkpoint` in
324/// later if that boundary falls within the available range.
325///
326/// Stages a merge into `batch`; the caller commits. Returns the epoch
327/// that was seeded.
328pub fn seed_current_epoch_start(
329    schema: &RpcStoreSchema,
330    objects: &dyn ObjectStore,
331    start_checkpoint: Option<u64>,
332    batch: &mut Batch,
333) -> anyhow::Result<u64> {
334    let system_state =
335        get_sui_system_state(objects).context("read SuiSystemState from restored objects")?;
336    let epoch = system_state.epoch();
337    let system_state_bcs = bcs::to_bytes(&system_state).context("bcs encode SuiSystemState")?;
338    batch
339        .merge(
340            &schema.epochs,
341            &U64Be(epoch),
342            &epochs::start(
343                system_state.protocol_version(),
344                system_state.reference_gas_price(),
345                system_state.epoch_start_timestamp_ms(),
346                start_checkpoint,
347                Some(system_state_bcs),
348            ),
349        )
350        .context("stage epoch start seed")?;
351    Ok(epoch)
352}
353
354/// Seed the framework state for the embedded fullnode's
355/// [`HISTORY_COHORT`] after [`restore_indexes`] has bulk-loaded the
356/// [`LIVE_COHORT`].
357///
358/// The live cohort resumes from the restore target `T` (written by
359/// the restore driver's finalize step). The history cohort is *not*
360/// restored; instead each of its pipelines is seeded to
361/// `history_watermark` — the lowest available checkpoint `L` in the
362/// perpetual store — so tip indexing backfills `(L, T]` from the
363/// perpetual store and then follows live. For each history pipeline
364/// this writes:
365///
366/// - `__watermark = history_watermark` — the framework resumes at
367///   `history_watermark.checkpoint_hi_inclusive + 1`.
368/// - `__chain_id = chain_id` — pins the chain, matching what the
369///   restore driver wrote for the live cohort.
370///
371/// When `objects` is supplied, also seeds the current epoch's
372/// `epochs` row from its on-chain `SuiSystemState` — a *partial*
373/// start record without `start_checkpoint` (see
374/// [`seed_current_epoch_start`]) — so `get_epoch` / `get_committee`
375/// and Move type-layout resolution work immediately after restore
376/// rather than only once the backfill reaches the epoch's boundary.
377/// `objects` is read through the [`ObjectStore`] trait, so the
378/// embedded caller passes the validator's perpetual store directly
379/// (this crate stays free of any `sui-core` dependency).
380///
381/// Stamps the singleton `pruning_watermark` at the lowest available
382/// checkpoint `L` — the first checkpoint the backfill will index,
383/// `history_watermark.checkpoint_hi_inclusive + 1` — with
384/// `tx_seq_lo = history_watermark.tx_hi` (the first `tx_seq` that
385/// checkpoint contributes). This records that nothing below `L` is
386/// available and sets the bitmap compaction filter's floor. The
387/// backfill only ever writes `tx_seq` at or above the floor, so the
388/// filter drops nothing it produces. (The *upper* bound of history
389/// availability while a backfill is in progress is a separate
390/// concern, handled elsewhere.)
391///
392/// Idempotent: re-running overwrites the same rows. Does not touch
393/// the live cohort or the deactivated (perpetual-store-served) CFs.
394pub fn seed_history_cohort(
395    db: &Db,
396    schema: &RpcStoreSchema,
397    history_watermark: Watermark,
398    chain_id: ChainId,
399    objects: Option<&dyn ObjectStore>,
400) -> anyhow::Result<()> {
401    let framework = FrameworkSchema::new(db.clone());
402    let mut batch = db.batch();
403
404    for name in HISTORY_COHORT {
405        let key = PipelineTaskKey::new(*name);
406        batch
407            .put(&framework.watermarks, &key, &history_watermark)
408            .with_context(|| format!("stage __watermark for {name:?}"))?;
409        batch
410            .put(&framework.chain_ids, &key, &chain_id)
411            .with_context(|| format!("stage __chain_id for {name:?}"))?;
412    }
413
414    // Stamp the pruning floor at the lowest available checkpoint `L`
415    // (the first checkpoint the backfill will index). `tx_seq_lo` is
416    // the tx count through the seed point, i.e. the first `tx_seq`
417    // checkpoint `L` contributes, so the bitmap compaction filter
418    // drops nothing the backfill produces.
419    let tx_seq_lo = history_watermark.tx_hi;
420    let (k, v) = pruning_watermark::store(&pruning_watermark::Watermarks {
421        tx_seq_lo,
422        checkpoint_lo: history_watermark.checkpoint_hi_inclusive.saturating_add(1),
423    });
424    batch
425        .put(&schema.pruning_watermark, &k, &v)
426        .context("stage pruning_watermark row")?;
427
428    if let Some(objects) = objects {
429        // Mid-epoch restore: the epoch's first checkpoint precedes the
430        // tip, so seed a partial start record (no `start_checkpoint`).
431        match seed_current_epoch_start(schema, objects, None, &mut batch) {
432            Ok(epoch) => info!(epoch, "seeded partial start record for the current epoch"),
433            Err(e) => warn!(
434                error = %e,
435                "could not seed the current epoch's start record; get_epoch / \
436                 get_committee / Move type-layout resolution for the current epoch \
437                 will be unavailable until the backfill reaches its boundary",
438            ),
439        }
440    }
441
442    batch.commit().context("commit history-cohort seed batch")?;
443
444    // The watermark is durable; publish it to this database's
445    // bitmap compaction filters.
446    schema.set_pruning_floor(tx_seq_lo);
447
448    Ok(())
449}
450
451/// Whether any embedded-cohort pipeline has a bulk restore in
452/// progress: an `__restore` row in the `InProgress` state, meaning a
453/// restore crashed mid-run and its per-shard cursors are resumable.
454///
455/// Tip indexing never writes `__restore` rows, and the restore
456/// driver's finalize flips every registered row to `Complete` in one
457/// atomic batch, so an `InProgress` row is unambiguous. The embedded
458/// bootstrap uses this to tell a resumable restore apart from other
459/// states that merely lack live watermarks -- a fresh store, or a
460/// from-genesis run that crashed between the live pipelines' first
461/// commits -- which must be cleared before restoring, because the
462/// bulk restore merges and puts on top of whatever rows exist.
463pub fn restore_in_progress(db: &Db) -> anyhow::Result<bool> {
464    let framework = db.framework();
465    for name in LIVE_COHORT.iter().chain(HISTORY_COHORT) {
466        let key = PipelineTaskKey::new(*name);
467        let state = framework
468            .restore
469            .get(&key)
470            .with_context(|| format!("reading restore state for {name}"))?;
471        if let Some(state) = state
472            && matches!(state.state, Some(restore_state::State::InProgress(_)))
473        {
474            return Ok(true);
475        }
476    }
477    Ok(false)
478}
479
480/// Whether the persisted history-cohort state is exactly the
481/// post-restore, pre-seed state: both history-floor pipelines
482/// (`object_version_by_checkpoint` and `package_versions`) carry the
483/// watermark the restore driver's finalize stamped, while no other
484/// history pipeline has ever committed.
485///
486/// This is the only state the embedded bootstrap may repair with
487/// [`seed_history_cohort`] alone (the crash window between the
488/// restore's finalize and the history seed): the floor pipelines'
489/// restored rows anchor checkpoint-pinned reads at the target, and
490/// the seed's rewind makes the backfill cover everything else in
491/// `(L, T]`. Any other history coverage below the available floor
492/// sits behind a pruned gap the backfill can never fill -- seeding
493/// over it would advertise the range above the floor as served while
494/// stale sub-floor rows resolve checkpoint-pinned reads to pre-gap
495/// versions -- so such a store must be cleared and re-restored
496/// instead.
497pub fn history_seed_pending(db: &Db) -> anyhow::Result<bool> {
498    let framework = db.framework();
499    for name in HISTORY_COHORT {
500        let key = PipelineTaskKey::new(*name);
501        let watermark = framework
502            .watermarks
503            .get(&key)
504            .with_context(|| format!("reading watermark for {name}"))?;
505        if watermark.is_some() != HISTORY_FLOORS.contains(name) {
506            return Ok(false);
507        }
508    }
509    Ok(true)
510}
511
512#[cfg(test)]
513mod tests {
514    use async_trait::async_trait;
515    use bytes::Bytes;
516    use futures::StreamExt;
517    use futures::stream;
518    use futures::stream::BoxStream;
519    use sui_consistent_store::ChainId;
520    use sui_consistent_store::Db;
521    use sui_consistent_store::DbOptions;
522    use sui_consistent_store::PipelineTaskKey;
523    use sui_consistent_store::Watermark;
524    use sui_consistent_store::restore::RestoreChunk;
525    use sui_consistent_store::restore_state;
526    use sui_indexer_alt_framework::pipeline::Processor;
527    use sui_types::base_types::ObjectID;
528    use sui_types::base_types::SuiAddress;
529    use sui_types::object::Object;
530
531    use super::*;
532    use crate::RpcStoreSchema;
533    use crate::indexer::objects::Objects;
534    use crate::schema::object_by_owner::OwnerKind;
535
536    /// Minimal [`RestoreSource`] that wraps a `Vec<RestoreChunk>`
537    /// and uses the 4-byte BE chunk index as cursor. Lets us
538    /// drive the end-to-end pipeline registration / commit path
539    /// without standing up a real snapshot.
540    struct VecSource {
541        target: u64,
542        chain_id: ChainId,
543        chunks: Vec<RestoreChunk>,
544    }
545
546    impl VecSource {
547        fn from_objects(target: u64, chain_id: ChainId, objects: Vec<Vec<Object>>) -> Self {
548            let chunks = objects
549                .into_iter()
550                .enumerate()
551                .map(|(i, objs)| RestoreChunk {
552                    objects: objs,
553                    cursor: Bytes::copy_from_slice(&(i as u32).to_be_bytes()),
554                })
555                .collect();
556            Self {
557                target,
558                chain_id,
559                chunks,
560            }
561        }
562    }
563
564    #[async_trait]
565    impl RestoreSource for VecSource {
566        fn target_checkpoint(&self) -> u64 {
567            self.target
568        }
569
570        fn target_chain_id(&self) -> ChainId {
571            self.chain_id
572        }
573
574        fn shards(&self) -> u32 {
575            1
576        }
577
578        fn stream(
579            &self,
580            shard_id: u32,
581            cursor: Option<Bytes>,
582        ) -> BoxStream<'_, anyhow::Result<RestoreChunk>> {
583            assert_eq!(shard_id, 0);
584            let resume_after = cursor.map(|c| {
585                let mut buf = [0u8; 4];
586                buf.copy_from_slice(&c[..4]);
587                u32::from_be_bytes(buf)
588            });
589            let chunks: Vec<_> = self
590                .chunks
591                .iter()
592                .enumerate()
593                .filter_map(|(i, chunk)| {
594                    let i = i as u32;
595                    if let Some(after) = resume_after
596                        && i <= after
597                    {
598                        None
599                    } else {
600                        Some(Ok(RestoreChunk {
601                            objects: chunk.objects.clone(),
602                            cursor: chunk.cursor.clone(),
603                        }))
604                    }
605                })
606                .collect();
607            stream::iter(chunks).boxed()
608        }
609    }
610
611    /// End-to-end: drive a handful of address-owned objects
612    /// through every registered pipeline. Verifies that the
613    /// rows we expect end up in `object_version_by_checkpoint` and
614    /// `object_by_owner`, and that every pipeline's
615    /// `__restore` / `__watermark` rows are set up for the
616    /// tip-indexer to take over.
617    #[tokio::test]
618    async fn restore_indexes_populates_schema_and_finalises() {
619        let dir = tempfile::tempdir().unwrap();
620        let (db, schema) = Db::open::<RpcStoreSchema>(dir.path(), DbOptions::default()).unwrap();
621        let schema = Arc::new(schema);
622
623        let owner = SuiAddress::random_for_testing_only();
624        let objects: Vec<Object> = (1..=4u8)
625            .map(|i| Object::with_id_owner_for_testing(ObjectID::from_single_byte(i), owner))
626            .collect();
627
628        let chain_id = ChainId([7u8; 32]);
629        let source = VecSource::from_objects(123, chain_id, vec![objects.clone()]);
630
631        restore_indexes(
632            db.clone(),
633            schema.clone(),
634            source,
635            RestoreDriverConfig::default(),
636            RestoreLayer::indexes_only(),
637            RestoreMetrics::new(None, &prometheus::Registry::new()),
638        )
639        .unwrap()
640        .shutdown()
641        .await
642        .unwrap();
643
644        // Each object's restore floor row landed in the checkpoint-pinned
645        // index at the anchor checkpoint (123).
646        for o in &objects {
647            assert_eq!(
648                schema
649                    .get_object_version_at_checkpoint(o.id(), 123)
650                    .unwrap(),
651                Some(o.version()),
652            );
653        }
654
655        // Owner index has every object under the same
656        // AddressOwner(owner) key.
657        let owned: Vec<(OwnerKind, ObjectID)> = schema
658            .iter_objects_owned_by_address(owner)
659            .unwrap()
660            .map(Result::unwrap)
661            .map(|(key, _v)| (key.kind, key.object_id))
662            .collect();
663        let mut got_ids: Vec<_> = owned.iter().map(|(_, id)| *id).collect();
664        got_ids.sort();
665        let mut expected_ids: Vec<_> = objects.iter().map(|o| o.id()).collect();
666        expected_ids.sort();
667        assert_eq!(got_ids, expected_ids);
668        for (kind, _) in &owned {
669            assert!(matches!(kind, OwnerKind::AddressOwner(addr) if *addr == owner));
670        }
671
672        // `indexes_only` did not register `objects`, so the
673        // `(id, version)` CF stays empty.
674        for o in &objects {
675            assert_eq!(schema.get_object_by_key(o.id(), o.version()).unwrap(), None,);
676        }
677
678        // Every registered pipeline finished and has __restore
679        // Complete, __watermark, and __chain_id all set. `objects`
680        // was not registered with `indexes_only`, so it has no
681        // __restore row at all.
682        for name in [
683            ObjectVersionByCheckpoint::NAME,
684            ObjectByOwner::NAME,
685            ObjectByType::NAME,
686            Balance::NAME,
687            PackageVersions::NAME,
688        ] {
689            let key = PipelineTaskKey::new(name);
690            let state = db.framework().restore.get(&key).unwrap().unwrap();
691            match state.state.unwrap() {
692                restore_state::State::Complete(c) => assert_eq!(c.restored_at, 123),
693                other => panic!("expected Complete, got {other:?}"),
694            }
695            let wm = db.framework().watermarks.get(&key).unwrap().unwrap();
696            assert_eq!(wm, Watermark::for_checkpoint(123));
697            let pinned_chain_id = db.framework().chain_ids.get(&key).unwrap().unwrap();
698            assert_eq!(pinned_chain_id, chain_id);
699        }
700        let objects_key = PipelineTaskKey::new(Objects::NAME);
701        assert!(
702            db.framework().restore.get(&objects_key).unwrap().is_none(),
703            "indexes_only should leave the objects pipeline unregistered",
704        );
705
706        // A finalized `indexes_only` restore is exactly the state the
707        // history seed repairs.
708        assert!(!restore_in_progress(&db).unwrap());
709        assert!(history_seed_pending(&db).unwrap());
710    }
711
712    /// `floor_unrestored_pipelines` writes a `__watermark` /
713    /// `__chain_id` row for every pipeline outside the restored
714    /// set and stamps the singleton `pruning_watermark` so the
715    /// available range tracks the post-restore floor.
716    #[test]
717    fn floor_unrestored_pipelines_writes_watermarks_for_tip_only_pipelines() {
718        let dir = tempfile::tempdir().unwrap();
719        let (db, schema) = Db::open::<RpcStoreSchema>(dir.path(), DbOptions::default()).unwrap();
720        let schema = Arc::new(schema);
721
722        let chain_id = ChainId([42u8; 32]);
723        let target = Watermark {
724            epoch_hi_inclusive: 7,
725            checkpoint_hi_inclusive: 1_000,
726            tx_hi: 5_000,
727            timestamp_ms_hi_inclusive: 1_700_000_000_000,
728        };
729
730        floor_unrestored_pipelines(&db, &schema, target, chain_id, &RestoreLayer::all()).unwrap();
731
732        // Sample raw-chain-data / bitmap pipelines that the
733        // formal-snapshot path doesn't cover — every one of them
734        // should be primed with the target watermark + chain id.
735        for name in [
736            Epochs::NAME,
737            CheckpointSummary::NAME,
738            CheckpointContents::NAME,
739            CheckpointSeqByDigest::NAME,
740            Transactions::NAME,
741            TxSeqByDigest::NAME,
742            TxMetadataBySeq::NAME,
743            Effects::NAME,
744            Events::NAME,
745            TransactionBitmap::NAME,
746            EventBitmap::NAME,
747        ] {
748            let key = PipelineTaskKey::new(name);
749            assert_eq!(
750                db.framework().watermarks.get(&key).unwrap(),
751                Some(target),
752                "{name} should have the post-restore watermark",
753            );
754            assert_eq!(
755                db.framework().chain_ids.get(&key).unwrap(),
756                Some(chain_id),
757                "{name} should pin the restored chain id",
758            );
759        }
760
761        // Restored pipelines are left to whatever the restore
762        // driver wrote (here: nothing, since we didn't actually
763        // run a restore in this test). The helper must not
764        // clobber them.
765        for name in [
766            ObjectVersionByCheckpoint::NAME,
767            ObjectByOwner::NAME,
768            ObjectByType::NAME,
769            Balance::NAME,
770            PackageVersions::NAME,
771            Objects::NAME,
772        ] {
773            let key = PipelineTaskKey::new(name);
774            assert!(
775                db.framework().watermarks.get(&key).unwrap().is_none(),
776                "{name} watermark should be untouched by the floor helper",
777            );
778            assert!(
779                db.framework().chain_ids.get(&key).unwrap().is_none(),
780                "{name} chain id should be untouched by the floor helper",
781            );
782        }
783
784        // Pruning singleton reflects the post-restore floor: tx
785        // ids and checkpoint sequences below this row aren't
786        // available in the new database.
787        assert_eq!(
788            schema.get_pruning_watermarks().unwrap(),
789            Some(crate::schema::pruning_watermark::Watermarks {
790                tx_seq_lo: target.tx_hi,
791                checkpoint_lo: target.checkpoint_hi_inclusive + 1,
792            }),
793        );
794    }
795
796    /// With `RestoreLayer::indexes_only`, the `objects` pipeline
797    /// is *not* in the restored set, so the floor helper primes
798    /// it the same way it does the raw-chain-data pipelines.
799    #[test]
800    fn floor_unrestored_pipelines_includes_objects_when_layer_skips_it() {
801        let dir = tempfile::tempdir().unwrap();
802        let (db, schema) = Db::open::<RpcStoreSchema>(dir.path(), DbOptions::default()).unwrap();
803        let schema = Arc::new(schema);
804
805        let chain_id = ChainId([11u8; 32]);
806        let target = Watermark::for_checkpoint(42);
807
808        floor_unrestored_pipelines(
809            &db,
810            &schema,
811            target,
812            chain_id,
813            &RestoreLayer::indexes_only(),
814        )
815        .unwrap();
816
817        let key = PipelineTaskKey::new(Objects::NAME);
818        assert_eq!(db.framework().watermarks.get(&key).unwrap(), Some(target),);
819        assert_eq!(db.framework().chain_ids.get(&key).unwrap(), Some(chain_id));
820    }
821
822    /// `RestoreLayer::all` additionally registers the `objects`
823    /// pipeline, so every restored live object lands in the
824    /// `(id, version)` CF and the pipeline itself transitions to
825    /// `Complete`.
826    #[tokio::test]
827    async fn restore_indexes_with_objects_layer_populates_objects_cf() {
828        let dir = tempfile::tempdir().unwrap();
829        let (db, schema) = Db::open::<RpcStoreSchema>(dir.path(), DbOptions::default()).unwrap();
830        let schema = Arc::new(schema);
831
832        let owner = SuiAddress::random_for_testing_only();
833        let objects: Vec<Object> = (1..=4u8)
834            .map(|i| Object::with_id_owner_for_testing(ObjectID::from_single_byte(i), owner))
835            .collect();
836
837        let chain_id = ChainId([9u8; 32]);
838        let source = VecSource::from_objects(123, chain_id, vec![objects.clone()]);
839
840        restore_indexes(
841            db.clone(),
842            schema.clone(),
843            source,
844            RestoreDriverConfig::default(),
845            RestoreLayer::all(),
846            RestoreMetrics::new(None, &prometheus::Registry::new()),
847        )
848        .unwrap()
849        .shutdown()
850        .await
851        .unwrap();
852
853        // Every object lands at its current version in `objects`.
854        for o in &objects {
855            assert_eq!(
856                schema.get_object_by_key(o.id(), o.version()).unwrap(),
857                Some(o.clone()),
858            );
859        }
860
861        // The `objects` pipeline's __restore / __watermark /
862        // __chain_id rows all match the source target.
863        let key = PipelineTaskKey::new(Objects::NAME);
864        let state = db.framework().restore.get(&key).unwrap().unwrap();
865        match state.state.unwrap() {
866            restore_state::State::Complete(c) => assert_eq!(c.restored_at, 123),
867            other => panic!("expected Complete, got {other:?}"),
868        }
869        assert_eq!(
870            db.framework().watermarks.get(&key).unwrap().unwrap(),
871            Watermark::for_checkpoint(123),
872        );
873        assert_eq!(
874            db.framework().chain_ids.get(&key).unwrap().unwrap(),
875            chain_id,
876        );
877    }
878
879    /// `seed_history_cohort` primes every history-cohort pipeline with
880    /// the seed watermark and the chain id, stamps the pruning floor at
881    /// the lowest available checkpoint `L`, and leaves the live cohort
882    /// untouched (the restore driver owns it).
883    #[test]
884    fn seed_history_cohort_seeds_history_watermarks_and_pruning_floor() {
885        let dir = tempfile::tempdir().unwrap();
886        let (db, schema) = Db::open::<RpcStoreSchema>(dir.path(), DbOptions::default()).unwrap();
887
888        let chain_id = ChainId([5u8; 32]);
889        // Seed point: committed through checkpoint 999 / tx 5000, so the
890        // backfill resumes at (and the floor is) checkpoint 1000.
891        let seed = Watermark {
892            checkpoint_hi_inclusive: 999,
893            tx_hi: 5_000,
894            ..Watermark::default()
895        };
896        seed_history_cohort(&db, &schema, seed, chain_id, None).unwrap();
897
898        // History cohort resumes from the seed point, pinned to the
899        // chain.
900        for name in HISTORY_COHORT {
901            let key = PipelineTaskKey::new(*name);
902            assert_eq!(
903                db.framework().watermarks.get(&key).unwrap(),
904                Some(seed),
905                "{name} should resume from the seed point",
906            );
907            assert_eq!(
908                db.framework().chain_ids.get(&key).unwrap(),
909                Some(chain_id),
910                "{name} should pin the chain id",
911            );
912        }
913
914        // Live cohort is the restore driver's responsibility — the
915        // history seed must not touch it.
916        for name in LIVE_COHORT {
917            let key = PipelineTaskKey::new(*name);
918            assert!(
919                db.framework().watermarks.get(&key).unwrap().is_none(),
920                "{name} watermark must be untouched by the history seed",
921            );
922        }
923
924        // Pruning floor sits at the lowest available checkpoint
925        // (seed + 1) and the seed point's tx count.
926        assert_eq!(
927            schema.get_pruning_watermarks().unwrap(),
928            Some(crate::schema::pruning_watermark::Watermarks {
929                tx_seq_lo: 5_000,
930                checkpoint_lo: 1_000,
931            }),
932        );
933    }
934
935    /// `RestoreLayer::live_only` registers only the live-cohort
936    /// pipelines: `object_version_by_checkpoint` and
937    /// `package_versions` get no restore state, no watermark, no
938    /// chain id, and no floor rows, so a from-genesis backfill owns
939    /// them outright and never skips `(0, T]`.
940    #[tokio::test]
941    async fn restore_indexes_live_only_skips_history_floors() {
942        let dir = tempfile::tempdir().unwrap();
943        let (db, schema) = Db::open::<RpcStoreSchema>(dir.path(), DbOptions::default()).unwrap();
944        let schema = Arc::new(schema);
945
946        let owner = SuiAddress::random_for_testing_only();
947        let objects: Vec<Object> = (1..=4u8)
948            .map(|i| Object::with_id_owner_for_testing(ObjectID::from_single_byte(i), owner))
949            .collect();
950
951        let chain_id = ChainId([8u8; 32]);
952        let source = VecSource::from_objects(123, chain_id, vec![objects.clone()]);
953
954        restore_indexes(
955            db.clone(),
956            schema.clone(),
957            source,
958            RestoreDriverConfig::default(),
959            RestoreLayer::live_only(),
960            RestoreMetrics::new(None, &prometheus::Registry::new()),
961        )
962        .unwrap()
963        .shutdown()
964        .await
965        .unwrap();
966
967        // The live cohort restored and finalized as usual.
968        for name in LIVE_COHORT {
969            let key = PipelineTaskKey::new(*name);
970            let state = db.framework().restore.get(&key).unwrap().unwrap();
971            assert!(
972                matches!(
973                    state.state.unwrap(),
974                    restore_state::State::Complete(c) if c.restored_at == 123,
975                ),
976                "{name} should complete its restore",
977            );
978            assert_eq!(
979                db.framework().watermarks.get(&key).unwrap(),
980                Some(Watermark::for_checkpoint(123)),
981                "{name} should resume from the restore target",
982            );
983        }
984
985        // The history-floor pipelines were never registered: no
986        // restore state, no watermark (so tip indexing backfills them
987        // from genesis), and no floor rows in their CFs.
988        for name in [ObjectVersionByCheckpoint::NAME, PackageVersions::NAME] {
989            let key = PipelineTaskKey::new(name);
990            assert!(
991                db.framework().restore.get(&key).unwrap().is_none(),
992                "{name} should have no restore state under live_only",
993            );
994            assert!(
995                db.framework().watermarks.get(&key).unwrap().is_none(),
996                "{name} should have no watermark under live_only",
997            );
998        }
999        for o in &objects {
1000            assert_eq!(
1001                schema
1002                    .get_object_version_at_checkpoint(o.id(), 123)
1003                    .unwrap(),
1004                None,
1005                "live_only must write no restore floor rows",
1006            );
1007        }
1008
1009        // A `live_only` restore leaves nothing to seed: the whole
1010        // history cohort (floors included) belongs to the
1011        // from-genesis backfill.
1012        assert!(!history_seed_pending(&db).unwrap());
1013    }
1014
1015    /// The bootstrap state predicates track the restore and seed
1016    /// lifecycle: a fresh store reports neither, a mid-run restore
1017    /// reports `restore_in_progress`, a finalized `indexes_only`
1018    /// restore reports `history_seed_pending` until the history seed
1019    /// runs, and after the seed both are off.
1020    #[tokio::test]
1021    async fn state_predicates_track_restore_and_seed_lifecycle() {
1022        let dir = tempfile::tempdir().unwrap();
1023        let (db, schema) = Db::open::<RpcStoreSchema>(dir.path(), DbOptions::default()).unwrap();
1024        let schema = Arc::new(schema);
1025
1026        // Fresh store: nothing in progress, nothing to seed.
1027        assert!(!restore_in_progress(&db).unwrap());
1028        assert!(!history_seed_pending(&db).unwrap());
1029
1030        // A registered-but-unfinished restore (the state a mid-run
1031        // crash leaves behind): an `InProgress` row with no watermark.
1032        let framework = FrameworkSchema::new(db.clone());
1033        let in_progress = sui_consistent_store::RestoreState::default().with_in_progress(
1034            restore_state::InProgress {
1035                target_checkpoint: 123,
1036                shards: Default::default(),
1037            },
1038        );
1039        let mut batch = db.batch();
1040        batch
1041            .put(
1042                &framework.restore,
1043                &PipelineTaskKey::new(Balance::NAME),
1044                &in_progress,
1045            )
1046            .unwrap();
1047        batch.commit().unwrap();
1048        assert!(restore_in_progress(&db).unwrap());
1049        assert!(!history_seed_pending(&db).unwrap());
1050
1051        // Run the restore to completion at the same target: every
1052        // registered row flips to `Complete` and the store enters the
1053        // post-restore, pre-seed state.
1054        let owner = SuiAddress::random_for_testing_only();
1055        let objects = vec![Object::with_id_owner_for_testing(
1056            ObjectID::from_single_byte(1),
1057            owner,
1058        )];
1059        let source = VecSource::from_objects(123, ChainId([3u8; 32]), vec![objects]);
1060        restore_indexes(
1061            db.clone(),
1062            schema.clone(),
1063            source,
1064            RestoreDriverConfig::default(),
1065            RestoreLayer::indexes_only(),
1066            RestoreMetrics::new(None, &prometheus::Registry::new()),
1067        )
1068        .unwrap()
1069        .shutdown()
1070        .await
1071        .unwrap();
1072        assert!(!restore_in_progress(&db).unwrap());
1073        assert!(history_seed_pending(&db).unwrap());
1074
1075        // The history seed stamps every history pipeline, closing the
1076        // pre-seed window.
1077        seed_history_cohort(
1078            &db,
1079            &schema,
1080            Watermark::for_checkpoint(99),
1081            ChainId([3u8; 32]),
1082            None,
1083        )
1084        .unwrap();
1085        assert!(!restore_in_progress(&db).unwrap());
1086        assert!(!history_seed_pending(&db).unwrap());
1087    }
1088
1089    /// `history_seed_pending` requires exactly the post-restore
1090    /// watermark shape: floor pipelines watermarked, everything else
1091    /// in the history cohort unwatermarked. Partial coverage in
1092    /// either direction is not seedable.
1093    #[test]
1094    fn history_seed_pending_rejects_partial_history_coverage() {
1095        let dir = tempfile::tempdir().unwrap();
1096        let (db, _schema) = Db::open::<RpcStoreSchema>(dir.path(), DbOptions::default()).unwrap();
1097        let framework = FrameworkSchema::new(db.clone());
1098
1099        let stamp = |name: &'static str| {
1100            let mut batch = db.batch();
1101            batch
1102                .put(
1103                    &framework.watermarks,
1104                    &PipelineTaskKey::new(name),
1105                    &Watermark::for_checkpoint(123),
1106                )
1107                .unwrap();
1108            batch.commit().unwrap();
1109        };
1110
1111        // A non-floor history pipeline with prior committed coverage
1112        // (and no floor watermarks) is not the pre-seed state.
1113        stamp(Epochs::NAME);
1114        assert!(!history_seed_pending(&db).unwrap());
1115
1116        // Both floors watermarked on top of that: still committed
1117        // non-floor coverage, still not seedable.
1118        stamp(ObjectVersionByCheckpoint::NAME);
1119        stamp(PackageVersions::NAME);
1120        assert!(!history_seed_pending(&db).unwrap());
1121    }
1122
1123    /// The live and history cohorts are disjoint and each has the
1124    /// expected size. (Their union being exactly the embedded layer's
1125    /// enabled set is pinned by `embedded_registers_only_cohort_pipelines`.)
1126    #[test]
1127    fn cohorts_are_disjoint() {
1128        let live: std::collections::BTreeSet<_> = LIVE_COHORT.iter().collect();
1129        let history: std::collections::BTreeSet<_> = HISTORY_COHORT.iter().collect();
1130        assert!(live.is_disjoint(&history), "cohorts must not overlap");
1131        assert_eq!(live.len(), 3);
1132        assert_eq!(history.len(), 7);
1133    }
1134}