1use std::collections::{BTreeSet, HashMap};
16
17use uuid::Uuid;
18
19use crate::limits::{
20 MAX_PENDING_REPORTS_PER_CONNECTION, MAX_REPORT_PAGES, REPORT_IDLE_TIMEOUT,
21 REPORT_TOTAL_TIMEOUT, WireValidationError,
22};
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum PageOutcome {
27 Pending,
30 Final {
34 accumulated_discovered_count: u32,
37 },
38}
39
40#[derive(Debug)]
42struct PendingReport {
43 pages_received: BTreeSet<u32>,
45 total_pages: u32,
47 started_at: tokio::time::Instant,
49 last_page_at: tokio::time::Instant,
51 discovered_count: u32,
53}
54
55#[derive(Debug)]
60pub struct ReportTracker {
61 pending: HashMap<Uuid, PendingReport>,
62}
63
64impl ReportTracker {
65 pub fn new() -> Self {
67 Self {
68 pending: HashMap::new(),
69 }
70 }
71
72 pub fn register_page(
82 &mut self,
83 report_id: Uuid,
84 page: u32,
85 total_pages: u32,
86 ) -> Result<PageOutcome, WireValidationError> {
87 self.evict_expired();
89
90 if let Some(existing) = self.pending.get_mut(&report_id) {
91 if existing.total_pages != total_pages {
93 return Err(WireValidationError {
94 field: "pagination.total_pages",
95 message: format!(
96 "total_pages changed from {} to {total_pages} within report {report_id}",
97 existing.total_pages
98 ),
99 });
100 }
101
102 if existing.pages_received.contains(&page) {
104 return Err(WireValidationError {
105 field: "pagination.page",
106 message: format!("duplicate page {page} for report {report_id}"),
107 });
108 }
109
110 existing.pages_received.insert(page);
111 existing.last_page_at = tokio::time::Instant::now();
112
113 if existing.pages_received.len() == existing.total_pages as usize {
114 #[expect(
116 clippy::expect_used,
117 reason = "infallible: we are inside a branch that already accessed this key via get_mut; the entry is guaranteed to exist"
118 )]
119 let report = self
120 .pending
121 .remove(&report_id)
122 .expect("report was just accessed");
123 Ok(PageOutcome::Final {
124 accumulated_discovered_count: report.discovered_count,
125 })
126 } else {
127 Ok(PageOutcome::Pending)
128 }
129 } else {
130 if self.pending.len() >= MAX_PENDING_REPORTS_PER_CONNECTION {
132 return Err(WireValidationError {
133 field: "pagination.report_id",
134 message: format!(
135 "too many concurrent paginated reports (max {MAX_PENDING_REPORTS_PER_CONNECTION})"
136 ),
137 });
138 }
139
140 if total_pages > MAX_REPORT_PAGES {
141 return Err(WireValidationError {
142 field: "pagination.total_pages",
143 message: format!("total_pages is {total_pages}, max {MAX_REPORT_PAGES}"),
144 });
145 }
146
147 let now = tokio::time::Instant::now();
148 let mut pages_received = BTreeSet::new();
149 pages_received.insert(page);
150
151 if total_pages == 1 {
153 return Ok(PageOutcome::Final {
154 accumulated_discovered_count: 0,
155 });
156 }
157
158 self.pending.insert(
159 report_id,
160 PendingReport {
161 pages_received,
162 total_pages,
163 started_at: now,
164 last_page_at: now,
165 discovered_count: 0,
166 },
167 );
168
169 Ok(PageOutcome::Pending)
170 }
171 }
172
173 pub fn add_discovered_count(&mut self, report_id: Uuid, count: u32) {
177 if let Some(report) = self.pending.get_mut(&report_id) {
178 report.discovered_count = report.discovered_count.saturating_add(count);
179 }
180 }
181
182 pub fn evict_expired(&mut self) {
187 let now = tokio::time::Instant::now();
188 self.pending.retain(|id, report| {
189 let total_elapsed = now.duration_since(report.started_at);
190 let idle_elapsed = now.duration_since(report.last_page_at);
191
192 if total_elapsed >= REPORT_TOTAL_TIMEOUT {
193 tracing::warn!(
194 report_id = %id,
195 pages_received = report.pages_received.len(),
196 total_pages = report.total_pages,
197 "paginated report timed out (total timeout {}s exceeded)",
198 REPORT_TOTAL_TIMEOUT.as_secs()
199 );
200 return false;
201 }
202 if idle_elapsed >= REPORT_IDLE_TIMEOUT {
203 tracing::warn!(
204 report_id = %id,
205 pages_received = report.pages_received.len(),
206 total_pages = report.total_pages,
207 "paginated report timed out (idle timeout {}s exceeded)",
208 REPORT_IDLE_TIMEOUT.as_secs()
209 );
210 return false;
211 }
212 true
213 });
214 }
215
216 pub fn pending_count(&self) -> usize {
218 self.pending.len()
219 }
220}
221
222impl Default for ReportTracker {
223 fn default() -> Self {
224 Self::new()
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[tokio::test(start_paused = true)]
233 async fn single_page_report_returns_final() {
234 let mut tracker = ReportTracker::new();
235 let id = Uuid::new_v4();
236 let outcome = tracker.register_page(id, 1, 1).unwrap();
237 assert_eq!(
238 outcome,
239 PageOutcome::Final {
240 accumulated_discovered_count: 0
241 }
242 );
243 assert_eq!(tracker.pending_count(), 0);
244 }
245
246 #[tokio::test(start_paused = true)]
247 async fn multi_page_report_lifecycle() {
248 let mut tracker = ReportTracker::new();
249 let id = Uuid::new_v4();
250
251 let outcome = tracker.register_page(id, 1, 3).unwrap();
253 assert_eq!(outcome, PageOutcome::Pending);
254 assert_eq!(tracker.pending_count(), 1);
255
256 tracker.add_discovered_count(id, 100);
257
258 let outcome = tracker.register_page(id, 2, 3).unwrap();
260 assert_eq!(outcome, PageOutcome::Pending);
261
262 tracker.add_discovered_count(id, 50);
263
264 let outcome = tracker.register_page(id, 3, 3).unwrap();
266 assert_eq!(
267 outcome,
268 PageOutcome::Final {
269 accumulated_discovered_count: 150
270 }
271 );
272 assert_eq!(tracker.pending_count(), 0);
273 }
274
275 #[tokio::test(start_paused = true)]
276 async fn pages_out_of_order() {
277 let mut tracker = ReportTracker::new();
278 let id = Uuid::new_v4();
279
280 assert_eq!(
281 tracker.register_page(id, 3, 3).unwrap(),
282 PageOutcome::Pending
283 );
284 assert_eq!(
285 tracker.register_page(id, 1, 3).unwrap(),
286 PageOutcome::Pending
287 );
288 let outcome = tracker.register_page(id, 2, 3).unwrap();
289 assert_eq!(
290 outcome,
291 PageOutcome::Final {
292 accumulated_discovered_count: 0
293 }
294 );
295 }
296
297 #[tokio::test(start_paused = true)]
298 async fn duplicate_page_rejected() {
299 let mut tracker = ReportTracker::new();
300 let id = Uuid::new_v4();
301 tracker.register_page(id, 1, 3).unwrap();
302 let err = tracker.register_page(id, 1, 3).unwrap_err();
303 assert!(err.message.contains("duplicate page"));
304 }
305
306 #[tokio::test(start_paused = true)]
307 async fn total_pages_mismatch_rejected() {
308 let mut tracker = ReportTracker::new();
309 let id = Uuid::new_v4();
310 tracker.register_page(id, 1, 3).unwrap();
311 let err = tracker.register_page(id, 2, 5).unwrap_err();
312 assert!(err.message.contains("total_pages changed"));
313 }
314
315 #[tokio::test(start_paused = true)]
316 async fn max_concurrent_reports_enforced() {
317 let mut tracker = ReportTracker::new();
318 for i in 0..MAX_PENDING_REPORTS_PER_CONNECTION {
319 let id = Uuid::from_u128(i as u128);
320 tracker.register_page(id, 1, 2).unwrap();
321 }
322 let extra_id = Uuid::from_u128(999);
323 let err = tracker.register_page(extra_id, 1, 2).unwrap_err();
324 assert!(err.message.contains("too many concurrent"));
325 }
326
327 #[tokio::test(start_paused = true)]
328 async fn idle_timeout_evicts() {
329 let mut tracker = ReportTracker::new();
330 let id = Uuid::new_v4();
331 tracker.register_page(id, 1, 3).unwrap();
332 assert_eq!(tracker.pending_count(), 1);
333
334 tokio::time::advance(REPORT_IDLE_TIMEOUT + std::time::Duration::from_secs(1)).await;
336 tracker.evict_expired();
337 assert_eq!(tracker.pending_count(), 0);
338 }
339
340 #[tokio::test(start_paused = true)]
341 async fn total_timeout_evicts() {
342 let mut tracker = ReportTracker::new();
343 let id = Uuid::new_v4();
344 tracker.register_page(id, 1, 3).unwrap();
345
346 for step in 0..25 {
349 tokio::time::advance(std::time::Duration::from_secs(13)).await;
350 if step == 0 {
354 }
358 }
359
360 tokio::time::advance(REPORT_TOTAL_TIMEOUT + std::time::Duration::from_secs(1)).await;
362 tracker.evict_expired();
363 assert_eq!(tracker.pending_count(), 0);
364 }
365
366 #[tokio::test(start_paused = true)]
367 async fn add_discovered_count_no_op_for_unknown() {
368 let mut tracker = ReportTracker::new();
369 tracker.add_discovered_count(Uuid::new_v4(), 42);
371 assert_eq!(tracker.pending_count(), 0);
372 }
373}