Skip to main content

sui_indexer_alt_e2e_tests/
lib.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::HashMap;
5use std::fs;
6use std::net::IpAddr;
7use std::net::Ipv4Addr;
8use std::net::SocketAddr;
9use std::path::Path;
10use std::time::Duration;
11
12use anyhow::Context;
13use anyhow::ensure;
14use diesel::ExpressionMethods;
15use diesel::OptionalExtension;
16use diesel::QueryDsl;
17use diesel_async::RunQueryDsl;
18use prost::Message;
19use reqwest::Client;
20use serde_json::Value;
21use serde_json::json;
22use simulacrum::AdvanceEpochConfig;
23use simulacrum::Simulacrum;
24use sui_futures::service::Service;
25use sui_indexer_alt::BootstrapGenesis;
26use sui_indexer_alt::config::IndexerConfig;
27use sui_indexer_alt::setup_indexer;
28use sui_indexer_alt_consistent_api::proto::rpc::consistent::v1alpha::AvailableRangeRequest;
29use sui_indexer_alt_consistent_api::proto::rpc::consistent::v1alpha::consistent_service_client::ConsistentServiceClient;
30use sui_indexer_alt_consistent_store::args::RpcArgs as ConsistentArgs;
31use sui_indexer_alt_consistent_store::args::TlsArgs as ConsistentTlsArgs;
32use sui_indexer_alt_consistent_store::config::ServiceConfig as ConsistentConfig;
33use sui_indexer_alt_consistent_store::start_service as start_consistent_store;
34use sui_indexer_alt_framework::IndexerArgs;
35use sui_indexer_alt_framework::ingestion::ClientArgs;
36use sui_indexer_alt_framework::ingestion::ingestion_client::IngestionClientArgs;
37use sui_indexer_alt_framework::pipeline::CommitterConfig;
38use sui_indexer_alt_framework::postgres::schema::watermarks;
39use sui_indexer_alt_graphql::RpcArgs as GraphQlArgs;
40use sui_indexer_alt_graphql::args::SubscriptionArgs;
41use sui_indexer_alt_graphql::config::RpcConfig as GraphQlConfig;
42use sui_indexer_alt_graphql::start_rpc as start_graphql;
43use sui_indexer_alt_jsonrpc::NodeArgs as JsonRpcNodeArgs;
44use sui_indexer_alt_jsonrpc::RpcArgs as JsonRpcArgs;
45use sui_indexer_alt_jsonrpc::config::RpcConfig as JsonRpcConfig;
46use sui_indexer_alt_jsonrpc::start_rpc as start_jsonrpc;
47use sui_indexer_alt_reader::consistent_reader::ConsistentReaderArgs;
48use sui_indexer_alt_reader::fullnode_client::FullnodeArgs;
49use sui_indexer_alt_reader::kv_loader::KvArgs;
50use sui_indexer_alt_reader::system_package_task::SystemPackageTaskArgs;
51use sui_kv_rpc::KvRpcConfig;
52use sui_kv_rpc::KvRpcServer;
53use sui_kvstore::ALL_PIPELINE_NAMES;
54use sui_kvstore::BigTableClient;
55use sui_kvstore::BigTableIndexer;
56use sui_kvstore::BigTableStore;
57use sui_kvstore::IndexerConfig as BtIndexerConfig;
58use sui_kvstore::IngestionConfig as BtIngestionConfig;
59use sui_kvstore::KeyValueStoreReader;
60use sui_kvstore::PipelineLayer;
61use sui_kvstore::testing::BigTableEmulator;
62use sui_kvstore::testing::INSTANCE_ID;
63use sui_kvstore::testing::create_tables;
64use sui_pg_db::Db;
65use sui_pg_db::DbArgs;
66use sui_pg_db::temp::TempDb;
67use sui_pg_db::temp::get_available_port;
68use sui_protocol_config::Chain;
69use sui_rpc::field::FieldMask;
70use sui_rpc::field::FieldMaskUtil;
71use sui_rpc::merge::Merge;
72use sui_rpc::proto::sui::rpc;
73use sui_types::base_types::ObjectRef;
74use sui_types::base_types::SuiAddress;
75use sui_types::crypto::AccountKeyPair;
76use sui_types::effects::TransactionEffects;
77use sui_types::error::ExecutionError;
78use sui_types::full_checkpoint_content::Checkpoint;
79use sui_types::messages_checkpoint::VerifiedCheckpoint;
80use sui_types::transaction::Transaction;
81use tempfile::TempDir;
82use tokio::time::error::Elapsed;
83use tokio::time::interval;
84use tokio::try_join;
85use url::Url;
86
87pub mod coin_registry;
88pub mod find;
89pub mod graphql;
90pub mod move_helpers;
91pub mod transaction;
92
93/// A simulation of the network, accompanied by off-chain services (database, indexer, RPC),
94/// connected by local data ingestion.
95pub struct FullCluster {
96    /// A simulation of the network, executing transactions and producing checkpoints.
97    executor: Simulacrum,
98
99    /// The off-chain services (database, indexer, RPC) that are ingesting data from the
100    /// simulation.
101    offchain: OffchainCluster,
102
103    /// Temporary directory to store checkpoint information in, so that the indexer can pick it up.
104    #[allow(unused)]
105    temp_dir: TempDir,
106}
107
108/// A collection of the off-chain services (an indexer, a database, and JSON-RPC/GraphQL servers
109/// that read from that database), grouped together to simplify set-up and tear-down for tests. The
110/// included RPC servers do not support transaction dry run and execution.
111///
112/// The database is temporary, and will be cleaned up when the cluster is dropped, and the RPCs are
113/// set-up to listen on a random, available port, to avoid conflicts when multiple instances are
114/// running concurrently in the same process.
115pub struct OffchainCluster {
116    /// The address the consistent store is listening on.
117    consistent_listen_address: SocketAddr,
118
119    /// The address the JSON-RPC server is listening on.
120    jsonrpc_listen_address: SocketAddr,
121
122    /// The address the GraphQL server is listening on.
123    graphql_listen_address: SocketAddr,
124
125    /// The address the kv-rpc (LedgerService) server is listening on.
126    kv_rpc_listen_address: SocketAddr,
127
128    /// Read access to BigTable.
129    bigtable_client: BigTableClient,
130
131    /// Read access to the temporary database.
132    db: Db,
133
134    /// The pipelines that the indexer is populating.
135    pipelines: Vec<&'static str>,
136
137    /// Handles to all running services. Held on to so the services are not dropped (and therefore
138    /// aborted) until the cluster is stopped.
139    #[allow(unused)]
140    services: Service,
141
142    /// Handle to the BigTable emulator process.
143    #[allow(unused)]
144    bigtable_emulator: BigTableEmulator,
145
146    /// Hold on to the database so it doesn't get dropped until the cluster is stopped.
147    #[allow(unused)]
148    database: TempDb,
149
150    /// Hold on to the temporary directory where the consistent store writes its data, so it
151    /// doesn't get cleaned up until the cluster is stopped.
152    #[allow(unused)]
153    dir: TempDir,
154}
155
156pub struct OffchainClusterConfig {
157    pub indexer_args: IndexerArgs,
158    pub consistent_indexer_args: IndexerArgs,
159    pub fullnode_args: FullnodeArgs,
160    pub indexer_config: IndexerConfig,
161    pub consistent_config: ConsistentConfig,
162    pub jsonrpc_config: JsonRpcConfig,
163    pub jsonrpc_node_args: JsonRpcNodeArgs,
164    pub graphql_config: GraphQlConfig,
165    pub bootstrap_genesis: Option<BootstrapGenesis>,
166    pub kv_rpc_config: KvRpcConfig,
167}
168
169impl FullCluster {
170    /// Creates a cluster with a fresh executor where the off-chain services are set up with a
171    /// default configuration.
172    pub async fn new() -> anyhow::Result<Self> {
173        Self::new_with_configs(
174            Simulacrum::new(),
175            OffchainClusterConfig::default(),
176            &prometheus::Registry::new(),
177        )
178        .await
179    }
180
181    /// Creates a new cluster executing transactions using `executor`. The indexer is configured
182    /// using `indexer_args` and `indexer_config, the JSON-RPC server is configured using
183    /// `jsonrpc_config`, and the GraphQL server is configured using `graphql_config`.
184    pub async fn new_with_configs(
185        mut executor: Simulacrum,
186        offchain_cluster_config: OffchainClusterConfig,
187        registry: &prometheus::Registry,
188    ) -> anyhow::Result<Self> {
189        let (client_args, temp_dir) = local_ingestion_client_args();
190        executor.set_data_ingestion_path(temp_dir.path().to_owned());
191
192        let offchain = OffchainCluster::new(client_args, offchain_cluster_config, registry)
193            .await
194            .context("Failed to create off-chain cluster")?;
195
196        Ok(Self {
197            executor,
198            offchain,
199            temp_dir,
200        })
201    }
202
203    /// Return the reference gas price for the current epoch
204    pub fn reference_gas_price(&self) -> u64 {
205        self.executor.reference_gas_price()
206    }
207
208    /// Create a new account and credit it with `amount` gas units from a faucet account. Returns
209    /// the account, its keypair, and a reference to the gas object it was funded with.
210    pub fn funded_account(
211        &mut self,
212        amount: u64,
213    ) -> anyhow::Result<(SuiAddress, AccountKeyPair, ObjectRef)> {
214        self.executor.funded_account(amount)
215    }
216
217    /// Request gas from the faucet, sent to `address`. Return the object reference of the gas
218    /// object that was sent.
219    pub fn request_gas(
220        &mut self,
221        address: SuiAddress,
222        amount: u64,
223    ) -> anyhow::Result<TransactionEffects> {
224        self.executor.request_gas(address, amount)
225    }
226
227    /// Execute a signed transaction, returning its effects.
228    pub fn execute_transaction(
229        &mut self,
230        tx: Transaction,
231    ) -> anyhow::Result<(TransactionEffects, Option<ExecutionError>)> {
232        self.executor.execute_transaction(tx)
233    }
234
235    /// Execute a system transaction advancing the lock by the given `duration`.
236    pub fn advance_clock(&mut self, duration: Duration) -> TransactionEffects {
237        self.executor.advance_clock(duration)
238    }
239
240    /// Advance the executor into the next epoch. This executes an end-of-epoch transaction and
241    /// creates the epoch's final checkpoint, but does not wait for the off-chain services to ingest
242    /// it — follow with [`create_checkpoint`](Self::create_checkpoint) to sync.
243    pub fn advance_epoch(&mut self) {
244        self.executor.advance_epoch(AdvanceEpochConfig::default());
245    }
246
247    /// Create a new checkpoint containing the transactions executed since the last checkpoint that
248    /// was created, and wait for the off-chain services to ingest it. Returns the checkpoint
249    /// contents.
250    pub async fn create_checkpoint(&mut self) -> VerifiedCheckpoint {
251        let checkpoint = self.executor.create_checkpoint();
252        let timeout = Duration::from_secs(100);
253        let indexer = self
254            .offchain
255            .wait_for_indexer(checkpoint.sequence_number, timeout);
256        let consistent_store = self
257            .offchain
258            .wait_for_consistent_store(checkpoint.sequence_number, timeout);
259        let graphql = self
260            .offchain
261            .wait_for_graphql(checkpoint.sequence_number, timeout);
262        let bigtable = self
263            .offchain
264            .wait_for_bigtable(checkpoint.sequence_number, timeout);
265
266        try_join!(indexer, consistent_store, graphql, bigtable)
267            .expect("Timed out waiting for off-chain services");
268
269        checkpoint
270    }
271
272    /// The URL to talk to the database on.
273    pub fn db_url(&self) -> Url {
274        self.offchain.db_url()
275    }
276
277    /// The URL to send Consistent Store requests to.
278    pub fn consistent_store_url(&self) -> Url {
279        self.offchain.consistent_store_url()
280    }
281
282    /// The URL to send JSON-RPC requests to.
283    pub fn jsonrpc_url(&self) -> Url {
284        self.offchain.jsonrpc_url()
285    }
286
287    /// The URL to send GraphQL requests to.
288    pub fn graphql_url(&self) -> Url {
289        self.offchain.graphql_url()
290    }
291
292    /// The URL to send kv-rpc (LedgerService) requests to.
293    pub fn kv_rpc_url(&self) -> Url {
294        self.offchain.kv_rpc_url()
295    }
296
297    /// Returns the latest checkpoint that we have all data for in the database, according to the
298    /// watermarks table. Returns `None` if any of the expected pipelines are missing data.
299    pub async fn latest_checkpoint(&self) -> anyhow::Result<Option<u64>> {
300        self.offchain.latest_checkpoint().await
301    }
302
303    /// Waits until the indexer has caught up to the given `checkpoint`, or the `timeout` is
304    /// reached (an error).
305    pub async fn wait_for_indexer(
306        &self,
307        checkpoint: u64,
308        timeout: Duration,
309    ) -> Result<(), Elapsed> {
310        self.offchain.wait_for_indexer(checkpoint, timeout).await
311    }
312
313    /// Waits until the indexer's pruner has caught up to the given `checkpoint`, for the given
314    /// `pipeline`, or the `timeout` is reached (an error).
315    pub async fn wait_for_pruner(
316        &self,
317        pipeline: &str,
318        checkpoint: u64,
319        timeout: Duration,
320    ) -> Result<(), Elapsed> {
321        self.offchain
322            .wait_for_pruner(pipeline, checkpoint, timeout)
323            .await
324    }
325
326    /// Waits until GraphQL has caught up to the given `checkpoint`, or the `timeout` is
327    /// reached (an error).
328    pub async fn wait_for_graphql(
329        &self,
330        checkpoint: u64,
331        timeout: Duration,
332    ) -> Result<(), Elapsed> {
333        self.offchain.wait_for_graphql(checkpoint, timeout).await
334    }
335}
336
337impl OffchainCluster {
338    /// Construct a new off-chain cluster and spin up its constituent services.
339    ///
340    /// - `indexer_args`, `client_args`, and `indexer_config` control the indexer. In particular
341    ///   `client_args` is used to configure the client that the indexer uses to fetch checkpoints.
342    /// - `jsonrpc_config` controls the JSON-RPC server.
343    /// - `graphql_config` controls the GraphQL server.
344    /// - `registry` is used to register metrics for the indexer, JSON-RPC, and GraphQL servers.
345    pub async fn new(
346        client_args: ClientArgs,
347        OffchainClusterConfig {
348            indexer_args,
349            consistent_indexer_args,
350            fullnode_args,
351            indexer_config,
352            consistent_config,
353            jsonrpc_config,
354            jsonrpc_node_args,
355            graphql_config,
356            bootstrap_genesis,
357            kv_rpc_config,
358        }: OffchainClusterConfig,
359        registry: &prometheus::Registry,
360    ) -> anyhow::Result<Self> {
361        let consistent_port = get_available_port();
362        let consistent_listen_address =
363            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), consistent_port);
364
365        let jsonrpc_port = get_available_port();
366        let jsonrpc_listen_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), jsonrpc_port);
367
368        let graphql_port = get_available_port();
369        let graphql_listen_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), graphql_port);
370
371        let kv_rpc_port = get_available_port();
372        let kv_rpc_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), kv_rpc_port);
373
374        let database = TempDb::new().context("Failed to create database")?;
375        let database_url = database.database().url();
376
377        let dir = tempfile::tempdir().context("Failed to create temporary directory")?;
378        let rocksdb_path = dir.path().join("rocksdb");
379
380        let consistent_args = ConsistentArgs {
381            rpc_listen_address: consistent_listen_address,
382            tls: ConsistentTlsArgs::default(),
383        };
384
385        let jsonrpc_args = JsonRpcArgs {
386            rpc_listen_address: jsonrpc_listen_address,
387            ..Default::default()
388        };
389
390        let graphql_args = GraphQlArgs {
391            rpc_listen_address: graphql_listen_address,
392            no_ide: true,
393        };
394
395        let db = Db::for_read(database_url.clone(), DbArgs::default())
396            .await
397            .context("Failed to connect to database")?;
398
399        let indexer = setup_indexer(
400            database_url.clone(),
401            DbArgs::default(),
402            indexer_args,
403            client_args.clone(),
404            indexer_config,
405            bootstrap_genesis,
406            registry,
407        )
408        .await
409        .context("Failed to setup indexer")?;
410
411        let pipelines: Vec<_> = indexer.pipelines().collect();
412        let indexer = indexer.run().await.context("Failed to start indexer")?;
413
414        let consistent_store = start_consistent_store(
415            rocksdb_path,
416            consistent_indexer_args,
417            client_args.clone(),
418            consistent_args,
419            "0.0.0",
420            consistent_config,
421            registry,
422        )
423        .await
424        .context("Failed to start Consistent Store")?;
425
426        let consistent_reader_args = ConsistentReaderArgs {
427            consistent_store_url: Some(
428                Url::parse(&format!("http://{consistent_listen_address}")).unwrap(),
429            ),
430            ..Default::default()
431        };
432
433        // One switch drives both sides: the kv-rpc server only serves the List
434        // APIs when they are enabled, and the graphql/jsonrpc readers only
435        // consume them when they are. Off by default, matching production.
436        let enable_list_apis = kv_rpc_config.enable_list_apis();
437
438        let (bigtable_client, bigtable_emulator, archival_service) =
439            start_archival(client_args.clone(), kv_rpc_address, kv_rpc_config, registry).await?;
440
441        let kv_args = KvArgs {
442            ledger_grpc_url: Some(
443                format!("http://{kv_rpc_address}")
444                    .parse()
445                    .expect("Failed to parse kv-rpc URI"),
446            ),
447            enable_list_apis: Some(enable_list_apis),
448            ..Default::default()
449        };
450
451        let jsonrpc = start_jsonrpc(
452            Some(database_url.clone()),
453            DbArgs::default(),
454            kv_args.clone(),
455            consistent_reader_args.clone(),
456            jsonrpc_args,
457            jsonrpc_node_args,
458            SystemPackageTaskArgs::default(),
459            jsonrpc_config,
460            registry,
461        )
462        .await
463        .context("Failed to start JSON-RPC server")?;
464
465        let graphql = start_graphql(
466            Some(database_url.clone()),
467            fullnode_args,
468            DbArgs::default(),
469            kv_args,
470            consistent_reader_args,
471            graphql_args,
472            SystemPackageTaskArgs::default(),
473            SubscriptionArgs::default(),
474            "0.0.0",
475            graphql_config,
476            pipelines.iter().map(|p| p.to_string()).collect(),
477            registry,
478        )
479        .await
480        .context("Failed to start GraphQL server")?;
481
482        let services = indexer
483            .merge(consistent_store)
484            .merge(jsonrpc)
485            .merge(graphql)
486            .merge(archival_service);
487
488        Ok(Self {
489            consistent_listen_address,
490            jsonrpc_listen_address,
491            graphql_listen_address,
492            kv_rpc_listen_address: kv_rpc_address,
493            bigtable_client,
494            db,
495            pipelines,
496            services,
497            bigtable_emulator,
498            database,
499            dir,
500        })
501    }
502
503    /// The URL to talk to the database on.
504    pub fn db_url(&self) -> Url {
505        self.database.database().url().clone()
506    }
507
508    /// The URL to send Consistent Store requests to.
509    pub fn consistent_store_url(&self) -> Url {
510        Url::parse(&format!("http://{}/", self.consistent_listen_address))
511            .expect("Failed to parse RPC URL")
512    }
513
514    /// The URL to send JSON-RPC requests to.
515    pub fn jsonrpc_url(&self) -> Url {
516        Url::parse(&format!("http://{}/", self.jsonrpc_listen_address))
517            .expect("Failed to parse RPC URL")
518    }
519
520    /// The URL to send GraphQL requests to.
521    pub fn graphql_url(&self) -> Url {
522        Url::parse(&format!("http://{}/graphql", self.graphql_listen_address))
523            .expect("Failed to parse RPC URL")
524    }
525
526    /// The URL to send kv-rpc (LedgerService) requests to.
527    pub fn kv_rpc_url(&self) -> Url {
528        Url::parse(&format!("http://{}/", self.kv_rpc_listen_address))
529            .expect("Failed to parse RPC URL")
530    }
531
532    /// Returns the latest checkpoint that we have all data for in the database, according to the
533    /// watermarks table. Returns `None` if any of the expected pipelines are missing data.
534    pub async fn latest_checkpoint(&self) -> anyhow::Result<Option<u64>> {
535        use watermarks::dsl as w;
536
537        let mut conn = self
538            .db
539            .connect()
540            .await
541            .context("Failed to connect to database")?;
542
543        let latest: HashMap<String, i64> = w::watermarks
544            .select((w::pipeline, w::checkpoint_hi_inclusive))
545            .filter(w::pipeline.eq_any(&self.pipelines))
546            .filter(w::reader_lo.le(w::checkpoint_hi_inclusive))
547            .load(&mut conn)
548            .await?
549            .into_iter()
550            .collect();
551
552        if latest.len() != self.pipelines.len() {
553            return Ok(None);
554        }
555
556        Ok(latest.into_values().min().map(|l| l as u64))
557    }
558
559    /// Returns the latest checkpoint that the pruner is willing to prune up to for the given
560    /// `pipeline`.
561    pub async fn latest_pruner_checkpoint(&self, pipeline: &str) -> anyhow::Result<Option<u64>> {
562        use watermarks::dsl as w;
563
564        let mut conn = self
565            .db
566            .connect()
567            .await
568            .context("Failed to connect to database")?;
569
570        let latest: Option<i64> = w::watermarks
571            .select(w::reader_lo)
572            .filter(w::pipeline.eq(pipeline))
573            .first(&mut conn)
574            .await
575            .optional()?;
576
577        Ok(latest.map(|l| l as u64))
578    }
579
580    /// Returns the latest checkpoint that the consistent store is aware of.
581    pub async fn latest_consistent_store_checkpoint(&self) -> anyhow::Result<u64> {
582        ConsistentServiceClient::connect(self.consistent_store_url().to_string())
583            .await
584            .context("Failed to connect to Consistent Store")?
585            .available_range(AvailableRangeRequest {})
586            .await
587            .context("Failed to fetch available range from Consistent Store")?
588            .into_inner()
589            .max_checkpoint
590            .context("Consistent Store has not started yet")
591    }
592
593    /// Returns the latest checkpoint that the GraphQL service is aware of.
594    pub async fn latest_graphql_checkpoint(&self) -> anyhow::Result<u64> {
595        let query = json!({
596            "query": "query { checkpoint { sequenceNumber } }"
597        });
598
599        let client = Client::new();
600        let request = client.post(self.graphql_url()).json(&query);
601        let response = request
602            .send()
603            .await
604            .context("Request to GraphQL server failed")?;
605
606        let body: Value = response
607            .json()
608            .await
609            .context("Failed to parse GraphQL response")?;
610
611        let sequence_number = body
612            .pointer("/data/checkpoint/sequenceNumber")
613            .context("Failed to find checkpoint sequence number in response")?;
614
615        let sequence_number: i64 = serde_json::from_value(sequence_number.clone())
616            .context("Failed to parse sequence number as i64")?;
617
618        ensure!(sequence_number != i64::MAX, "Indexer has not started yet");
619
620        Ok(sequence_number as u64)
621    }
622
623    /// Returns the latest epoch that the GraphQL service is aware of.
624    pub async fn latest_graphql_epoch(&self) -> anyhow::Result<u64> {
625        let query = json!({
626            "query": "query { epoch { epochId } }"
627        });
628
629        let client = Client::new();
630        let request = client.post(self.graphql_url()).json(&query);
631        let response = request
632            .send()
633            .await
634            .context("Request to GraphQL server failed")?;
635
636        let body: Value = response
637            .json()
638            .await
639            .context("Failed to parse GraphQL response")?;
640
641        let epoch_id = body
642            .pointer("/data/epoch/epochId")
643            .context("Failed to find epochId in response")?;
644
645        let epoch_id: i64 =
646            serde_json::from_value(epoch_id.clone()).context("Failed to parse epochId as i64")?;
647
648        ensure!(epoch_id != i64::MAX, "Indexer has not started yet");
649
650        Ok(epoch_id as u64)
651    }
652
653    /// Waits until the indexer has caught up to the given `checkpoint`, or the `timeout` is
654    /// reached (an error).
655    pub async fn wait_for_indexer(
656        &self,
657        checkpoint: u64,
658        timeout: Duration,
659    ) -> Result<(), Elapsed> {
660        tokio::time::timeout(timeout, async move {
661            let mut interval = interval(Duration::from_millis(200));
662            loop {
663                interval.tick().await;
664                if matches!(self.latest_checkpoint().await, Ok(Some(l)) if l >= checkpoint) {
665                    break;
666                }
667            }
668        })
669        .await
670    }
671
672    /// Waits until the indexer's pruner has caught up to the given `checkpoint`, for the given
673    /// `pipeline`, or the `timeout` is reached (an error).
674    pub async fn wait_for_pruner(
675        &self,
676        pipeline: &str,
677        checkpoint: u64,
678        timeout: Duration,
679    ) -> Result<(), Elapsed> {
680        tokio::time::timeout(timeout, async move {
681            let mut interval = interval(Duration::from_millis(200));
682            loop {
683                interval.tick().await;
684                if matches!(self.latest_pruner_checkpoint(pipeline).await, Ok(Some(l)) if l >= checkpoint) {
685                    break;
686                }
687            }
688        }).await
689    }
690
691    /// Waits until the Consistent Store has caught up to the given `checkpoint`, or the `timeout`
692    /// is reached (an error).
693    pub async fn wait_for_consistent_store(
694        &self,
695        checkpoint: u64,
696        timeout: Duration,
697    ) -> Result<(), Elapsed> {
698        tokio::time::timeout(timeout, async move {
699            let mut interval = interval(Duration::from_millis(200));
700            loop {
701                interval.tick().await;
702                if matches!(self.latest_consistent_store_checkpoint().await, Ok(l) if l >= checkpoint) {
703                    break;
704                }
705            }
706        })
707        .await
708    }
709
710    /// Waits until GraphQL has caught up to the given `checkpoint`, or the `timeout` is reached
711    /// (an error).
712    pub async fn wait_for_graphql(
713        &self,
714        checkpoint: u64,
715        timeout: Duration,
716    ) -> Result<(), Elapsed> {
717        tokio::time::timeout(timeout, async move {
718            let mut interval = interval(Duration::from_millis(200));
719            loop {
720                interval.tick().await;
721                if matches!(self.latest_graphql_checkpoint().await, Ok(l) if l >= checkpoint) {
722                    break;
723                }
724            }
725        })
726        .await
727    }
728
729    /// Waits until the BigTable indexer has caught up to the given `checkpoint`, or the `timeout`
730    /// is reached (an error).
731    pub async fn wait_for_bigtable(
732        &self,
733        checkpoint: u64,
734        timeout: Duration,
735    ) -> Result<(), Elapsed> {
736        let mut client = self.bigtable_client.clone();
737        tokio::time::timeout(timeout, async move {
738            let mut interval = interval(Duration::from_millis(200));
739            loop {
740                interval.tick().await;
741                if client
742                    .get_watermark_for_pipelines(&ALL_PIPELINE_NAMES)
743                    .await
744                    .is_ok_and(|wm| {
745                        wm.is_some_and(|wm| {
746                            wm.checkpoint_hi_inclusive
747                                .is_some_and(|cp| cp >= checkpoint)
748                        })
749                    })
750                {
751                    break;
752                }
753            }
754        })
755        .await
756    }
757}
758
759impl Default for OffchainClusterConfig {
760    fn default() -> Self {
761        Self {
762            indexer_args: Default::default(),
763            consistent_indexer_args: Default::default(),
764            fullnode_args: FullnodeArgs::default(),
765            indexer_config: IndexerConfig::for_test(),
766            consistent_config: ConsistentConfig::for_test(),
767            jsonrpc_config: Default::default(),
768            jsonrpc_node_args: Default::default(),
769            graphql_config: Default::default(),
770            bootstrap_genesis: None,
771            kv_rpc_config: KvRpcConfig::default(),
772        }
773    }
774}
775
776/// Returns ClientArgs that use a temporary local ingestion path and the TempDir of that path.
777pub fn local_ingestion_client_args() -> (ClientArgs, TempDir) {
778    let temp_dir = tempfile::tempdir()
779        .context("Failed to create data ingestion path")
780        .unwrap();
781    let client_args = ClientArgs {
782        ingestion: IngestionClientArgs {
783            local_ingestion_path: Some(temp_dir.path().to_owned()),
784            ..Default::default()
785        },
786        ..Default::default()
787    };
788    (client_args, temp_dir)
789}
790
791/// Writes a checkpoint file to the given path.
792pub async fn write_checkpoint(path: &Path, checkpoint: Checkpoint) -> anyhow::Result<()> {
793    let sequence_number = checkpoint.summary.sequence_number;
794
795    let mask = FieldMask::from_paths([
796        rpc::v2::Checkpoint::path_builder().sequence_number(),
797        rpc::v2::Checkpoint::path_builder().summary().bcs().value(),
798        rpc::v2::Checkpoint::path_builder().signature().finish(),
799        rpc::v2::Checkpoint::path_builder().contents().bcs().value(),
800        rpc::v2::Checkpoint::path_builder()
801            .transactions()
802            .transaction()
803            .bcs()
804            .value(),
805        rpc::v2::Checkpoint::path_builder()
806            .transactions()
807            .effects()
808            .bcs()
809            .value(),
810        rpc::v2::Checkpoint::path_builder()
811            .transactions()
812            .effects()
813            .unchanged_loaded_runtime_objects()
814            .finish(),
815        rpc::v2::Checkpoint::path_builder()
816            .transactions()
817            .events()
818            .bcs()
819            .value(),
820        rpc::v2::Checkpoint::path_builder()
821            .objects()
822            .objects()
823            .bcs()
824            .value(),
825    ]);
826
827    let proto_checkpoint = rpc::v2::Checkpoint::merge_from(&checkpoint, &mask.into());
828    let proto_bytes = proto_checkpoint.encode_to_vec();
829    let compressed = zstd::encode_all(&proto_bytes[..], 3)?;
830
831    let file_name = format!("{}.binpb.zst", sequence_number);
832    let file_path = path.join(file_name);
833    fs::write(file_path, compressed)?;
834    Ok(())
835}
836
837/// Start the archival stack: BigTable emulator, BigTable indexer, and sui-kv-rpc.
838async fn start_archival(
839    client_args: ClientArgs,
840    kv_rpc_address: SocketAddr,
841    kv_rpc_config: KvRpcConfig,
842    registry: &prometheus::Registry,
843) -> anyhow::Result<(BigTableClient, BigTableEmulator, Service)> {
844    let emulator = tokio::task::spawn_blocking(BigTableEmulator::start)
845        .await
846        .context("spawn_blocking panicked")?
847        .context("Failed to start BigTable emulator")?;
848
849    create_tables(emulator.host(), INSTANCE_ID)
850        .await
851        .context("Failed to create BigTable tables")?;
852
853    let bigtable_client =
854        BigTableClient::new_local(emulator.host().to_string(), INSTANCE_ID.to_string())
855            .await
856            .context("Failed to create BigTable client")?;
857
858    let store = BigTableStore::new(
859        BigTableClient::new_local(emulator.host().to_string(), INSTANCE_ID.to_string())
860            .await
861            .context("Failed to create BigTable client for indexer")?,
862    );
863    let bt_indexer = BigTableIndexer::new(
864        store,
865        IndexerArgs::default(),
866        client_args,
867        BtIngestionConfig::default(),
868        CommitterConfig::default(),
869        BtIndexerConfig::default(),
870        PipelineLayer::default(),
871        Chain::Unknown,
872        registry,
873    )
874    .await
875    .context("Failed to create BigTable indexer")?;
876
877    // Use the BigTable wrapper, not the raw framework indexer, so bitmap
878    // committer background tasks are supervised for the duration of the test.
879    let bt_indexer_service = bt_indexer
880        .run()
881        .await
882        .context("Failed to start BigTable indexer")?;
883
884    let kv_rpc_server = KvRpcServer::new_local_with_config(
885        emulator.host().to_string(),
886        INSTANCE_ID.to_string(),
887        None,
888        kv_rpc_config.ledger_history(),
889        kv_rpc_config.request_bigtable_concurrency(),
890        kv_rpc_config.stages(),
891        kv_rpc_config.enable_list_apis(),
892    )
893    .await
894    .context("Failed to create KvRpcServer")?;
895    let kv_rpc_service = kv_rpc_server
896        .start_service(kv_rpc_address, sui_kv_rpc::ServerConfig::default())
897        .await
898        .context("Failed to start kv-rpc server")?;
899
900    let service = bt_indexer_service.merge(kv_rpc_service);
901    Ok((bigtable_client, emulator, service))
902}