Skip to main content

sui_rpc/client/ledger_streams/adapter/
progress.rs

1use prost::bytes::Bytes;
2use tonic::Status;
3
4use super::super::super::Result;
5use super::CHECKPOINT_CURSOR_OVERFLOW;
6use super::ListScanDirection;
7
8/// A resumable cursor plus known checkpoint coverage.
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub(in crate::client::ledger_streams) struct Progress<C> {
11    pub(in crate::client::ledger_streams) cursor: C,
12    /// Always equals a dense checkpoint cursor; present for opaque cursors when reported.
13    pub(in crate::client::ledger_streams) checkpoint: Option<u64>,
14}
15
16/// Cursor-specific coverage and replay behavior.
17///
18/// Dense checkpoint cursors provide their own coverage and replay whole checkpoints. Opaque
19/// transaction and event cursors rely on reported coverage and replay strictly after the token.
20pub(in crate::client::ledger_streams) trait CursorDomain:
21    Clone + Eq + Send + 'static
22{
23    /// The resumable position a watermark names, or `None` if it does not name one yet.
24    fn position(cursor: &Bytes, checkpoint: Option<u64>) -> Option<Progress<Self>>;
25
26    /// Builds the replay interval strictly after this committed cursor and ending at `upper`.
27    fn gap(&self, committed_checkpoint: u64, upper: GapUpper<'_, Self>) -> Result<RecoveryGap>;
28}
29
30pub(in crate::client::ledger_streams) enum GapUpper<'a, C> {
31    Cursor(&'a C, u64),
32    EndOfCheckpoint(u64),
33}
34
35/// Recovery selected by comparing a subscription's first frame with committed progress.
36pub(in crate::client::ledger_streams) enum Recovery {
37    /// Deliver immediately from the committed position.
38    Live,
39    /// Replay the missing interval while buffering live frames.
40    Replay(RecoveryGap),
41    /// Hide frames until the subscription reaches committed progress.
42    MuteUntilCommitted,
43}
44
45/// Historical List interval between subscription positions.
46pub(in crate::client::ledger_streams) enum RecoveryGap {
47    /// Inclusive start and exclusive end typed range. The replay re-delivers its boundary item.
48    Checkpoints {
49        start_checkpoint: u64,
50        end_checkpoint: u64,
51    },
52    /// Strictly after `after`, ending before a cursor or after an inclusive checkpoint.
53    Cursors { after: Bytes, upper: CursorGapUpper },
54}
55
56pub(in crate::client::ledger_streams) enum CursorGapUpper {
57    Before(Bytes),
58    EndOfCheckpoint(u64),
59}
60
61impl RecoveryGap {
62    pub(in crate::client::ledger_streams) fn replays_boundary_item(&self) -> bool {
63        matches!(self, Self::Checkpoints { .. })
64    }
65}
66
67/// Advancement between ordered frames from one subscription.
68///
69/// Same-cursor coverage growth advances knowledge; only identical progress is a duplicate.
70pub(in crate::client::ledger_streams) enum ProgressAdvance {
71    Unchanged,
72    CheckpointCoverageAdvanced,
73    CursorAdvanced,
74}
75
76impl<C: CursorDomain> Progress<C> {
77    pub(in crate::client::ledger_streams) fn has_checkpoint_coverage(&self) -> bool {
78        self.checkpoint.is_some()
79    }
80
81    pub(in crate::client::ledger_streams) fn same_position(&self, other: &Self) -> bool {
82        self.cursor == other.cursor
83    }
84
85    pub(in crate::client::ledger_streams) fn inherit_checkpoint_coverage(
86        &mut self,
87        previous: &Self,
88    ) {
89        if self.checkpoint.is_none() {
90            self.checkpoint = previous.checkpoint;
91        }
92    }
93
94    pub(in crate::client::ledger_streams) fn classify_consecutive_subscription_progress(
95        &self,
96        next: &Self,
97        item_present: bool,
98    ) -> Result<ProgressAdvance> {
99        let current_checkpoint = self.checkpoint;
100        let next_checkpoint = next.checkpoint;
101        if matches!((current_checkpoint, next_checkpoint), (Some(_), None)) {
102            Err(Status::data_loss(
103                "subscription checkpoint coverage became unavailable",
104            ))
105        } else if matches!(
106            (current_checkpoint, next_checkpoint),
107            (Some(current), Some(next)) if next < current
108        ) {
109            Err(Status::data_loss(
110                "subscription checkpoint coverage regressed",
111            ))
112        } else if self.same_position(next) && item_present {
113            Err(Status::data_loss("subscription item repeated its cursor"))
114        } else if !self.same_position(next) {
115            // Frames from one subscription stream are already in ledger order. Checkpoint
116            // coverage is needed only to compare frames from different subscriptions.
117            Ok(ProgressAdvance::CursorAdvanced)
118        } else if matches!(
119            (current_checkpoint, next_checkpoint),
120            (Some(current), Some(next)) if next > current
121        ) || matches!((current_checkpoint, next_checkpoint), (None, Some(_)))
122        {
123            Ok(ProgressAdvance::CheckpointCoverageAdvanced)
124        } else {
125            Ok(ProgressAdvance::Unchanged)
126        }
127    }
128
129    pub(in crate::client::ledger_streams) fn validate_list_successor(
130        &self,
131        next: &Self,
132        direction: ListScanDirection,
133    ) -> Result<()> {
134        let current_checkpoint = self.checkpoint;
135        let next_checkpoint = next.checkpoint;
136        if matches!((current_checkpoint, next_checkpoint), (Some(_), None)) {
137            return Err(Status::data_loss(
138                "List checkpoint coverage became unavailable",
139            ));
140        }
141        let regressed = match (current_checkpoint, next_checkpoint) {
142            (Some(current), Some(next)) => match direction {
143                ListScanDirection::Ascending => next < current,
144                ListScanDirection::Descending => next > current,
145            },
146            _ => false,
147        };
148        if regressed {
149            Err(Status::data_loss("List checkpoint coverage regressed"))
150        } else {
151            Ok(())
152        }
153    }
154
155    pub(in crate::client::ledger_streams) fn plan_recovery(
156        &self,
157        new: &Self,
158        known_coverage: Option<u64>,
159    ) -> Result<Recovery> {
160        let committed = self.checkpoint.max(known_coverage);
161        if self.same_position(new) {
162            return if matches!(
163                (committed, new.checkpoint),
164                (Some(committed), Some(new_checkpoint)) if new_checkpoint < committed
165            ) {
166                Ok(Recovery::MuteUntilCommitted)
167            } else {
168                Ok(Recovery::Live)
169            };
170        }
171        match (committed, new.checkpoint) {
172            (Some(committed), Some(new_checkpoint)) if new_checkpoint < committed => {
173                Ok(Recovery::MuteUntilCommitted)
174            }
175            (Some(committed), Some(new_checkpoint)) if new_checkpoint > committed => {
176                Ok(Recovery::Replay(self.cursor.gap(
177                    committed,
178                    GapUpper::Cursor(&new.cursor, new_checkpoint),
179                )?))
180            }
181            (Some(committed), Some(_)) => Ok(Recovery::Replay(
182                self.cursor
183                    .gap(committed, GapUpper::EndOfCheckpoint(committed))?,
184            )),
185            _ => Ok(Recovery::MuteUntilCommitted),
186        }
187    }
188}
189
190impl CursorDomain for u64 {
191    fn position(_cursor: &Bytes, checkpoint: Option<u64>) -> Option<Progress<Self>> {
192        checkpoint.map(|checkpoint| Progress {
193            cursor: checkpoint,
194            checkpoint: Some(checkpoint),
195        })
196    }
197
198    fn gap(&self, committed_checkpoint: u64, upper: GapUpper<'_, Self>) -> Result<RecoveryGap> {
199        let upper_checkpoint = match upper {
200            GapUpper::Cursor(_, checkpoint) | GapUpper::EndOfCheckpoint(checkpoint) => checkpoint,
201        };
202        let start_checkpoint = committed_checkpoint
203            .checked_add(1)
204            .ok_or_else(|| Status::out_of_range(CHECKPOINT_CURSOR_OVERFLOW))?;
205        let end_checkpoint = upper_checkpoint
206            .checked_add(1)
207            .ok_or_else(|| Status::out_of_range(CHECKPOINT_CURSOR_OVERFLOW))?;
208        Ok(RecoveryGap::Checkpoints {
209            start_checkpoint,
210            end_checkpoint,
211        })
212    }
213}
214
215impl CursorDomain for Bytes {
216    fn position(cursor: &Bytes, checkpoint: Option<u64>) -> Option<Progress<Self>> {
217        Some(Progress {
218            cursor: cursor.clone(),
219            checkpoint,
220        })
221    }
222
223    fn gap(&self, _committed_checkpoint: u64, upper: GapUpper<'_, Self>) -> Result<RecoveryGap> {
224        let upper = match upper {
225            GapUpper::Cursor(cursor, _) => CursorGapUpper::Before(cursor.clone()),
226            GapUpper::EndOfCheckpoint(checkpoint) => CursorGapUpper::EndOfCheckpoint(checkpoint),
227        };
228        Ok(RecoveryGap::Cursors {
229            after: self.clone(),
230            upper,
231        })
232    }
233}