Skip to main content

iota_graphql_rpc/test_infra/
cluster.rs

1// Copyright (c) Mysten Labs, Inc.
2// Modifications Copyright (c) 2024 IOTA Stiftung
3// SPDX-License-Identifier: Apache-2.0
4
5use std::{net::SocketAddr, path::PathBuf, sync::Arc, time::Duration};
6
7use iota_graphql_rpc_client::simple_client::SimpleClient;
8use iota_indexer::{
9    config::RetentionConfig,
10    errors::IndexerError,
11    store::PgIndexerStore,
12    test_utils::{IndexerTypeConfig, force_delete_database, start_test_indexer_impl},
13};
14use iota_node_storage::GrpcStateReader;
15use iota_sdk_types::Transaction;
16use iota_swarm_config::genesis_config::{AccountConfig, DEFAULT_GAS_AMOUNT};
17use iota_types::transaction::TransactionEnvelope;
18use test_cluster::{TestCluster, TestClusterBuilder};
19use tokio::{join, task::JoinHandle};
20use tokio_util::sync::CancellationToken;
21use tracing::info;
22
23use crate::{
24    config::{ConnectionConfig, ServerConfig, ServiceConfig, Version},
25    server::graphiql_server::start_graphiql_server,
26};
27
28const VALIDATOR_COUNT: usize = 7;
29const EPOCH_DURATION_MS: u64 = 15000;
30
31const ACCOUNT_NUM: usize = 20;
32const GAS_OBJECT_COUNT: usize = 3;
33
34pub const DEFAULT_INTERNAL_DATA_SOURCE_PORT: u16 = 3000;
35
36pub struct ExecutorCluster {
37    pub indexer_store: PgIndexerStore,
38    pub indexer_join_handle: JoinHandle<Result<(), IndexerError>>,
39    pub graphql_server_join_handle: JoinHandle<()>,
40    pub graphql_client: SimpleClient,
41    pub graphql_connection_config: ConnectionConfig,
42    pub cancellation_token: CancellationToken,
43}
44
45pub struct Cluster {
46    pub validator_fullnode_handle: TestCluster,
47    pub indexer_store: PgIndexerStore,
48    pub indexer_join_handle: JoinHandle<Result<(), IndexerError>>,
49    pub graphql_server_join_handle: JoinHandle<()>,
50    pub graphql_client: SimpleClient,
51    pub cancellation_token: CancellationToken,
52}
53
54/// Starts a validator, fullnode, indexer, and graphql service for testing.
55pub async fn start_cluster(
56    graphql_connection_config: ConnectionConfig,
57    internal_data_source_rpc_port: Option<u16>,
58    service_config: ServiceConfig,
59) -> Cluster {
60    let data_ingestion_path = iota_common::tempdir().keep();
61    let db_url = graphql_connection_config.db_url();
62    let cancellation_token = CancellationToken::new();
63    // Starts validator+fullnode
64    let test_cluster =
65        start_validator_with_fullnode(internal_data_source_rpc_port, data_ingestion_path.clone())
66            .await;
67
68    let grpc_url = test_cluster.grpc_url();
69    // Starts indexer
70    let (pg_store, pg_handle) = start_test_indexer_impl(
71        db_url,
72        // reset the existing db
73        true,
74        None,
75        grpc_url.clone(),
76        IndexerTypeConfig::writer_mode(None),
77        Some(data_ingestion_path),
78        cancellation_token.clone(),
79    )
80    .await;
81
82    // Starts graphql server
83    let graphql_server_handle = start_graphql_server_with_fn_rpc(
84        graphql_connection_config.clone(),
85        Some(grpc_url),
86        Some(cancellation_token.clone()),
87        Some(service_config),
88    )
89    .await;
90
91    let server_url = format!(
92        "http://{}:{}/",
93        graphql_connection_config.host, graphql_connection_config.port
94    );
95
96    // Starts graphql client
97    let client = SimpleClient::new(server_url);
98    wait_for_graphql_server(&client).await;
99
100    Cluster {
101        validator_fullnode_handle: test_cluster,
102        indexer_store: pg_store,
103        indexer_join_handle: pg_handle,
104        graphql_server_join_handle: graphql_server_handle,
105        graphql_client: client,
106        cancellation_token,
107    }
108}
109
110/// Takes in a simulated instantiation of an IOTA blockchain and builds a
111/// cluster around it.
112///
113/// This cluster is typically used in e2e tests to emulate
114/// and test behaviors. It should be noted however that queries
115/// that rely on the fullnode Write API are not supported yet.
116pub async fn serve_executor(
117    graphql_connection_config: ConnectionConfig,
118    internal_data_source_rpc_port: u16,
119    _executor: Arc<dyn GrpcStateReader + Send + Sync>,
120    epochs_to_keep: Option<u64>,
121    data_ingestion_path: PathBuf,
122) -> ExecutorCluster {
123    let db_url = graphql_connection_config.db_url();
124    // Creates a cancellation token and adds this to the ExecutorCluster, so that we
125    // can send a cancellation token on cleanup
126    let cancellation_token = CancellationToken::new();
127
128    // a dummy address to satisfy the indexer and graphql, the latter needs the url
129    // for the Write API, if not provided the server will return an error.
130    let executor_server_url: SocketAddr = format!("127.0.0.1:{internal_data_source_rpc_port}")
131        .parse()
132        .unwrap();
133
134    // in writer mode the indexer will read checkpoint data from the data ingestion
135    // path and ignore the rpc_url.
136    let (pg_store, pg_handle) = start_test_indexer_impl(
137        db_url,
138        true,
139        None,
140        format!("http://{executor_server_url}"),
141        IndexerTypeConfig::writer_mode_with_retention(
142            epochs_to_keep.map(|epochs| RetentionConfig::new(epochs, Default::default())),
143        ),
144        Some(data_ingestion_path),
145        cancellation_token.clone(),
146    )
147    .await;
148
149    // Starts graphql server
150    let graphql_server_handle = start_graphql_server_with_fn_rpc(
151        graphql_connection_config.clone(),
152        // this does not provide access to the node write api
153        Some(format!("http://{executor_server_url}")),
154        Some(cancellation_token.clone()),
155        None,
156    )
157    .await;
158
159    let server_url = format!(
160        "http://{}:{}/",
161        graphql_connection_config.host, graphql_connection_config.port
162    );
163
164    // Starts graphql client
165    let client = SimpleClient::new(server_url);
166    wait_for_graphql_server(&client).await;
167
168    ExecutorCluster {
169        indexer_store: pg_store,
170        indexer_join_handle: pg_handle,
171        graphql_server_join_handle: graphql_server_handle,
172        graphql_client: client,
173        graphql_connection_config,
174        cancellation_token,
175    }
176}
177
178/// Ping the GraphQL server for a checkpoint until an empty response is
179/// returned, indicating that the checkpoint has been pruned.
180pub async fn wait_for_graphql_checkpoint_pruned(
181    client: &SimpleClient,
182    checkpoint: u64,
183    base_timeout: Duration,
184) {
185    info!(
186        "Waiting for checkpoint to be pruned {}, base time out is {}",
187        checkpoint,
188        base_timeout.as_secs()
189    );
190    let query = format!(
191        r#"
192        {{
193            checkpoint(id: {{ sequenceNumber: {checkpoint} }}) {{
194                sequenceNumber
195            }}
196        }}"#
197    );
198
199    let timeout = base_timeout.mul_f64(checkpoint.max(1) as f64);
200
201    tokio::time::timeout(timeout, async {
202        loop {
203            let resp = match client
204                .execute_to_graphql(query.to_string(), false, vec![], vec![])
205                .await
206            {
207                Ok(resp) => resp.response_body_json(),
208                Err(e) => {
209                    info!("GraphQL request failed, retrying: {e}");
210                    tokio::time::sleep(Duration::from_secs(1)).await;
211                    continue;
212                }
213            };
214
215            let current_checkpoint = &resp["data"]["checkpoint"];
216            if current_checkpoint.is_null() {
217                break;
218            } else {
219                tokio::time::sleep(Duration::from_secs(1)).await;
220            }
221        }
222    })
223    .await
224    .expect("timeout waiting for checkpoint to be pruned");
225}
226
227pub async fn start_graphql_server_with_fn_rpc(
228    graphql_connection_config: ConnectionConfig,
229    fn_rpc_url: Option<String>,
230    cancellation_token: Option<CancellationToken>,
231    service_config: Option<ServiceConfig>,
232) -> JoinHandle<()> {
233    let cancellation_token = cancellation_token.unwrap_or_default();
234    let mut server_config = ServerConfig {
235        connection: graphql_connection_config,
236        service: service_config.unwrap_or_else(ServiceConfig::test_defaults),
237        ..ServerConfig::default()
238    };
239    if let Some(fn_rpc_url) = fn_rpc_url {
240        server_config.tx_exec_full_node.node_rpc_url = Some(fn_rpc_url);
241    };
242
243    // Starts graphql server
244    tokio::spawn(async move {
245        start_graphiql_server(&server_config, &Version::for_testing(), cancellation_token)
246            .await
247            .unwrap();
248    })
249}
250
251async fn start_validator_with_fullnode(
252    internal_data_source_rpc_port: Option<u16>,
253    data_ingestion_dir: PathBuf,
254) -> TestCluster {
255    let mut test_cluster_builder = TestClusterBuilder::new()
256        .with_num_validators(VALIDATOR_COUNT)
257        .with_epoch_duration_ms(EPOCH_DURATION_MS)
258        .with_data_ingestion_dir(data_ingestion_dir)
259        .with_accounts(vec![
260            AccountConfig {
261                address: None,
262                gas_amounts: vec![DEFAULT_GAS_AMOUNT; GAS_OBJECT_COUNT],
263            };
264            ACCOUNT_NUM
265        ]);
266
267    if let Some(internal_data_source_rpc_port) = internal_data_source_rpc_port {
268        test_cluster_builder =
269            test_cluster_builder.with_fullnode_rpc_port(internal_data_source_rpc_port);
270    };
271    test_cluster_builder.build().await
272}
273
274/// Repeatedly ping the GraphQL server for 10s, until it responds
275async fn wait_for_graphql_server(client: &SimpleClient) {
276    tokio::time::timeout(Duration::from_secs(10), async {
277        while client.ping().await.is_err() {
278            tokio::time::sleep(Duration::from_millis(500)).await;
279        }
280    })
281    .await
282    .expect("timeout waiting for graphql server to start");
283}
284
285/// Ping the GraphQL server until its background task has updated the checkpoint
286/// watermark to the desired checkpoint.
287async fn wait_for_graphql_checkpoint_catchup(
288    client: &SimpleClient,
289    checkpoint: u64,
290    base_timeout: Duration,
291) {
292    info!(
293        "Waiting for graphql to catchup to checkpoint {}, base time out is {}",
294        checkpoint,
295        base_timeout.as_secs()
296    );
297    let query = r#"
298    {
299        availableRange {
300            last {
301                sequenceNumber
302            }
303        }
304    }"#;
305
306    let timeout = base_timeout.mul_f64(checkpoint.max(1) as f64);
307
308    tokio::time::timeout(timeout, async {
309        loop {
310            let resp = match client
311                .execute_to_graphql(query.to_string(), false, vec![], vec![])
312                .await
313            {
314                Ok(resp) => resp.response_body_json(),
315                Err(e) => {
316                    info!("GraphQL request failed, retrying: {e}");
317                    tokio::time::sleep(Duration::from_secs(1)).await;
318                    continue;
319                }
320            };
321
322            let current_checkpoint = resp["data"]["availableRange"]["last"].get("sequenceNumber");
323            info!("Current checkpoint: {:?}", current_checkpoint);
324            // Indexer has not picked up any checkpoints yet
325            let Some(current_checkpoint) = current_checkpoint else {
326                tokio::time::sleep(Duration::from_secs(1)).await;
327                continue;
328            };
329
330            // Indexer has picked up a checkpoint, but it's not the one we're waiting for
331            let current_checkpoint = current_checkpoint.as_u64().unwrap();
332            if current_checkpoint < checkpoint {
333                tokio::time::sleep(Duration::from_secs(1)).await;
334            } else {
335                break;
336            }
337        }
338    })
339    .await
340    .expect("timeout waiting for graphql to catchup to checkpoint");
341}
342
343impl Cluster {
344    /// Waits for the indexer to index up to the given checkpoint, then waits
345    /// for the graphql service's background task to update the checkpoint
346    /// watermark to the given checkpoint.
347    pub async fn wait_for_checkpoint_catchup(&self, checkpoint: u64, base_timeout: Duration) {
348        wait_for_graphql_checkpoint_catchup(&self.graphql_client, checkpoint, base_timeout).await
349    }
350
351    /// Waits for the indexer to prune a given checkpoint.
352    pub async fn wait_for_checkpoint_pruned(&self, checkpoint: u64, base_timeout: Duration) {
353        wait_for_graphql_checkpoint_pruned(&self.graphql_client, checkpoint, base_timeout).await
354    }
355
356    /// Builds a transaction that transfers IOTA for testing.
357    pub async fn build_transfer_iota_for_test(&self) -> Transaction {
358        let addresses = self.validator_fullnode_handle.wallet.get_addresses();
359
360        let recipient = addresses[1];
361        self.validator_fullnode_handle
362            .test_transaction_builder()
363            .await
364            .transfer_iota(Some(1_000), recipient)
365            .build()
366    }
367
368    /// Signs a transaction.
369    pub fn sign_transaction(&self, transaction: &Transaction) -> TransactionEnvelope {
370        self.validator_fullnode_handle
371            .wallet
372            .sign_transaction(transaction)
373    }
374}
375
376impl ExecutorCluster {
377    /// Waits for the indexer to index up to the given checkpoint, then waits
378    /// for the graphql service's background task to update the checkpoint
379    /// watermark to the given checkpoint.
380    pub async fn wait_for_checkpoint_catchup(&self, checkpoint: u64, base_timeout: Duration) {
381        wait_for_graphql_checkpoint_catchup(&self.graphql_client, checkpoint, base_timeout).await
382    }
383
384    /// Waits for the indexer to prune a given checkpoint.
385    pub async fn wait_for_checkpoint_pruned(&self, checkpoint: u64, base_timeout: Duration) {
386        wait_for_graphql_checkpoint_pruned(&self.graphql_client, checkpoint, base_timeout).await
387    }
388
389    /// Sends a cancellation signal to the graphql and indexer services, waits
390    /// for them to complete, and then deletes the database created for the
391    /// test.
392    pub async fn cleanup_resources(self) {
393        self.cancellation_token.cancel();
394        let _ = join!(self.graphql_server_join_handle, self.indexer_join_handle);
395        let db_url = self.graphql_connection_config.db_url();
396        force_delete_database(db_url).await;
397    }
398}