Skip to main content

cloud_sdk/pagination/
offset.rs

1use crate::rate_limit::RateLimit;
2
3use super::{PaginationBudget, PaginationError, PaginationProgress, SnapshotId};
4
5/// Validated metadata from one offset-based response.
6#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7pub struct OffsetPageMetadata {
8    offset: u64,
9    page_size: u64,
10    next_offset: Option<u64>,
11    total_entries: Option<u64>,
12}
13
14impl OffsetPageMetadata {
15    /// Creates offset metadata without inferring continuation.
16    pub const fn new(
17        offset: u64,
18        page_size: u64,
19        next_offset: Option<u64>,
20        total_entries: Option<u64>,
21    ) -> Result<Self, PaginationError> {
22        if page_size == 0 {
23            return Err(PaginationError::PageSizeZero);
24        }
25        if let Some(next) = next_offset
26            && next <= offset
27        {
28            return Err(PaginationError::InvalidNextPage);
29        }
30        Ok(Self {
31            offset,
32            page_size,
33            next_offset,
34            total_entries,
35        })
36    }
37
38    /// Returns the current offset.
39    #[must_use]
40    pub const fn offset(self) -> u64 {
41        self.offset
42    }
43
44    /// Returns the requested page size.
45    #[must_use]
46    pub const fn page_size(self) -> u64 {
47        self.page_size
48    }
49
50    /// Returns the next offset when advertised.
51    #[must_use]
52    pub const fn next_offset(self) -> Option<u64> {
53        self.next_offset
54    }
55
56    /// Returns total entries when known.
57    #[must_use]
58    pub const fn total_entries(self) -> Option<u64> {
59        self.total_entries
60    }
61}
62
63/// Accepted offset-page boundary.
64#[derive(Clone, Copy, Debug, Eq, PartialEq)]
65pub struct OffsetPageBoundary {
66    metadata: OffsetPageMetadata,
67    entries: usize,
68    rate_limit: Option<RateLimit>,
69    progress: PaginationProgress,
70}
71
72impl OffsetPageBoundary {
73    /// Returns validated metadata.
74    #[must_use]
75    pub const fn metadata(self) -> OffsetPageMetadata {
76        self.metadata
77    }
78
79    /// Returns decoded entry count.
80    #[must_use]
81    pub const fn entries(self) -> usize {
82        self.entries
83    }
84
85    /// Returns rate-limit metadata when supplied.
86    #[must_use]
87    pub const fn rate_limit(self) -> Option<RateLimit> {
88        self.rate_limit
89    }
90
91    /// Returns traversal counters after this response.
92    #[must_use]
93    pub const fn progress(self) -> PaginationProgress {
94        self.progress
95    }
96
97    /// Reports whether no continuation remains.
98    #[must_use]
99    pub const fn is_terminal(self) -> bool {
100        self.metadata.next_offset.is_none()
101    }
102}
103
104/// Stateful offset strategy with fixed page size and total metadata.
105///
106/// This type is intentionally neither `Copy` nor `Clone`.
107pub struct OffsetPagination {
108    next_offset: Option<u64>,
109    expected_page_size: u64,
110    expected_total_entries: Option<u64>,
111    metadata_initialized: bool,
112    budget: PaginationBudget,
113}
114
115impl OffsetPagination {
116    /// Starts an offset traversal.
117    pub fn new(
118        first_offset: u64,
119        expected_page_size: u64,
120        budget: PaginationBudget,
121    ) -> Result<Self, PaginationError> {
122        if expected_page_size == 0 {
123            return Err(PaginationError::PageSizeZero);
124        }
125        Ok(Self {
126            next_offset: Some(first_offset),
127            expected_page_size,
128            expected_total_entries: None,
129            metadata_initialized: false,
130            budget,
131        })
132    }
133
134    /// Returns the offset the caller must request next.
135    pub const fn next_offset(&self) -> Result<u64, PaginationError> {
136        match self.next_offset {
137            Some(offset) => Ok(offset),
138            None => Err(PaginationError::Complete),
139        }
140    }
141
142    /// Returns accepted counters.
143    #[must_use]
144    pub const fn progress(&self) -> PaginationProgress {
145        self.budget.progress()
146    }
147
148    /// Validates one response transactionally, then advances the strategy.
149    pub fn observe(
150        &mut self,
151        metadata: OffsetPageMetadata,
152        entries: usize,
153        rate_limit: Option<RateLimit>,
154        snapshot: Option<SnapshotId<'_>>,
155    ) -> Result<OffsetPageBoundary, PaginationError> {
156        let expected = self.next_offset.ok_or(PaginationError::Complete)?;
157        if metadata.offset != expected {
158            return Err(PaginationError::UnexpectedPosition);
159        }
160        if metadata.page_size != self.expected_page_size {
161            return Err(PaginationError::PageSizeChanged);
162        }
163        if self.metadata_initialized && metadata.total_entries != self.expected_total_entries {
164            return Err(PaginationError::TraversalChanged);
165        }
166        let entry_count = u64::try_from(entries).map_err(|_| PaginationError::InvalidEntryCount)?;
167        if entry_count > metadata.page_size {
168            return Err(PaginationError::InvalidEntryCount);
169        }
170        let expected_next = metadata
171            .offset
172            .checked_add(entry_count)
173            .ok_or(PaginationError::InvalidEntryCount)?;
174        if let Some(next) = metadata.next_offset
175            && (entries == 0 || next != expected_next)
176        {
177            return Err(PaginationError::InvalidNextPage);
178        }
179        if let Some(total) = metadata.total_entries {
180            let continuation = total > expected_next;
181            if expected_next > total || continuation != metadata.next_offset.is_some() {
182                return Err(PaginationError::InvalidEntryCount);
183            }
184        }
185        let has_continuation = metadata.next_offset.is_some();
186        if entries == 0 && has_continuation {
187            return Err(PaginationError::EmptyPageWithContinuation);
188        }
189        let progress = self.budget.admit(entries, has_continuation, snapshot)?;
190        self.next_offset = metadata.next_offset;
191        if !self.metadata_initialized {
192            self.expected_total_entries = metadata.total_entries;
193            self.metadata_initialized = true;
194        }
195        Ok(OffsetPageBoundary {
196            metadata,
197            entries,
198            rate_limit,
199            progress,
200        })
201    }
202}
203
204impl core::fmt::Debug for OffsetPagination {
205    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
206        formatter
207            .debug_struct("OffsetPagination")
208            .field("next_offset", &self.next_offset)
209            .field("expected_page_size", &self.expected_page_size)
210            .field("traversal_metadata", &"[redacted]")
211            .field("budget", &self.budget)
212            .finish()
213    }
214}