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