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