sui_indexer_alt_framework/ingestion/
streaming_client.rs1use std::sync::Arc;
5use std::time::Duration;
6
7use anyhow::Context;
8use anyhow::anyhow;
9use async_trait::async_trait;
10use futures::StreamExt;
11use futures::stream::BoxStream;
12use sui_rpc::headers::X_SUI_CHAIN_ID;
13use sui_rpc::proto::sui::rpc::v2::SubscribeCheckpointsRequest;
14use sui_rpc::proto::sui::rpc::v2::subscription_service_client::SubscriptionServiceClient;
15use sui_types::digests::ChainIdentifier;
16use sui_types::messages_checkpoint::CheckpointDigest;
17use tokio_stream::adapters::Peekable;
18use tonic::Code;
19use tonic::Status;
20use tonic::transport::Endpoint;
21use tonic::transport::Uri;
22use tracing::warn;
23use url::Url;
24
25use crate::ingestion::MAX_GRPC_MESSAGE_SIZE_BYTES;
26use crate::ingestion::error::Error;
27use crate::ingestion::error::Result;
28use crate::ingestion::ingestion_client::IngestionClient;
29use crate::metrics::IngestionMetrics;
30use crate::types::full_checkpoint_content::Checkpoint;
31
32pub struct CheckpointStream {
33 pub stream: Peekable<BoxStream<'static, Result<Checkpoint>>>,
34 pub chain_id: ChainIdentifier,
35}
36
37#[async_trait]
39pub trait CheckpointStreamingClient {
40 async fn connect(&self) -> Result<CheckpointStream>;
42
43 async fn latest_checkpoint_number(&self) -> Result<u64> {
45 let mut stream = self.connect().await?;
46
47 match stream.stream.next().await {
48 Some(Ok(checkpoint)) => Ok(checkpoint.summary.sequence_number),
49 Some(Err(e)) => Err(e),
50 None => Err(Error::StreamingError(anyhow!("Stream ended unexpectedly"))),
51 }
52 }
53}
54
55#[derive(clap::Args, Clone, Debug, Default)]
56pub struct StreamingClientArgs {
57 #[clap(long, env)]
61 pub streaming_url: Option<Uri>,
62}
63
64#[derive(Clone)]
66pub struct GrpcStreamingClient {
67 uri: Uri,
68 connection_timeout: Duration,
69 statement_timeout: Duration,
70}
71
72impl StreamingClientArgs {
73 pub async fn gap_fill_client(
82 &self,
83 connection_timeout: Duration,
84 metrics: Arc<IngestionMetrics>,
85 ) -> Result<Option<IngestionClient>> {
86 let Some(uri) = &self.streaming_url else {
87 return Ok(None);
88 };
89
90 let url = Url::parse(&uri.to_string())
91 .with_context(|| format!("Invalid streaming URL for gap filling: {uri}"))
92 .map_err(Error::StreamingError)?;
93
94 let client = IngestionClient::with_grpc(url, None, None, metrics)?;
95 match tokio::time::timeout(connection_timeout, client.latest_checkpoint_number()).await {
96 Ok(Err(error)) if refuses_ledger_requests(&error) => {
97 warn!(
98 %uri,
99 "Streaming endpoint refused a ledger request, filling stream gaps from the \
100 ingestion source instead: {error}"
101 );
102 Ok(None)
103 }
104 _ => Ok(Some(client)),
105 }
106 }
107}
108
109impl GrpcStreamingClient {
110 pub fn new(uri: Uri, connection_timeout: Duration, statement_timeout: Duration) -> Self {
111 Self {
112 uri,
113 connection_timeout,
114 statement_timeout,
115 }
116 }
117}
118
119#[async_trait]
120impl CheckpointStreamingClient for GrpcStreamingClient {
121 async fn connect(&self) -> Result<CheckpointStream> {
122 let endpoint = Endpoint::from(self.uri.clone())
123 .connect_timeout(self.connection_timeout)
124 .timeout(self.connection_timeout);
125
126 let mut client = SubscriptionServiceClient::connect(endpoint)
127 .await
128 .map_err(|err| Error::RpcClientError(Status::from_error(err.into())))?
129 .max_decoding_message_size(MAX_GRPC_MESSAGE_SIZE_BYTES);
130
131 let mut request = SubscribeCheckpointsRequest::default();
132 request.read_mask = Some(Checkpoint::proto_field_mask());
133
134 let response = client
135 .subscribe_checkpoints(request)
136 .await
137 .map_err(Error::RpcClientError)?;
138
139 let chain_id_value = response.metadata().get(X_SUI_CHAIN_ID).ok_or_else(|| {
140 Error::StreamingError(anyhow!("Chain ID not found in response metadata"))
141 })?;
142 let chain_id: ChainIdentifier = chain_id_value
143 .to_str()
144 .map_err(|e| Error::StreamingError(anyhow!("Chain ID is not valid ASCII: {e}")))?
145 .parse::<CheckpointDigest>()
146 .map_err(|e| Error::StreamingError(anyhow!("Chain ID parse error: {e}")))?
147 .into();
148
149 let stream = response
150 .into_inner()
151 .map(|result| async move {
152 match result {
153 Ok(response) => {
154 let checkpoint = response
155 .checkpoint
156 .context("Checkpoint data missing in response")
157 .map_err(Error::StreamingError)?;
158 tokio::task::spawn_blocking(move || {
163 Checkpoint::try_from(&checkpoint).context("Failed to parse checkpoint")
164 })
165 .await
166 .map_err(|e| Error::StreamingError(anyhow!("decode task panicked: {e}")))?
167 .map_err(Error::StreamingError)
168 }
169 Err(e) => Err(Error::RpcClientError(e)),
170 }
171 })
172 .buffered(4);
173 let stream = wrap_stream(stream, self.statement_timeout);
174
175 Ok(CheckpointStream { stream, chain_id })
176 }
177}
178
179fn wrap_stream(
182 stream: impl futures::Stream<Item = Result<Checkpoint>> + Send + 'static,
183 statement_timeout: Duration,
184) -> Peekable<BoxStream<'static, Result<Checkpoint>>> {
185 let stream = tokio_stream::StreamExt::timeout(stream, statement_timeout)
186 .map(move |result| match result {
187 Err(_elapsed) => Err(Error::StreamingError(anyhow!(
188 "Statement timeout after {statement_timeout:?}"
189 ))),
190 Ok(result) => result,
191 })
192 .boxed();
193 tokio_stream::StreamExt::peekable(stream)
194}
195
196fn refuses_ledger_requests(error: &anyhow::Error) -> bool {
199 error.downcast_ref::<Status>().is_some_and(|status| {
200 matches!(
201 status.code(),
202 Code::Unimplemented | Code::PermissionDenied | Code::Unauthenticated
203 )
204 })
205}
206
207#[cfg(test)]
208mod tests {
209 use std::net::SocketAddr;
210 use std::time::Duration;
211
212 use sui_rpc::proto::sui::rpc::v2::SubscribeCheckpointsRequest;
213 use sui_rpc::proto::sui::rpc::v2::SubscribeCheckpointsResponse;
214 use sui_rpc::proto::sui::rpc::v2::subscription_service_server::SubscriptionService;
215 use sui_rpc::proto::sui::rpc::v2::subscription_service_server::SubscriptionServiceServer;
216 use tonic::transport::Server;
217
218 use crate::metrics::tests::test_ingestion_metrics;
219
220 use super::*;
221
222 struct HangingSubscriptionService;
225
226 #[tonic::async_trait]
227 impl SubscriptionService for HangingSubscriptionService {
228 async fn subscribe_checkpoints(
229 &self,
230 _request: tonic::Request<SubscribeCheckpointsRequest>,
231 ) -> std::result::Result<
232 tonic::Response<
233 BoxStream<'static, std::result::Result<SubscribeCheckpointsResponse, Status>>,
234 >,
235 Status,
236 > {
237 futures::future::pending().await
238 }
239 }
240
241 #[tokio::test]
242 async fn subscribe_checkpoints_times_out_on_stalled_server() {
243 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
244 let addr: SocketAddr = listener.local_addr().unwrap();
245
246 tokio::spawn(async move {
247 let incoming = tokio_stream::wrappers::TcpListenerStream::new(listener);
248 Server::builder()
249 .add_service(SubscriptionServiceServer::new(HangingSubscriptionService))
250 .serve_with_incoming(incoming)
251 .await
252 .unwrap();
253 });
254
255 let timeout = Duration::from_millis(200);
256 let uri: Uri = format!("http://{addr}").parse().unwrap();
257 let client = GrpcStreamingClient::new(uri, timeout, timeout);
258
259 let start = std::time::Instant::now();
260 let result = client.connect().await;
261 let elapsed = start.elapsed();
262
263 assert!(result.is_err(), "expected timeout error");
264 assert!(
265 elapsed < Duration::from_secs(5),
266 "connect() took {elapsed:?}, should have timed out in ~200ms"
267 );
268 }
269
270 #[tokio::test]
273 async fn no_gap_fill_client_without_ledger_service() {
274 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
275 let addr: SocketAddr = listener.local_addr().unwrap();
276
277 tokio::spawn(async move {
278 let incoming = tokio_stream::wrappers::TcpListenerStream::new(listener);
279 Server::builder()
280 .add_service(SubscriptionServiceServer::new(HangingSubscriptionService))
281 .serve_with_incoming(incoming)
282 .await
283 .unwrap();
284 });
285
286 let args = StreamingClientArgs {
287 streaming_url: Some(format!("http://{addr}").parse().unwrap()),
288 };
289 let client = args
290 .gap_fill_client(Duration::from_secs(5), test_ingestion_metrics())
291 .await
292 .unwrap();
293
294 assert!(client.is_none());
295 }
296
297 #[tokio::test]
300 async fn gap_fill_client_kept_when_endpoint_unreachable() {
301 let addr = tokio::net::TcpListener::bind("127.0.0.1:0")
303 .await
304 .unwrap()
305 .local_addr()
306 .unwrap();
307
308 let args = StreamingClientArgs {
309 streaming_url: Some(format!("http://{addr}").parse().unwrap()),
310 };
311 let client = args
312 .gap_fill_client(Duration::from_secs(5), test_ingestion_metrics())
313 .await
314 .unwrap();
315
316 assert!(client.is_some());
317 }
318}
319
320#[cfg(test)]
321pub mod test_utils {
322 use std::pin::Pin;
323 use std::sync::Arc;
324 use std::sync::Mutex;
325 use std::sync::atomic::AtomicUsize;
326 use std::sync::atomic::Ordering;
327 use std::time::Duration;
328 use std::time::Instant;
329
330 use futures::Stream;
331
332 use crate::types::test_checkpoint_data_builder::TestCheckpointBuilder;
333
334 use super::*;
335
336 enum StreamAction {
337 Checkpoint(u64),
338 Error,
339 Timeout {
340 deadline: Option<Instant>,
341 duration: Duration,
342 },
343 }
344
345 struct MockStreamState {
346 actions: Arc<Mutex<Vec<StreamAction>>>,
347 }
348
349 impl Stream for MockStreamState {
350 type Item = Result<Checkpoint>;
351
352 fn poll_next(
353 self: Pin<&mut Self>,
354 _cx: &mut std::task::Context<'_>,
355 ) -> std::task::Poll<Option<Self::Item>> {
356 let mut actions = self.actions.lock().unwrap();
357 if actions.is_empty() {
358 return std::task::Poll::Ready(None);
359 }
360
361 match &actions[0] {
362 StreamAction::Checkpoint(seq) => {
363 let seq = *seq;
364 actions.remove(0);
365 let mut builder = TestCheckpointBuilder::new(seq);
366 std::task::Poll::Ready(Some(Ok(builder.build_checkpoint())))
367 }
368 StreamAction::Error => {
369 actions.remove(0);
370 std::task::Poll::Ready(Some(Err(Error::StreamingError(anyhow::anyhow!(
371 "Mock streaming error"
372 )))))
373 }
374 StreamAction::Timeout { deadline, duration } => match deadline {
375 None => {
376 let deadline = Instant::now() + *duration;
377 actions[0] = StreamAction::Timeout {
378 deadline: Some(deadline),
379 duration: *duration,
380 };
381 std::task::Poll::Pending
382 }
383 Some(deadline_instant) => {
384 if Instant::now() >= *deadline_instant {
385 actions.remove(0);
386 drop(actions);
387 self.poll_next(_cx)
388 } else {
389 std::task::Poll::Pending
390 }
391 }
392 },
393 }
394 }
395 }
396
397 pub struct MockStreamingClient {
399 actions: Arc<Mutex<Vec<StreamAction>>>,
400 connection_failures_remaining: AtomicUsize,
401 connection_timeouts_remaining: AtomicUsize,
402 timeout_duration: Duration,
404 statement_timeout: Duration,
406 }
407
408 impl MockStreamingClient {
409 pub fn mock_chain_id() -> ChainIdentifier {
410 CheckpointDigest::new([1; 32]).into()
411 }
412
413 pub fn new<I>(checkpoint_range: I, timeout_duration: Option<Duration>) -> Self
414 where
415 I: IntoIterator<Item = u64>,
416 {
417 let timeout_duration = timeout_duration.unwrap_or(Duration::from_secs(5));
418 Self {
419 actions: Arc::new(Mutex::new(
420 checkpoint_range
421 .into_iter()
422 .map(StreamAction::Checkpoint)
423 .collect(),
424 )),
425 connection_failures_remaining: AtomicUsize::new(0),
426 connection_timeouts_remaining: AtomicUsize::new(0),
427 statement_timeout: timeout_duration / 2,
428 timeout_duration,
429 }
430 }
431
432 pub fn fail_connection_times(mut self, times: usize) -> Self {
434 self.connection_failures_remaining = AtomicUsize::new(times);
435 self
436 }
437
438 pub fn fail_connection_with_timeout(mut self, times: usize) -> Self {
440 self.connection_timeouts_remaining = AtomicUsize::new(times);
441 self
442 }
443
444 pub fn insert_error(&mut self) {
446 self.actions.lock().unwrap().push(StreamAction::Error);
447 }
448
449 pub fn insert_timeout(&mut self) {
451 self.insert_timeout_with_duration(self.timeout_duration)
452 }
453
454 pub fn insert_timeout_with_duration(&mut self, duration: Duration) {
456 self.actions.lock().unwrap().push(StreamAction::Timeout {
457 deadline: None,
458 duration,
459 });
460 }
461
462 pub fn insert_checkpoint(&mut self, sequence_number: u64) {
464 self.insert_checkpoint_range([sequence_number])
465 }
466
467 pub fn insert_checkpoint_range<I>(&mut self, checkpoint_range: I)
468 where
469 I: IntoIterator<Item = u64>,
470 {
471 let mut actions = self.actions.lock().unwrap();
472 for sequence_number in checkpoint_range {
473 actions.push(StreamAction::Checkpoint(sequence_number));
474 }
475 }
476 }
477
478 #[async_trait]
479 impl CheckpointStreamingClient for MockStreamingClient {
480 async fn connect(&self) -> Result<CheckpointStream> {
481 if self.connection_timeouts_remaining.load(Ordering::Relaxed) > 0 {
482 self.connection_timeouts_remaining
483 .fetch_sub(1, Ordering::Relaxed);
484 tokio::time::sleep(self.timeout_duration).await;
486 return Err(Error::StreamingError(anyhow::anyhow!(
487 "Mock connection timeout"
488 )));
489 }
490 if self.connection_failures_remaining.load(Ordering::Relaxed) > 0 {
491 self.connection_failures_remaining
492 .fetch_sub(1, Ordering::Relaxed);
493 return Err(Error::StreamingError(anyhow::anyhow!(
494 "Mock connection failure"
495 )));
496 }
497 let stream_state = MockStreamState {
498 actions: Arc::clone(&self.actions),
499 };
500 Ok(CheckpointStream {
501 stream: wrap_stream(stream_state, self.statement_timeout),
502 chain_id: Self::mock_chain_id(),
503 })
504 }
505 }
506}