|
1 | 1 | // Copyright (c) Mysten Labs, Inc. |
2 | 2 | // SPDX-License-Identifier: Apache-2.0 |
3 | 3 |
|
| 4 | +use std::collections::HashMap; |
| 5 | +use std::hash::Hash; |
4 | 6 | use std::time::Duration; |
5 | 7 |
|
6 | 8 | use anyhow::Context; |
7 | 9 | use async_graphql::dataloader::DataLoader; |
| 10 | +use async_graphql::dataloader::Loader; |
| 11 | +use futures::future::try_join_all; |
8 | 12 | use prometheus::Registry; |
9 | 13 | use sui_rpc::Client; |
10 | 14 | use sui_rpc::proto::sui::rpc::v2 as grpc; |
@@ -49,6 +53,68 @@ pub struct LedgerGrpcReader { |
49 | 53 | timeout: Option<Duration>, |
50 | 54 | } |
51 | 55 |
|
| 56 | +/// Maximum number of transaction digests `LedgerGrpcReader` will put in a single |
| 57 | +/// `BatchGetTransactions` call, matching the ledger gRPC/KV-RPC service's own hard cap. |
| 58 | +pub(crate) const MAX_BATCH_GET_TRANSACTIONS: usize = 200; |
| 59 | + |
| 60 | +/// Maximum number of object keys `LedgerGrpcReader` will put in a single `BatchGetObjects` |
| 61 | +/// call, matching the ledger gRPC/KV-RPC service's own hard cap. |
| 62 | +pub(crate) const MAX_BATCH_GET_OBJECTS: usize = 1000; |
| 63 | + |
| 64 | +/// Implemented by `LedgerGrpcReader` for each key type whose `Loader::load` needs to stay under |
| 65 | +/// the ledger service's batch-size limit — `DataLoader`'s own `max_batch_size` is only a dispatch |
| 66 | +/// trigger, not a hard cap on how many keys reach a single `Loader::load` call. `load_chunk` |
| 67 | +/// supplies the raw, single-chunk fetch and `chunk_size` supplies the limit; `load_chunked`'s |
| 68 | +/// default body splits `keys` into chunks, dispatches `load_chunk` concurrently per chunk, and |
| 69 | +/// merges the results. |
| 70 | +/// |
| 71 | +/// `pub`, not `pub(crate)`: it's referenced through `LedgerGrpcReader`'s blanket `Loader<K>` impl |
| 72 | +/// below (`type Value = <Self as ChunkedLoader<K>>::Value`), and that impl is part of |
| 73 | +/// `LedgerGrpcReader`'s public interface since both the type and `Loader` are public. |
| 74 | +#[async_trait::async_trait] |
| 75 | +pub trait ChunkedLoader<K> |
| 76 | +where |
| 77 | + K: Send + Sync + Hash + Eq + Clone + 'static, |
| 78 | +{ |
| 79 | + type Value: Send + Sync + Clone + 'static; |
| 80 | + type Error: Send + Sync + Clone + 'static; |
| 81 | + |
| 82 | + fn chunk_size(&self) -> usize; |
| 83 | + |
| 84 | + async fn load_chunk(&self, keys: &[K]) -> Result<HashMap<K, Self::Value>, Self::Error>; |
| 85 | + |
| 86 | + async fn load_chunked(&self, keys: &[K]) -> Result<HashMap<K, Self::Value>, Self::Error> |
| 87 | + where |
| 88 | + Self: Sync, |
| 89 | + { |
| 90 | + let limit = self.chunk_size(); |
| 91 | + |
| 92 | + let mut results = HashMap::new(); |
| 93 | + for batch in try_join_all(keys.chunks(limit).map(|chunk| self.load_chunk(chunk))).await? { |
| 94 | + results.extend(batch); |
| 95 | + } |
| 96 | + Ok(results) |
| 97 | + } |
| 98 | +} |
| 99 | + |
| 100 | +/// Covers every key type `LedgerGrpcReader` implements [`ChunkedLoader`] for. Coherent because |
| 101 | +/// `Self` (`LedgerGrpcReader`) is a concrete local type — only `K` is generic — unlike a bare |
| 102 | +/// `impl<K, T: ChunkedLoader<K>> Loader<K> for T`, which the orphan rules reject since neither |
| 103 | +/// `Loader` (foreign) nor `T` (an uncovered generic) is local. |
| 104 | +#[async_trait::async_trait] |
| 105 | +impl<K> Loader<K> for LedgerGrpcReader |
| 106 | +where |
| 107 | + K: Send + Sync + Hash + Eq + Clone + 'static, |
| 108 | + Self: ChunkedLoader<K>, |
| 109 | +{ |
| 110 | + type Value = <Self as ChunkedLoader<K>>::Value; |
| 111 | + type Error = <Self as ChunkedLoader<K>>::Error; |
| 112 | + |
| 113 | + async fn load(&self, keys: &[K]) -> Result<HashMap<K, Self::Value>, Self::Error> { |
| 114 | + self.load_chunked(keys).await |
| 115 | + } |
| 116 | +} |
| 117 | + |
52 | 118 | impl LedgerGrpcArgs { |
53 | 119 | pub fn new( |
54 | 120 | statement_timeout_ms: Option<u64>, |
@@ -90,7 +156,7 @@ impl LedgerGrpcReader { |
90 | 156 | Ok(Self { client, timeout }) |
91 | 157 | } |
92 | 158 |
|
93 | | - pub fn as_data_loader(&self) -> DataLoader<Self> { |
| 159 | + pub(crate) fn as_data_loader(&self) -> DataLoader<Self> { |
94 | 160 | DataLoader::new(self.clone(), tokio::spawn) |
95 | 161 | } |
96 | 162 |
|
@@ -207,3 +273,126 @@ impl Default for LedgerGrpcArgs { |
207 | 273 | } |
208 | 274 | } |
209 | 275 | } |
| 276 | + |
| 277 | +#[cfg(test)] |
| 278 | +pub(crate) mod test_support { |
| 279 | + use std::net::SocketAddr; |
| 280 | + use std::sync::Arc; |
| 281 | + use std::sync::Mutex; |
| 282 | + use std::time::Duration; |
| 283 | + |
| 284 | + use prometheus::Registry; |
| 285 | + use sui_rpc::proto::sui::rpc::v2::BatchGetObjectsRequest; |
| 286 | + use sui_rpc::proto::sui::rpc::v2::BatchGetObjectsResponse; |
| 287 | + use sui_rpc::proto::sui::rpc::v2::BatchGetTransactionsRequest; |
| 288 | + use sui_rpc::proto::sui::rpc::v2::BatchGetTransactionsResponse; |
| 289 | + use sui_rpc::proto::sui::rpc::v2::GetObjectRequest; |
| 290 | + use sui_rpc::proto::sui::rpc::v2::ledger_service_server::LedgerService; |
| 291 | + use sui_rpc::proto::sui::rpc::v2::ledger_service_server::LedgerServiceServer; |
| 292 | + use tokio::net::TcpListener; |
| 293 | + use tokio::task::JoinHandle; |
| 294 | + use tokio_stream::wrappers::TcpListenerStream; |
| 295 | + use tonic::Request; |
| 296 | + use tonic::Response; |
| 297 | + use tonic::Status; |
| 298 | + |
| 299 | + use super::LedgerGrpcArgs; |
| 300 | + use super::LedgerGrpcReader; |
| 301 | + |
| 302 | + /// Starts a [`MockLedgerServer`] and constructs a [`LedgerGrpcReader`] |
| 303 | + /// pointed at it. Shared by every loader's chunking test. |
| 304 | + pub(crate) async fn mock_reader() -> (LedgerGrpcReader, MockLedgerServer, JoinHandle<()>) { |
| 305 | + let mock = MockLedgerServer::new(); |
| 306 | + let (addr, server) = mock.start().await.expect("start mock ledger service"); |
| 307 | + let reader = LedgerGrpcReader::new( |
| 308 | + format!("http://{addr}").parse().unwrap(), |
| 309 | + LedgerGrpcArgs::default(), |
| 310 | + None, |
| 311 | + &Registry::new(), |
| 312 | + ) |
| 313 | + .await |
| 314 | + .expect("construct LedgerGrpcReader"); |
| 315 | + (reader, mock, server) |
| 316 | + } |
| 317 | + |
| 318 | + /// Asserts that `batches` (the digest lists recorded by |
| 319 | + /// [`MockLedgerServer`] across however many gRPC calls a loader made) is |
| 320 | + /// chunked to `limit`, and that their union reconstructs `expected` |
| 321 | + /// exactly, with none lost or duplicated. |
| 322 | + pub(crate) fn assert_chunked(batches: Vec<Vec<String>>, limit: usize, expected: &[String]) { |
| 323 | + assert_eq!(batches.len(), expected.len().div_ceil(limit)); |
| 324 | + assert!(batches.iter().all(|batch| batch.len() <= limit)); |
| 325 | + |
| 326 | + let mut requested: Vec<String> = batches.into_iter().flatten().collect(); |
| 327 | + requested.sort(); |
| 328 | + let mut expected = expected.to_vec(); |
| 329 | + expected.sort(); |
| 330 | + assert_eq!(requested, expected); |
| 331 | + } |
| 332 | + |
| 333 | + /// A minimal in-process `LedgerService` that records the shape of every |
| 334 | + /// `BatchGetTransactions`/`BatchGetObjects` call it receives and responds |
| 335 | + /// with an empty result set. Every other RPC falls back to the generated |
| 336 | + /// trait's default `unimplemented` behavior, which is all the chunking |
| 337 | + /// tests that use this need. |
| 338 | + #[derive(Clone, Default)] |
| 339 | + pub(crate) struct MockLedgerServer { |
| 340 | + transaction_batches: Arc<Mutex<Vec<Vec<String>>>>, |
| 341 | + object_batches: Arc<Mutex<Vec<Vec<GetObjectRequest>>>>, |
| 342 | + } |
| 343 | + |
| 344 | + impl MockLedgerServer { |
| 345 | + pub(crate) fn new() -> Self { |
| 346 | + Self::default() |
| 347 | + } |
| 348 | + |
| 349 | + pub(crate) async fn start(&self) -> anyhow::Result<(SocketAddr, JoinHandle<()>)> { |
| 350 | + let listener = TcpListener::bind("127.0.0.1:0").await?; |
| 351 | + let addr = listener.local_addr()?; |
| 352 | + let mock = self.clone(); |
| 353 | + let handle = tokio::spawn(async move { |
| 354 | + let incoming = TcpListenerStream::new(listener); |
| 355 | + tonic::transport::Server::builder() |
| 356 | + .add_service(LedgerServiceServer::new(mock)) |
| 357 | + .serve_with_incoming(incoming) |
| 358 | + .await |
| 359 | + .ok(); |
| 360 | + }); |
| 361 | + tokio::time::sleep(Duration::from_millis(10)).await; |
| 362 | + Ok((addr, handle)) |
| 363 | + } |
| 364 | + |
| 365 | + pub(crate) fn transaction_batches(&self) -> Vec<Vec<String>> { |
| 366 | + self.transaction_batches.lock().unwrap().clone() |
| 367 | + } |
| 368 | + |
| 369 | + pub(crate) fn object_batches(&self) -> Vec<Vec<GetObjectRequest>> { |
| 370 | + self.object_batches.lock().unwrap().clone() |
| 371 | + } |
| 372 | + } |
| 373 | + |
| 374 | + #[tonic::async_trait] |
| 375 | + impl LedgerService for MockLedgerServer { |
| 376 | + async fn batch_get_transactions( |
| 377 | + &self, |
| 378 | + request: Request<BatchGetTransactionsRequest>, |
| 379 | + ) -> Result<Response<BatchGetTransactionsResponse>, Status> { |
| 380 | + self.transaction_batches |
| 381 | + .lock() |
| 382 | + .unwrap() |
| 383 | + .push(request.into_inner().digests); |
| 384 | + Ok(Response::new(BatchGetTransactionsResponse::default())) |
| 385 | + } |
| 386 | + |
| 387 | + async fn batch_get_objects( |
| 388 | + &self, |
| 389 | + request: Request<BatchGetObjectsRequest>, |
| 390 | + ) -> Result<Response<BatchGetObjectsResponse>, Status> { |
| 391 | + self.object_batches |
| 392 | + .lock() |
| 393 | + .unwrap() |
| 394 | + .push(request.into_inner().requests); |
| 395 | + Ok(Response::new(BatchGetObjectsResponse::default())) |
| 396 | + } |
| 397 | + } |
| 398 | +} |
0 commit comments