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