sui_indexer_alt_framework/ingestion/
streaming_client.rs1use std::time::Duration;
5
6use anyhow::Context;
7use anyhow::anyhow;
8use async_trait::async_trait;
9use futures::StreamExt;
10use futures::stream::BoxStream;
11use sui_rpc::headers::X_SUI_CHAIN_ID;
12use sui_rpc::proto::sui::rpc::v2::SubscribeCheckpointsRequest;
13use sui_rpc::proto::sui::rpc::v2::subscription_service_client::SubscriptionServiceClient;
14use sui_types::digests::ChainIdentifier;
15use sui_types::messages_checkpoint::CheckpointDigest;
16use tokio_stream::adapters::Peekable;
17use tonic::Status;
18use tonic::transport::Endpoint;
19use tonic::transport::Uri;
20
21use crate::ingestion::MAX_GRPC_MESSAGE_SIZE_BYTES;
22use crate::ingestion::error::Error;
23use crate::ingestion::error::Result;
24use crate::types::full_checkpoint_content::Checkpoint;
25
26pub struct CheckpointStream {
27 pub stream: Peekable<BoxStream<'static, Result<Checkpoint>>>,
28 pub chain_id: ChainIdentifier,
29}
30
31#[async_trait]
33pub trait CheckpointStreamingClient {
34 async fn connect(&self) -> Result<CheckpointStream>;
36
37 async fn latest_checkpoint_number(&self) -> Result<u64> {
39 let mut stream = self.connect().await?;
40
41 match stream.stream.next().await {
42 Some(Ok(checkpoint)) => Ok(checkpoint.summary.sequence_number),
43 Some(Err(e)) => Err(e),
44 None => Err(Error::StreamingError(anyhow!("Stream ended unexpectedly"))),
45 }
46 }
47}
48
49#[derive(clap::Args, Clone, Debug, Default)]
50pub struct StreamingClientArgs {
51 #[clap(long, env)]
53 pub streaming_url: Option<Uri>,
54}
55
56#[derive(Clone)]
58pub struct GrpcStreamingClient {
59 uri: Uri,
60 connection_timeout: Duration,
61 statement_timeout: Duration,
62}
63
64impl GrpcStreamingClient {
65 pub fn new(uri: Uri, connection_timeout: Duration, statement_timeout: Duration) -> Self {
66 Self {
67 uri,
68 connection_timeout,
69 statement_timeout,
70 }
71 }
72}
73
74#[async_trait]
75impl CheckpointStreamingClient for GrpcStreamingClient {
76 async fn connect(&self) -> Result<CheckpointStream> {
77 let endpoint = Endpoint::from(self.uri.clone())
78 .connect_timeout(self.connection_timeout)
79 .timeout(self.connection_timeout);
80
81 let mut client = SubscriptionServiceClient::connect(endpoint)
82 .await
83 .map_err(|err| Error::RpcClientError(Status::from_error(err.into())))?
84 .max_decoding_message_size(MAX_GRPC_MESSAGE_SIZE_BYTES);
85
86 let mut request = SubscribeCheckpointsRequest::default();
87 request.read_mask = Some(Checkpoint::proto_field_mask());
88
89 let response = client
90 .subscribe_checkpoints(request)
91 .await
92 .map_err(Error::RpcClientError)?;
93
94 let chain_id_value = response.metadata().get(X_SUI_CHAIN_ID).ok_or_else(|| {
95 Error::StreamingError(anyhow!("Chain ID not found in response metadata"))
96 })?;
97 let chain_id: ChainIdentifier = chain_id_value
98 .to_str()
99 .map_err(|e| Error::StreamingError(anyhow!("Chain ID is not valid ASCII: {e}")))?
100 .parse::<CheckpointDigest>()
101 .map_err(|e| Error::StreamingError(anyhow!("Chain ID parse error: {e}")))?
102 .into();
103
104 let stream = response
105 .into_inner()
106 .map(|result| async move {
107 match result {
108 Ok(response) => {
109 let checkpoint = response
110 .checkpoint
111 .context("Checkpoint data missing in response")
112 .map_err(Error::StreamingError)?;
113 tokio::task::spawn_blocking(move || {
118 Checkpoint::try_from(&checkpoint).context("Failed to parse checkpoint")
119 })
120 .await
121 .map_err(|e| Error::StreamingError(anyhow!("decode task panicked: {e}")))?
122 .map_err(Error::StreamingError)
123 }
124 Err(e) => Err(Error::RpcClientError(e)),
125 }
126 })
127 .buffered(4);
128 let stream = wrap_stream(stream, self.statement_timeout);
129
130 Ok(CheckpointStream { stream, chain_id })
131 }
132}
133
134fn wrap_stream(
137 stream: impl futures::Stream<Item = Result<Checkpoint>> + Send + 'static,
138 statement_timeout: Duration,
139) -> Peekable<BoxStream<'static, Result<Checkpoint>>> {
140 let stream = tokio_stream::StreamExt::timeout(stream, statement_timeout)
141 .map(move |result| match result {
142 Err(_elapsed) => Err(Error::StreamingError(anyhow!(
143 "Statement timeout after {statement_timeout:?}"
144 ))),
145 Ok(result) => result,
146 })
147 .boxed();
148 tokio_stream::StreamExt::peekable(stream)
149}
150
151#[cfg(test)]
152mod tests {
153 use std::net::SocketAddr;
154 use std::time::Duration;
155
156 use sui_rpc::proto::sui::rpc::v2::SubscribeCheckpointsRequest;
157 use sui_rpc::proto::sui::rpc::v2::SubscribeCheckpointsResponse;
158 use sui_rpc::proto::sui::rpc::v2::subscription_service_server::SubscriptionService;
159 use sui_rpc::proto::sui::rpc::v2::subscription_service_server::SubscriptionServiceServer;
160 use tonic::transport::Server;
161
162 use super::*;
163
164 struct HangingSubscriptionService;
167
168 #[tonic::async_trait]
169 impl SubscriptionService for HangingSubscriptionService {
170 async fn subscribe_checkpoints(
171 &self,
172 _request: tonic::Request<SubscribeCheckpointsRequest>,
173 ) -> std::result::Result<
174 tonic::Response<
175 BoxStream<'static, std::result::Result<SubscribeCheckpointsResponse, Status>>,
176 >,
177 Status,
178 > {
179 futures::future::pending().await
180 }
181 }
182
183 #[tokio::test]
184 async fn subscribe_checkpoints_times_out_on_stalled_server() {
185 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
186 let addr: SocketAddr = listener.local_addr().unwrap();
187
188 tokio::spawn(async move {
189 let incoming = tokio_stream::wrappers::TcpListenerStream::new(listener);
190 Server::builder()
191 .add_service(SubscriptionServiceServer::new(HangingSubscriptionService))
192 .serve_with_incoming(incoming)
193 .await
194 .unwrap();
195 });
196
197 let timeout = Duration::from_millis(200);
198 let uri: Uri = format!("http://{addr}").parse().unwrap();
199 let client = GrpcStreamingClient::new(uri, timeout, timeout);
200
201 let start = std::time::Instant::now();
202 let result = client.connect().await;
203 let elapsed = start.elapsed();
204
205 assert!(result.is_err(), "expected timeout error");
206 assert!(
207 elapsed < Duration::from_secs(5),
208 "connect() took {elapsed:?}, should have timed out in ~200ms"
209 );
210 }
211}
212
213#[cfg(test)]
214pub mod test_utils {
215 use std::pin::Pin;
216 use std::sync::Arc;
217 use std::sync::Mutex;
218 use std::sync::atomic::AtomicUsize;
219 use std::sync::atomic::Ordering;
220 use std::time::Duration;
221 use std::time::Instant;
222
223 use futures::Stream;
224
225 use crate::types::test_checkpoint_data_builder::TestCheckpointBuilder;
226
227 use super::*;
228
229 enum StreamAction {
230 Checkpoint(u64),
231 Error,
232 Timeout {
233 deadline: Option<Instant>,
234 duration: Duration,
235 },
236 }
237
238 struct MockStreamState {
239 actions: Arc<Mutex<Vec<StreamAction>>>,
240 }
241
242 impl Stream for MockStreamState {
243 type Item = Result<Checkpoint>;
244
245 fn poll_next(
246 self: Pin<&mut Self>,
247 _cx: &mut std::task::Context<'_>,
248 ) -> std::task::Poll<Option<Self::Item>> {
249 let mut actions = self.actions.lock().unwrap();
250 if actions.is_empty() {
251 return std::task::Poll::Ready(None);
252 }
253
254 match &actions[0] {
255 StreamAction::Checkpoint(seq) => {
256 let seq = *seq;
257 actions.remove(0);
258 let mut builder = TestCheckpointBuilder::new(seq);
259 std::task::Poll::Ready(Some(Ok(builder.build_checkpoint())))
260 }
261 StreamAction::Error => {
262 actions.remove(0);
263 std::task::Poll::Ready(Some(Err(Error::StreamingError(anyhow::anyhow!(
264 "Mock streaming error"
265 )))))
266 }
267 StreamAction::Timeout { deadline, duration } => match deadline {
268 None => {
269 let deadline = Instant::now() + *duration;
270 actions[0] = StreamAction::Timeout {
271 deadline: Some(deadline),
272 duration: *duration,
273 };
274 std::task::Poll::Pending
275 }
276 Some(deadline_instant) => {
277 if Instant::now() >= *deadline_instant {
278 actions.remove(0);
279 drop(actions);
280 self.poll_next(_cx)
281 } else {
282 std::task::Poll::Pending
283 }
284 }
285 },
286 }
287 }
288 }
289
290 pub struct MockStreamingClient {
292 actions: Arc<Mutex<Vec<StreamAction>>>,
293 connection_failures_remaining: AtomicUsize,
294 connection_timeouts_remaining: AtomicUsize,
295 timeout_duration: Duration,
297 statement_timeout: Duration,
299 }
300
301 impl MockStreamingClient {
302 pub fn mock_chain_id() -> ChainIdentifier {
303 CheckpointDigest::new([1; 32]).into()
304 }
305
306 pub fn new<I>(checkpoint_range: I, timeout_duration: Option<Duration>) -> Self
307 where
308 I: IntoIterator<Item = u64>,
309 {
310 let timeout_duration = timeout_duration.unwrap_or(Duration::from_secs(5));
311 Self {
312 actions: Arc::new(Mutex::new(
313 checkpoint_range
314 .into_iter()
315 .map(StreamAction::Checkpoint)
316 .collect(),
317 )),
318 connection_failures_remaining: AtomicUsize::new(0),
319 connection_timeouts_remaining: AtomicUsize::new(0),
320 statement_timeout: timeout_duration / 2,
321 timeout_duration,
322 }
323 }
324
325 pub fn fail_connection_times(mut self, times: usize) -> Self {
327 self.connection_failures_remaining = AtomicUsize::new(times);
328 self
329 }
330
331 pub fn fail_connection_with_timeout(mut self, times: usize) -> Self {
333 self.connection_timeouts_remaining = AtomicUsize::new(times);
334 self
335 }
336
337 pub fn insert_error(&mut self) {
339 self.actions.lock().unwrap().push(StreamAction::Error);
340 }
341
342 pub fn insert_timeout(&mut self) {
344 self.insert_timeout_with_duration(self.timeout_duration)
345 }
346
347 pub fn insert_timeout_with_duration(&mut self, duration: Duration) {
349 self.actions.lock().unwrap().push(StreamAction::Timeout {
350 deadline: None,
351 duration,
352 });
353 }
354
355 pub fn insert_checkpoint(&mut self, sequence_number: u64) {
357 self.insert_checkpoint_range([sequence_number])
358 }
359
360 pub fn insert_checkpoint_range<I>(&mut self, checkpoint_range: I)
361 where
362 I: IntoIterator<Item = u64>,
363 {
364 let mut actions = self.actions.lock().unwrap();
365 for sequence_number in checkpoint_range {
366 actions.push(StreamAction::Checkpoint(sequence_number));
367 }
368 }
369 }
370
371 #[async_trait]
372 impl CheckpointStreamingClient for MockStreamingClient {
373 async fn connect(&self) -> Result<CheckpointStream> {
374 if self.connection_timeouts_remaining.load(Ordering::Relaxed) > 0 {
375 self.connection_timeouts_remaining
376 .fetch_sub(1, Ordering::Relaxed);
377 tokio::time::sleep(self.timeout_duration).await;
379 return Err(Error::StreamingError(anyhow::anyhow!(
380 "Mock connection timeout"
381 )));
382 }
383 if self.connection_failures_remaining.load(Ordering::Relaxed) > 0 {
384 self.connection_failures_remaining
385 .fetch_sub(1, Ordering::Relaxed);
386 return Err(Error::StreamingError(anyhow::anyhow!(
387 "Mock connection failure"
388 )));
389 }
390 let stream_state = MockStreamState {
391 actions: Arc::clone(&self.actions),
392 };
393 Ok(CheckpointStream {
394 stream: wrap_stream(stream_state, self.statement_timeout),
395 chain_id: Self::mock_chain_id(),
396 })
397 }
398 }
399}