Skip to main content

sui_rpc/client/ledger_streams/
list.rs

1use std::pin::Pin;
2use std::time::Duration;
3
4use futures::StreamExt;
5use prost::bytes::Bytes;
6use tonic::Request;
7use tonic::Status;
8use tonic::codegen::BoxStream;
9
10use super::super::Client;
11use super::super::Result;
12use super::adapter::CursorDomain;
13use super::adapter::CursorGapUpper;
14use super::adapter::ListScanDirection;
15use super::adapter::Progress;
16use super::adapter::RecoveryGap;
17use super::adapter::RpcFuture;
18use super::adapter::SubscriptionAdapter;
19use super::observability::LedgerStreamEvent;
20use super::observability::LedgerStreamObservability;
21use super::observability::LedgerStreamOperation;
22use super::observability::LedgerStreamStage;
23use super::retry::FailurePhase;
24use super::retry::RetryState;
25use super::retry::backoff_delay;
26use super::types::LedgerStreamConfig;
27use crate::proto::sui::rpc::v2::QueryEnd;
28use crate::proto::sui::rpc::v2::QueryEndReason;
29use crate::proto::sui::rpc::v2::Watermark;
30
31/// Permitted `CheckpointBound` and `CursorBound` endings, with or without prior progress.
32#[derive(Clone, Copy)]
33pub(super) struct ExpectedEnd {
34    checkpoint_after_progress: bool,
35    cursor_after_progress: bool,
36    checkpoint_without_prior_progress: bool,
37    cursor_without_prior_progress: bool,
38}
39
40impl ExpectedEnd {
41    fn from_bounds(checkpoint: bool, cursor: bool) -> Self {
42        Self {
43            checkpoint_after_progress: checkpoint,
44            cursor_after_progress: cursor,
45            checkpoint_without_prior_progress: checkpoint,
46            cursor_without_prior_progress: cursor,
47        }
48    }
49
50    fn accepts(
51        self,
52        reason: QueryEndReason,
53        received_prior_frame: bool,
54        request_started_from_resume_bound: bool,
55    ) -> bool {
56        let (checkpoint, cursor) = if received_prior_frame {
57            (self.checkpoint_after_progress, self.cursor_after_progress)
58        } else {
59            // A resume frontier may immediately return CursorBound when nothing follows it.
60            (
61                self.checkpoint_without_prior_progress,
62                self.cursor_without_prior_progress || request_started_from_resume_bound,
63            )
64        };
65        (checkpoint && reason == QueryEndReason::CheckpointBound)
66            || (cursor && reason == QueryEndReason::CursorBound)
67    }
68}
69
70fn validate_query_end_item(reason: QueryEndReason, item_present: bool) -> Result<()> {
71    if reason == QueryEndReason::ItemLimit && !item_present {
72        Err(Status::data_loss("ItemLimit QueryEnd is missing its item"))
73    } else if reason != QueryEndReason::ItemLimit && item_present {
74        Err(Status::data_loss(
75            "non-ItemLimit QueryEnd unexpectedly contains an item",
76        ))
77    } else {
78        Ok(())
79    }
80}
81
82fn to_exclusive_end_bound(checkpoint_height: u64) -> Result<u64> {
83    checkpoint_height.checked_add(1).ok_or_else(|| {
84        Status::out_of_range("checkpoint height cannot be converted to an exclusive end bound")
85    })
86}
87
88/// Sets the page size on an internal request; caller-built `list_*` requests bypass this path.
89fn apply_internal_list_page_limit<A: SubscriptionAdapter>(
90    request: &mut A::ListRequest,
91    list_page_limit: Option<u32>,
92) {
93    A::options_mut(request).limit = list_page_limit;
94}
95
96pub(super) fn build_initial_list_request<A: SubscriptionAdapter>(
97    template: &A::ListRequest,
98    checkpoint_height: u64,
99    list_page_limit: Option<u32>,
100) -> Result<(A::ListRequest, ExpectedEnd)> {
101    let mut request = template.clone();
102    apply_internal_list_page_limit::<A>(&mut request, list_page_limit);
103    A::set_end_checkpoint(
104        &mut request,
105        Some(to_exclusive_end_bound(checkpoint_height)?),
106    );
107    Ok((request, ExpectedEnd::from_bounds(true, false)))
108}
109
110/// Queries a single tip checkpoint to establish baseline watermark progress (used when starting in
111/// Poll mode, or recovering baseline progress after an initial Subscribe connection failure).
112pub(super) fn build_live_tip_baseline_list_request<A: SubscriptionAdapter>(
113    template: &A::ListRequest,
114    checkpoint_height: u64,
115    list_page_limit: Option<u32>,
116) -> Result<(A::ListRequest, ExpectedEnd)> {
117    let mut request = template.clone();
118    apply_internal_list_page_limit::<A>(&mut request, list_page_limit);
119    A::set_start_checkpoint(&mut request, Some(checkpoint_height));
120    A::set_end_checkpoint(
121        &mut request,
122        Some(to_exclusive_end_bound(checkpoint_height)?),
123    );
124    let options = A::options_mut(&mut request);
125    options.after = None;
126    options.before = None;
127    Ok((request, ExpectedEnd::from_bounds(true, false)))
128}
129
130pub(super) fn build_polling_list_request<A: SubscriptionAdapter>(
131    template: &A::ListRequest,
132    committed_progress: &Progress<A::Cursor>,
133    checkpoint_height: u64,
134    list_page_limit: Option<u32>,
135) -> Result<(A::ListRequest, ExpectedEnd)> {
136    let mut request = template.clone();
137    apply_internal_list_page_limit::<A>(&mut request, list_page_limit);
138    A::set_ascending_resume(&mut request, committed_progress)?;
139    A::options_mut(&mut request).before = None;
140    A::set_end_checkpoint(
141        &mut request,
142        Some(to_exclusive_end_bound(checkpoint_height)?),
143    );
144    Ok((request, ExpectedEnd::from_bounds(true, false)))
145}
146
147pub(super) fn build_recovery_list_request<A: SubscriptionAdapter>(
148    template: A::ListRequest,
149    gap: &RecoveryGap,
150    list_page_limit: Option<u32>,
151) -> Result<(A::ListRequest, ExpectedEnd)> {
152    let mut request = template;
153    apply_internal_list_page_limit::<A>(&mut request, list_page_limit);
154    match gap {
155        RecoveryGap::Checkpoints {
156            start_checkpoint,
157            end_checkpoint,
158        } => {
159            A::set_start_checkpoint(&mut request, Some(*start_checkpoint));
160            A::set_end_checkpoint(&mut request, Some(*end_checkpoint));
161            let options = A::options_mut(&mut request);
162            options.after = None;
163            options.before = None;
164            Ok((request, ExpectedEnd::from_bounds(true, false)))
165        }
166        RecoveryGap::Cursors {
167            after,
168            upper: CursorGapUpper::Before(before),
169        } => {
170            let options = A::options_mut(&mut request);
171            options.after = Some(after.clone());
172            options.before = Some(before.clone());
173            Ok((request, ExpectedEnd::from_bounds(false, true)))
174        }
175        RecoveryGap::Cursors {
176            after,
177            upper: CursorGapUpper::EndOfCheckpoint(checkpoint),
178        } => {
179            let options = A::options_mut(&mut request);
180            options.after = Some(after.clone());
181            options.before = None;
182            A::set_end_checkpoint(&mut request, Some(to_exclusive_end_bound(*checkpoint)?));
183            Ok((request, ExpectedEnd::from_bounds(true, false)))
184        }
185    }
186}
187
188pub(super) fn determine_expected_end<A: SubscriptionAdapter>(
189    request: &A::ListRequest,
190    direction: ListScanDirection,
191) -> ExpectedEnd {
192    let options = A::options(request);
193    let checkpoint_without_prior_progress = A::start_checkpoint(request).is_some()
194        || A::end_checkpoint(request).is_some()
195        || direction == ListScanDirection::Descending;
196    let cursor_without_prior_progress =
197        options.is_some_and(|options| options.after.is_some() || options.before.is_some());
198    let (checkpoint_after_progress, cursor_after_progress) = match direction {
199        ListScanDirection::Ascending => (
200            A::end_checkpoint(request).is_some(),
201            options.is_some_and(|options| options.before.is_some()),
202        ),
203        ListScanDirection::Descending => {
204            (true, options.is_some_and(|options| options.after.is_some()))
205        }
206    };
207    ExpectedEnd {
208        checkpoint_after_progress,
209        cursor_after_progress,
210        checkpoint_without_prior_progress,
211        cursor_without_prior_progress,
212    }
213}
214
215fn set_resume_bound<A: SubscriptionAdapter>(
216    direction: ListScanDirection,
217    request: &mut A::ListRequest,
218    cursor: Bytes,
219) {
220    direction.set_resume_bound(A::options_mut(request), cursor);
221}
222
223enum RpcState<A: SubscriptionAdapter> {
224    Idle,
225    Dispatch {
226        future: RpcFuture<A::ListResponse>,
227        started_at: Option<tokio::time::Instant>,
228    },
229    Stream(BoxStream<A::ListResponse>),
230    Sleep(Pin<Box<tokio::time::Sleep>>),
231}
232
233/// Raw I/O event produced by [`ListMachine::poll_rpc`].
234pub(super) enum RpcEvent<R> {
235    Frame(R),
236    Status(Status, FailurePhase),
237    Eof,
238    Wake,
239}
240
241/// Driver action returned by [`ListMachine::process_event`].
242pub(super) enum ListAction<R, P> {
243    Frame {
244        response: R,
245        progress: Option<P>,
246        complete: bool,
247    },
248    Continue,
249    Terminal(Status),
250}
251
252/// How an ascending scan handles `LedgerTip` before its requested bound.
253#[derive(Clone, Copy)]
254pub(super) enum LedgerTipPolicy {
255    /// Complete at the indexed tip.
256    Complete,
257    /// Retry from the served frontier until the fixed bound is indexed.
258    WaitForExpectedBound,
259}
260
261pub(super) struct ListMachine<A: SubscriptionAdapter> {
262    client: Client,
263    payload: A::ListRequest,
264    expected_end: ExpectedEnd,
265    direction: ListScanDirection,
266    ledger_tip_policy: LedgerTipPolicy,
267    rpc_state: RpcState<A>,
268    /// Snapshot of the starting bound sent in the active request, used to detect bound violations.
269    request_start_resume_bound: Option<Bytes>,
270    /// Most recent raw wire cursor received, used to populate `after`/`before` when reconnecting.
271    latest_cursor: Option<Bytes>,
272    /// Most recent progress metadata, used to validate monotonic checkpoint coverage.
273    latest_progress: Option<Progress<A::Cursor>>,
274    polling_attempt: u32,
275    received_frame_in_request: bool,
276    request_has_checkpoint_coverage: bool,
277    received_any_frame: bool,
278    retry: RetryState,
279    pub(super) observability: LedgerStreamObservability,
280    stage: LedgerStreamStage,
281}
282
283impl<A: SubscriptionAdapter> ListMachine<A> {
284    pub(super) fn new(
285        client: Client,
286        payload: A::ListRequest,
287        expected_end: ExpectedEnd,
288        direction: ListScanDirection,
289        ledger_tip_policy: LedgerTipPolicy,
290        observability: LedgerStreamObservability,
291        stage: LedgerStreamStage,
292    ) -> Self {
293        let mut machine = Self {
294            client,
295            payload,
296            expected_end,
297            direction,
298            ledger_tip_policy,
299            rpc_state: RpcState::Idle,
300            request_start_resume_bound: None,
301            latest_cursor: None,
302            latest_progress: None,
303            polling_attempt: 0,
304            received_frame_in_request: false,
305            request_has_checkpoint_coverage: false,
306            received_any_frame: false,
307            retry: RetryState::new(A::FAMILY, LedgerStreamOperation::List),
308            observability,
309            stage,
310        };
311        machine.start_dispatch();
312        machine
313    }
314
315    fn start_dispatch(&mut self) {
316        self.received_frame_in_request = false;
317        self.request_has_checkpoint_coverage = false;
318        self.request_start_resume_bound = A::options(&self.payload)
319            .and_then(|options| self.direction.resume_bound(options).clone());
320        let request = Request::new(self.payload.clone());
321        self.rpc_state = RpcState::Dispatch {
322            future: A::dispatch_list(self.client.clone(), request),
323            started_at: None,
324        };
325    }
326
327    fn sleep(&mut self, delay: Duration) {
328        self.rpc_state = RpcState::Sleep(Box::pin(tokio::time::sleep(delay)));
329    }
330
331    /// Dropping this future preserves `self.rpc_state`, so the next call resumes rather than
332    /// restarts in-flight work.
333    pub(super) async fn poll_rpc(&mut self) -> RpcEvent<A::ListResponse> {
334        loop {
335            match &mut self.rpc_state {
336                RpcState::Idle => {
337                    return RpcEvent::Status(
338                        Status::internal("idle RPC state polled"),
339                        FailurePhase::Dispatch,
340                    );
341                }
342                RpcState::Dispatch { future, started_at } => {
343                    // The timer starts on the first poll and survives cancellation by gap buffering.
344                    if started_at.is_none() {
345                        *started_at = self.observability.start_timer();
346                    }
347                    let rpc_started_at = started_at.as_ref().cloned();
348                    let result = future.await;
349                    self.observability.emit_rpc_response(
350                        rpc_started_at,
351                        A::FAMILY,
352                        LedgerStreamOperation::List,
353                        self.stage,
354                        &result,
355                    );
356                    match result {
357                        Ok(stream) => self.rpc_state = RpcState::Stream(stream),
358                        Err(status) => {
359                            self.rpc_state = RpcState::Idle;
360                            return RpcEvent::Status(status, FailurePhase::Dispatch);
361                        }
362                    }
363                }
364                RpcState::Stream(stream) => match stream.next().await {
365                    Some(Ok(response)) => return RpcEvent::Frame(response),
366                    Some(Err(status)) => {
367                        self.rpc_state = RpcState::Idle;
368                        return RpcEvent::Status(status, FailurePhase::Body);
369                    }
370                    None => {
371                        self.rpc_state = RpcState::Idle;
372                        return RpcEvent::Eof;
373                    }
374                },
375                RpcState::Sleep(sleep) => {
376                    sleep.as_mut().await;
377                    self.rpc_state = RpcState::Idle;
378                    return RpcEvent::Wake;
379                }
380            }
381        }
382    }
383
384    /// Advances validation and pagination state with one RPC event.
385    pub(super) fn process_event(
386        &mut self,
387        event: RpcEvent<A::ListResponse>,
388        config: &LedgerStreamConfig,
389    ) -> ListAction<A::ListResponse, Progress<A::Cursor>> {
390        match event {
391            RpcEvent::Wake => {
392                self.start_dispatch();
393                ListAction::Continue
394            }
395            // Successful List streams end with QueryEnd; bare EOF has no completion boundary.
396            RpcEvent::Eof => ListAction::Terminal(Status::data_loss(
397                "List stream ended before its QueryEnd frame",
398            )),
399            RpcEvent::Status(status, phase) => match self.process_status(status, phase, config) {
400                Ok(()) => ListAction::Continue,
401                Err(status) => ListAction::Terminal(status),
402            },
403            RpcEvent::Frame(response) => {
404                let (item_present, watermark, end) = A::extract_metadata(&response);
405                match self.process_frame_metadata(item_present, watermark, end, config) {
406                    Ok((progress, complete)) => ListAction::Frame {
407                        response,
408                        progress,
409                        complete,
410                    },
411                    Err(status) => ListAction::Terminal(status),
412                }
413            }
414        }
415    }
416
417    fn process_status(
418        &mut self,
419        status: Status,
420        phase: FailurePhase,
421        config: &LedgerStreamConfig,
422    ) -> Result<()> {
423        if let Some(cursor) = self.latest_cursor.clone() {
424            set_resume_bound::<A>(self.direction, &mut self.payload, cursor);
425        }
426        if let Some(delay) =
427            self.retry
428                .retry_delay(&status, phase, config, &self.observability, self.stage)
429        {
430            self.sleep(delay);
431            Ok(())
432        } else {
433            Err(status)
434        }
435    }
436
437    fn process_frame_metadata(
438        &mut self,
439        item_present: bool,
440        watermark: Option<&Watermark>,
441        end: Option<&QueryEnd>,
442        config: &LedgerStreamConfig,
443    ) -> Result<(Option<Progress<A::Cursor>>, bool)> {
444        let watermark =
445            watermark.ok_or_else(|| Status::data_loss("List frame is missing its watermark"))?;
446        let cursor = watermark
447            .cursor
448            .as_ref()
449            .cloned()
450            .ok_or_else(|| Status::data_loss("List watermark is missing its cursor"))?;
451
452        let checkpoint = watermark.checkpoint;
453        if checkpoint.is_none() && self.request_has_checkpoint_coverage {
454            return Err(Status::data_loss(
455                "List checkpoint coverage became unavailable",
456            ));
457        }
458        if checkpoint.is_some() {
459            self.request_has_checkpoint_coverage = true;
460        }
461        let received_prior_frame_in_request = self.received_frame_in_request;
462        let received_prior_frame_ever = self.received_any_frame;
463        if item_present
464            && end.is_none()
465            && !received_prior_frame_in_request
466            && self.request_start_resume_bound.as_ref() == Some(&cursor)
467        {
468            return Err(Status::data_loss(
469                "List item cursor equals its exclusive request resume bound",
470            ));
471        }
472        let mut frame_progress = A::Cursor::position(&cursor, checkpoint);
473        if frame_progress.is_none() && item_present {
474            return Err(Status::data_loss(
475                "List item watermark names no resumable position",
476            ));
477        }
478        if let (Some(previous), Some(next)) = (&self.latest_progress, &mut frame_progress) {
479            next.inherit_checkpoint_coverage(previous);
480            previous.validate_list_successor(next, self.direction)?;
481        }
482        self.received_frame_in_request = true;
483        self.received_any_frame = true;
484        let cursor_changed = self.latest_cursor.as_ref() != Some(&cursor);
485        if item_present && !cursor_changed {
486            return Err(Status::data_loss("List item repeated its cursor"));
487        }
488        if cursor_changed {
489            self.latest_cursor = Some(cursor.clone());
490            self.polling_attempt = 0;
491            self.retry.reset(&self.observability);
492        }
493        if let Some(progress) = &frame_progress {
494            self.latest_progress = Some(progress.clone());
495        }
496
497        let complete = match end {
498            None => false,
499            Some(end) => {
500                let reason = end
501                    .reason
502                    .and_then(|reason| QueryEndReason::try_from(reason).ok())
503                    .filter(|reason| *reason != QueryEndReason::Unknown)
504                    .ok_or_else(|| {
505                        Status::data_loss("List QueryEnd has a missing or unknown reason")
506                    })?;
507                validate_query_end_item(reason, item_present)?;
508                if reason == QueryEndReason::CheckpointBound {
509                    A::validate_checkpoint_bound(&self.payload, self.direction, checkpoint)?;
510                }
511
512                match reason {
513                    QueryEndReason::ItemLimit => {
514                        if self.request_start_resume_bound.as_ref() == Some(&cursor) {
515                            return Err(Status::data_loss(
516                                "ItemLimit QueryEnd did not advance its cursor",
517                            ));
518                        }
519                        set_resume_bound::<A>(self.direction, &mut self.payload, cursor.clone());
520                        self.start_dispatch();
521                        false
522                    }
523                    QueryEndReason::ScanLimit => {
524                        if self.request_start_resume_bound.as_ref() == Some(&cursor) {
525                            return Err(Status::data_loss(
526                                "ScanLimit QueryEnd did not advance its cursor",
527                            ));
528                        }
529                        set_resume_bound::<A>(self.direction, &mut self.payload, cursor.clone());
530                        self.start_dispatch();
531                        false
532                    }
533                    QueryEndReason::CheckpointBound | QueryEndReason::CursorBound => {
534                        if !self.expected_end.accepts(
535                            reason,
536                            received_prior_frame_in_request,
537                            self.request_start_resume_bound.is_some(),
538                        ) {
539                            return Err(Status::data_loss("List ended at an unexpected bound"));
540                        }
541                        self.rpc_state = RpcState::Idle;
542                        true
543                    }
544                    // A descending interval entirely beyond the indexed tip ends at LedgerTip.
545                    QueryEndReason::LedgerTip
546                        if self.direction == ListScanDirection::Descending
547                            && !received_prior_frame_ever =>
548                    {
549                        self.rpc_state = RpcState::Idle;
550                        true
551                    }
552                    // After progress, a descending scan cannot legitimately reach LedgerTip.
553                    QueryEndReason::LedgerTip
554                        if self.direction == ListScanDirection::Descending =>
555                    {
556                        return Err(Status::data_loss(
557                            "descending List reached LedgerTip after prior scan progress",
558                        ));
559                    }
560                    QueryEndReason::LedgerTip
561                        if matches!(self.ledger_tip_policy, LedgerTipPolicy::Complete) =>
562                    {
563                        self.rpc_state = RpcState::Idle;
564                        true
565                    }
566                    QueryEndReason::LedgerTip => {
567                        set_resume_bound::<A>(self.direction, &mut self.payload, cursor.clone());
568                        let delay = backoff_delay(config, self.polling_attempt);
569                        self.polling_attempt = self.polling_attempt.saturating_add(1);
570                        self.sleep(delay);
571                        false
572                    }
573                    QueryEndReason::Unknown => {
574                        return Err(Status::data_loss("List QueryEnd has an unknown reason"));
575                    }
576                }
577            }
578        };
579
580        Ok((frame_progress, complete))
581    }
582}
583
584/// Drives a finite List operation by resuming the request cursor and yielding whole responses.
585pub(super) struct ListDriver<A: SubscriptionAdapter> {
586    machine: Option<ListMachine<A>>,
587    config: LedgerStreamConfig,
588    observability: LedgerStreamObservability,
589    terminal: Option<Status>,
590    done: bool,
591}
592
593impl<A: SubscriptionAdapter> ListDriver<A> {
594    pub(super) fn new(client: Client, request: A::ListRequest, config: LedgerStreamConfig) -> Self {
595        let observability = LedgerStreamObservability::new(config.observer());
596        let payload = request;
597        let direction = ListScanDirection::from_request::<A>(&payload);
598        let terminal = direction.as_ref().err().cloned();
599        let machine = direction.ok().map(|direction| {
600            let expected_end = determine_expected_end::<A>(&payload, direction);
601            ListMachine::new(
602                client,
603                payload,
604                expected_end,
605                direction,
606                LedgerTipPolicy::Complete,
607                observability.clone(),
608                LedgerStreamStage::List,
609            )
610        });
611        Self {
612            machine,
613            config,
614            observability,
615            terminal,
616            done: false,
617        }
618    }
619
620    pub(super) async fn next(&mut self) -> Option<Result<A::ListResponse>> {
621        if self.done {
622            return None;
623        }
624        if let Some(status) = self.terminal.take() {
625            return Some(self.finish_terminal(status));
626        }
627
628        loop {
629            let machine = self.machine.as_mut()?;
630            let event = machine.poll_rpc().await;
631            match machine.process_event(event, &self.config) {
632                ListAction::Frame {
633                    response, complete, ..
634                } => {
635                    if complete {
636                        self.done = true;
637                        self.machine = None;
638                    }
639                    return Some(Ok(response));
640                }
641                ListAction::Continue => {}
642                ListAction::Terminal(status) => return Some(self.finish_terminal(status)),
643            }
644        }
645    }
646
647    fn finish_terminal(&mut self, status: Status) -> Result<A::ListResponse> {
648        self.observability
649            .emit(|| LedgerStreamEvent::TerminalError {
650                family: A::FAMILY,
651                status: status.clone(),
652            });
653        self.done = true;
654        self.machine = None;
655        Err(status)
656    }
657}