Skip to main content

cloud_sdk/pagination/
budget.rs

1use cloud_sdk_sanitization::{sanitize_bytes, sanitize_value};
2
3use super::{MAX_OPAQUE_STATE_BYTES, PaginationError};
4
5/// Maximum exact provider snapshot identity length.
6pub const MAX_SNAPSHOT_ID_BYTES: usize = 256;
7
8/// Hard traversal limits selected before the first request.
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub struct PaginationLimits {
11    max_requests: u32,
12    max_items: u64,
13    max_state_bytes: usize,
14}
15
16impl PaginationLimits {
17    /// Creates nonzero request, item, and opaque-state limits.
18    pub const fn new(
19        max_requests: u32,
20        max_items: u64,
21        max_state_bytes: usize,
22    ) -> Result<Self, PaginationError> {
23        if max_requests == 0 || max_items == 0 || max_state_bytes == 0 {
24            return Err(PaginationError::ZeroLimit);
25        }
26        if max_state_bytes > MAX_OPAQUE_STATE_BYTES {
27            return Err(PaginationError::StateLimitTooLarge);
28        }
29        Ok(Self {
30            max_requests,
31            max_items,
32            max_state_bytes,
33        })
34    }
35
36    /// Returns the maximum admitted requests.
37    #[must_use]
38    pub const fn max_requests(self) -> u32 {
39        self.max_requests
40    }
41
42    /// Returns the maximum admitted items.
43    #[must_use]
44    pub const fn max_items(self) -> u64 {
45        self.max_items
46    }
47
48    /// Returns the maximum opaque state length.
49    #[must_use]
50    pub const fn max_state_bytes(self) -> usize {
51        self.max_state_bytes
52    }
53}
54
55/// Provider snapshot identifier supplied by a provider decoder.
56#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
57pub struct SnapshotId<'a>(&'a [u8]);
58
59impl<'a> SnapshotId<'a> {
60    /// Creates a nonempty exact bounded snapshot identifier.
61    pub const fn new(value: &'a [u8]) -> Result<Self, PaginationError> {
62        if value.is_empty() {
63            return Err(PaginationError::SnapshotIdEmpty);
64        }
65        if value.len() > MAX_SNAPSHOT_ID_BYTES {
66            return Err(PaginationError::SnapshotIdTooLong);
67        }
68        Ok(Self(value))
69    }
70
71    /// Returns the exact provider-decoder identity bytes.
72    #[must_use]
73    pub const fn as_bytes(self) -> &'a [u8] {
74        self.0
75    }
76}
77
78impl core::fmt::Debug for SnapshotId<'_> {
79    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
80        formatter.write_str("SnapshotId([redacted])")
81    }
82}
83
84/// Snapshot presence policy fixed for one traversal.
85#[derive(Clone, Copy, Debug, Eq, PartialEq)]
86pub enum SnapshotPolicy {
87    /// Every response must provide one stable identifier.
88    Required,
89    /// Presence is provider-dependent but cannot change after the first page.
90    Optional,
91    /// Responses must not provide snapshot identifiers.
92    Forbidden,
93}
94
95/// Public nonsensitive traversal counters.
96#[derive(Clone, Copy, Debug, Eq, PartialEq)]
97pub struct PaginationProgress {
98    requests: u32,
99    items: u64,
100}
101
102impl PaginationProgress {
103    /// Returns accepted response count.
104    #[must_use]
105    pub const fn requests(self) -> u32 {
106        self.requests
107    }
108
109    /// Returns accepted item count.
110    #[must_use]
111    pub const fn items(self) -> u64 {
112        self.items
113    }
114}
115
116/// Transactional request, item, and snapshot budget.
117///
118/// This state is intentionally neither `Copy` nor `Clone`.
119///
120/// ```compile_fail
121/// use cloud_sdk::pagination::PaginationBudget;
122/// fn require_copy<T: Copy>() {}
123/// require_copy::<PaginationBudget>();
124/// ```
125pub struct PaginationBudget {
126    limits: PaginationLimits,
127    snapshot_policy: SnapshotPolicy,
128    snapshot: [u8; MAX_SNAPSHOT_ID_BYTES],
129    snapshot_len: usize,
130    snapshot_present: bool,
131    snapshot_initialized: bool,
132    requests: u32,
133    items: u64,
134}
135
136impl PaginationBudget {
137    /// Starts an empty traversal budget.
138    #[must_use]
139    pub const fn new(limits: PaginationLimits, snapshot_policy: SnapshotPolicy) -> Self {
140        Self {
141            limits,
142            snapshot_policy,
143            snapshot: [0; MAX_SNAPSHOT_ID_BYTES],
144            snapshot_len: 0,
145            snapshot_present: false,
146            snapshot_initialized: false,
147            requests: 0,
148            items: 0,
149        }
150    }
151
152    /// Returns the immutable limits.
153    #[must_use]
154    pub const fn limits(&self) -> PaginationLimits {
155        self.limits
156    }
157
158    /// Returns accepted counters.
159    #[must_use]
160    pub const fn progress(&self) -> PaginationProgress {
161        PaginationProgress {
162            requests: self.requests,
163            items: self.items,
164        }
165    }
166
167    /// Transactionally admits one decoded response into the hard budget.
168    ///
169    /// Cursor, marker, and provider-link traversals call this once for every
170    /// response before following a continuation. On failure, counters and
171    /// snapshot state remain unchanged.
172    pub fn admit(
173        &mut self,
174        entries: usize,
175        has_continuation: bool,
176        snapshot: Option<SnapshotId<'_>>,
177    ) -> Result<PaginationProgress, PaginationError> {
178        self.validate_snapshot(snapshot)?;
179        let requests = self
180            .requests
181            .checked_add(1)
182            .ok_or(PaginationError::RequestBudgetExceeded)?;
183        if requests > self.limits.max_requests
184            || has_continuation && requests == self.limits.max_requests
185        {
186            return Err(PaginationError::RequestBudgetExceeded);
187        }
188        let entries = u64::try_from(entries).map_err(|_| PaginationError::ItemBudgetExceeded)?;
189        let items = self
190            .items
191            .checked_add(entries)
192            .ok_or(PaginationError::ItemBudgetExceeded)?;
193        if items > self.limits.max_items {
194            return Err(PaginationError::ItemBudgetExceeded);
195        }
196        if !self.snapshot_initialized {
197            if let Some(snapshot) = snapshot {
198                let bytes = snapshot.as_bytes();
199                self.snapshot
200                    .get_mut(..bytes.len())
201                    .ok_or(PaginationError::SnapshotIdTooLong)?
202                    .copy_from_slice(bytes);
203                self.snapshot_len = bytes.len();
204                self.snapshot_present = true;
205            }
206            self.snapshot_initialized = true;
207        }
208        self.requests = requests;
209        self.items = items;
210        Ok(self.progress())
211    }
212
213    fn validate_snapshot(&self, snapshot: Option<SnapshotId<'_>>) -> Result<(), PaginationError> {
214        match self.snapshot_policy {
215            SnapshotPolicy::Required if snapshot.is_none() => {
216                Err(PaginationError::SnapshotRequired)
217            }
218            SnapshotPolicy::Forbidden if snapshot.is_some() => {
219                Err(PaginationError::SnapshotForbidden)
220            }
221            _ if self.snapshot_initialized && !self.snapshot_matches(snapshot) => {
222                Err(PaginationError::SnapshotChanged)
223            }
224            _ => Ok(()),
225        }
226    }
227
228    fn snapshot_matches(&self, snapshot: Option<SnapshotId<'_>>) -> bool {
229        match (self.snapshot_present, snapshot) {
230            (false, None) => true,
231            (true, Some(snapshot)) => self
232                .snapshot
233                .get(..self.snapshot_len)
234                .is_some_and(|stored| stored == snapshot.as_bytes()),
235            _ => false,
236        }
237    }
238}
239
240impl Drop for PaginationBudget {
241    fn drop(&mut self) {
242        sanitize_bytes(&mut self.snapshot);
243        sanitize_value(&mut self.snapshot_len);
244        sanitize_value(&mut self.snapshot_present);
245        sanitize_value(&mut self.snapshot_initialized);
246        sanitize_value(&mut self.requests);
247        sanitize_value(&mut self.items);
248    }
249}
250
251impl core::fmt::Debug for PaginationBudget {
252    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
253        formatter
254            .debug_struct("PaginationBudget")
255            .field("limits", &self.limits)
256            .field("snapshot", &"[redacted]")
257            .field("progress", &self.progress())
258            .finish()
259    }
260}