Skip to content

Commit f840b6a

Browse files
sui-indexer-alt-reader: chunk oversized LedgerGrpcReader batch requests (#27516)
## Description `LedgerGrpcReader` (used by GraphQL and jsonrpc for ledger gRPC/KV-RPC access) built a single unchunked `BatchGetTransactions`/`BatchGetObjects` request per `DataLoader` batch, which could exceed the ledger service's own 200/1000 batch-size caps and surface as an opaque gRPC error. Introduces a `ChunkedLoader<K>` trait: `LedgerGrpcReader` implements it per key type with `chunk_size()` (the limit) and `load_chunk()` (the actual gRPC call); a provided `load_chunked()` method splits keys into chunks, dispatches `load_chunk` concurrently per chunk, and merges the results. A single generic `impl<K> Loader<K> for LedgerGrpcReader` (where `Self: ChunkedLoader<K>`) forwards to `load_chunked`, covering every chunked key type at once. `CheckpointKey`'s loader already fans out per-key rather than batching, so it keeps its own ordinary `Loader<CheckpointKey>` impl and never implements `ChunkedLoader`. ## Test plan New chunking tests in `sui-indexer-alt-reader` (`events`/`transactions`/`objects`) construct oversized key sets against a mock ledger service and assert requests are split to the batch limit. ## Release notes - [ ] Nothing user-facing changed.
1 parent b03270e commit f840b6a

6 files changed

Lines changed: 339 additions & 28 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/sui-indexer-alt-reader/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,6 @@ sui-rpc.workspace = true
4646
sui-sdk-types.workspace = true
4747
sui-sql-macro.workspace = true
4848
sui-types.workspace = true
49+
50+
[dev-dependencies]
51+
tokio-stream.workspace = true

crates/sui-indexer-alt-reader/src/events.rs

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ use sui_types::effects::TransactionEvents;
2323

2424
use crate::bigtable_reader::BigtableReader;
2525
use crate::error::Error;
26+
use crate::ledger_grpc_reader::ChunkedLoader;
2627
use crate::ledger_grpc_reader::LedgerGrpcReader;
28+
use crate::ledger_grpc_reader::MAX_BATCH_GET_TRANSACTIONS;
2729
use crate::pg_reader::PgReader;
2830

2931
/// Key for fetching transaction events contents (Events, TimestampMs) by digest.
@@ -102,18 +104,18 @@ impl Loader<TransactionEventsKey> for BigtableReader {
102104
}
103105

104106
#[async_trait::async_trait]
105-
impl Loader<TransactionEventsKey> for LedgerGrpcReader {
107+
impl ChunkedLoader<TransactionEventsKey> for LedgerGrpcReader {
106108
type Value = TransactionEventsData;
107109
type Error = Error;
108110

109-
async fn load(
111+
fn chunk_size(&self) -> usize {
112+
MAX_BATCH_GET_TRANSACTIONS
113+
}
114+
115+
async fn load_chunk(
110116
&self,
111117
keys: &[TransactionEventsKey],
112-
) -> Result<HashMap<TransactionEventsKey, Self::Value>, Self::Error> {
113-
if keys.is_empty() {
114-
return Ok(HashMap::new());
115-
}
116-
118+
) -> Result<HashMap<TransactionEventsKey, TransactionEventsData>, Error> {
117119
let digests = keys.iter().map(|key| key.0.to_string()).collect();
118120

119121
let mut request = proto::BatchGetTransactionsRequest::default();
@@ -171,3 +173,28 @@ impl Loader<TransactionEventsKey> for LedgerGrpcReader {
171173
.map_err(Error::from)
172174
}
173175
}
176+
177+
#[cfg(test)]
178+
mod tests {
179+
use super::*;
180+
use crate::ledger_grpc_reader::test_support::assert_chunked;
181+
use crate::ledger_grpc_reader::test_support::mock_reader;
182+
183+
#[tokio::test]
184+
async fn load_chunks_oversized_batches() {
185+
let (reader, mock, server) = mock_reader().await;
186+
let limit = MAX_BATCH_GET_TRANSACTIONS;
187+
188+
let keys: Vec<TransactionEventsKey> = (0..limit + 50)
189+
.map(|_| TransactionEventsKey(TransactionDigest::random()))
190+
.collect();
191+
192+
let result = reader.load(&keys).await.expect("load should succeed");
193+
assert!(result.is_empty());
194+
195+
let expected: Vec<String> = keys.iter().map(|key| key.0.to_string()).collect();
196+
assert_chunked(mock.transaction_batches(), limit, &expected);
197+
198+
server.abort();
199+
}
200+
}

crates/sui-indexer-alt-reader/src/ledger_grpc_reader.rs

Lines changed: 190 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
// Copyright (c) Mysten Labs, Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

4+
use std::collections::HashMap;
5+
use std::hash::Hash;
46
use std::time::Duration;
57

68
use anyhow::Context;
79
use async_graphql::dataloader::DataLoader;
10+
use async_graphql::dataloader::Loader;
11+
use futures::future::try_join_all;
812
use prometheus::Registry;
913
use sui_rpc::Client;
1014
use sui_rpc::proto::sui::rpc::v2 as grpc;
@@ -49,6 +53,68 @@ pub struct LedgerGrpcReader {
4953
timeout: Option<Duration>,
5054
}
5155

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+
52118
impl LedgerGrpcArgs {
53119
pub fn new(
54120
statement_timeout_ms: Option<u64>,
@@ -90,7 +156,7 @@ impl LedgerGrpcReader {
90156
Ok(Self { client, timeout })
91157
}
92158

93-
pub fn as_data_loader(&self) -> DataLoader<Self> {
159+
pub(crate) fn as_data_loader(&self) -> DataLoader<Self> {
94160
DataLoader::new(self.clone(), tokio::spawn)
95161
}
96162

@@ -207,3 +273,126 @@ impl Default for LedgerGrpcArgs {
207273
}
208274
}
209275
}
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+
}

crates/sui-indexer-alt-reader/src/objects.rs

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ use sui_types::storage::ObjectKey;
1919

2020
use crate::bigtable_reader::BigtableReader;
2121
use crate::error::Error;
22+
use crate::ledger_grpc_reader::ChunkedLoader;
2223
use crate::ledger_grpc_reader::LedgerGrpcReader;
24+
use crate::ledger_grpc_reader::MAX_BATCH_GET_OBJECTS;
2325
use crate::pg_reader::PgReader;
2426

2527
/// Key for fetching the contents a particular version of an object.
@@ -103,18 +105,18 @@ impl Loader<VersionedObjectKey> for BigtableReader {
103105
}
104106

105107
#[async_trait::async_trait]
106-
impl Loader<VersionedObjectKey> for LedgerGrpcReader {
108+
impl ChunkedLoader<VersionedObjectKey> for LedgerGrpcReader {
107109
type Value = Object;
108110
type Error = Error;
109111

110-
async fn load(
112+
fn chunk_size(&self) -> usize {
113+
MAX_BATCH_GET_OBJECTS
114+
}
115+
116+
async fn load_chunk(
111117
&self,
112118
keys: &[VersionedObjectKey],
113119
) -> Result<HashMap<VersionedObjectKey, Object>, Error> {
114-
if keys.is_empty() {
115-
return Ok(HashMap::new());
116-
}
117-
118120
let requests = keys
119121
.iter()
120122
.map(|key| {
@@ -145,3 +147,47 @@ impl Loader<VersionedObjectKey> for LedgerGrpcReader {
145147
Ok(results)
146148
}
147149
}
150+
151+
#[cfg(test)]
152+
mod tests {
153+
use sui_sdk_types::Address;
154+
use sui_types::base_types::ObjectID;
155+
156+
use super::*;
157+
use crate::ledger_grpc_reader::test_support::mock_reader;
158+
159+
#[tokio::test]
160+
async fn load_chunks_oversized_batches() {
161+
let (reader, mock, server) = mock_reader().await;
162+
let limit = MAX_BATCH_GET_OBJECTS;
163+
164+
let keys: Vec<VersionedObjectKey> = (0..limit + 50)
165+
.map(|i| VersionedObjectKey(ObjectID::random(), i as u64))
166+
.collect();
167+
168+
let result = reader.load(&keys).await.expect("load should succeed");
169+
assert!(result.is_empty());
170+
171+
let batches = mock.object_batches();
172+
assert_eq!(batches.len(), 2);
173+
assert!(batches.iter().all(|batch| batch.len() <= limit));
174+
175+
let mut requested: Vec<(String, Option<u64>)> = batches
176+
.into_iter()
177+
.flatten()
178+
.map(|req| (req.object_id.unwrap_or_default(), req.version))
179+
.collect();
180+
requested.sort();
181+
let mut expected: Vec<(String, Option<u64>)> = keys
182+
.iter()
183+
.map(|key| {
184+
let address: Address = key.0.into();
185+
(address.to_string(), Some(key.1))
186+
})
187+
.collect();
188+
expected.sort();
189+
assert_eq!(requested, expected);
190+
191+
server.abort();
192+
}
193+
}

0 commit comments

Comments
 (0)