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 let report = self
116 .pending
117 .remove(&report_id)
118 .expect("report was just accessed");
119 Ok(PageOutcome::Final {
120 accumulated_discovered_count: report.discovered_count,
121 })
122 } else {
123 Ok(PageOutcome::Pending)
124 }
125 } else {
126 if self.pending.len() >= MAX_PENDING_REPORTS_PER_CONNECTION {
128 return Err(WireValidationError {
129 field: "pagination.report_id",
130 message: format!(
131 "too many concurrent paginated reports (max {MAX_PENDING_REPORTS_PER_CONNECTION})"
132 ),
133 });
134 }
135
136 if total_pages > MAX_REPORT_PAGES {
137 return Err(WireValidationError {
138 field: "pagination.total_pages",
139 message: format!("total_pages is {total_pages}, max {MAX_REPORT_PAGES}"),
140 });
141 }
142
143 let now = tokio::time::Instant::now();
144 let mut pages_received = BTreeSet::new();
145 pages_received.insert(page);
146
147 if total_pages == 1 {
149 return Ok(PageOutcome::Final {
150 accumulated_discovered_count: 0,
151 });
152 }
153
154 self.pending.insert(
155 report_id,
156 PendingReport {
157 pages_received,
158 total_pages,
159 started_at: now,
160 last_page_at: now,
161 discovered_count: 0,
162 },
163 );
164
165 Ok(PageOutcome::Pending)
166 }
167 }
168
169 pub fn add_discovered_count(&mut self, report_id: Uuid, count: u32) {
173 if let Some(report) = self.pending.get_mut(&report_id) {
174 report.discovered_count = report.discovered_count.saturating_add(count);
175 }
176 }
177
178 pub fn evict_expired(&mut self) {
183 let now = tokio::time::Instant::now();
184 self.pending.retain(|id, report| {
185 let total_elapsed = now.duration_since(report.started_at);
186 let idle_elapsed = now.duration_since(report.last_page_at);
187
188 if total_elapsed >= REPORT_TOTAL_TIMEOUT {
189 tracing::warn!(
190 report_id = %id,
191 pages_received = report.pages_received.len(),
192 total_pages = report.total_pages,
193 "paginated report timed out (total timeout {}s exceeded)",
194 REPORT_TOTAL_TIMEOUT.as_secs()
195 );
196 return false;
197 }
198 if idle_elapsed >= REPORT_IDLE_TIMEOUT {
199 tracing::warn!(
200 report_id = %id,
201 pages_received = report.pages_received.len(),
202 total_pages = report.total_pages,
203 "paginated report timed out (idle timeout {}s exceeded)",
204 REPORT_IDLE_TIMEOUT.as_secs()
205 );
206 return false;
207 }
208 true
209 });
210 }
211
212 pub fn pending_count(&self) -> usize {
214 self.pending.len()
215 }
216}
217
218impl Default for ReportTracker {
219 fn default() -> Self {
220 Self::new()
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[tokio::test(start_paused = true)]
229 async fn single_page_report_returns_final() {
230 let mut tracker = ReportTracker::new();
231 let id = Uuid::new_v4();
232 let outcome = tracker.register_page(id, 1, 1).unwrap();
233 assert_eq!(
234 outcome,
235 PageOutcome::Final {
236 accumulated_discovered_count: 0
237 }
238 );
239 assert_eq!(tracker.pending_count(), 0);
240 }
241
242 #[tokio::test(start_paused = true)]
243 async fn multi_page_report_lifecycle() {
244 let mut tracker = ReportTracker::new();
245 let id = Uuid::new_v4();
246
247 let outcome = tracker.register_page(id, 1, 3).unwrap();
249 assert_eq!(outcome, PageOutcome::Pending);
250 assert_eq!(tracker.pending_count(), 1);
251
252 tracker.add_discovered_count(id, 100);
253
254 let outcome = tracker.register_page(id, 2, 3).unwrap();
256 assert_eq!(outcome, PageOutcome::Pending);
257
258 tracker.add_discovered_count(id, 50);
259
260 let outcome = tracker.register_page(id, 3, 3).unwrap();
262 assert_eq!(
263 outcome,
264 PageOutcome::Final {
265 accumulated_discovered_count: 150
266 }
267 );
268 assert_eq!(tracker.pending_count(), 0);
269 }
270
271 #[tokio::test(start_paused = true)]
272 async fn pages_out_of_order() {
273 let mut tracker = ReportTracker::new();
274 let id = Uuid::new_v4();
275
276 assert_eq!(
277 tracker.register_page(id, 3, 3).unwrap(),
278 PageOutcome::Pending
279 );
280 assert_eq!(
281 tracker.register_page(id, 1, 3).unwrap(),
282 PageOutcome::Pending
283 );
284 let outcome = tracker.register_page(id, 2, 3).unwrap();
285 assert_eq!(
286 outcome,
287 PageOutcome::Final {
288 accumulated_discovered_count: 0
289 }
290 );
291 }
292
293 #[tokio::test(start_paused = true)]
294 async fn duplicate_page_rejected() {
295 let mut tracker = ReportTracker::new();
296 let id = Uuid::new_v4();
297 tracker.register_page(id, 1, 3).unwrap();
298 let err = tracker.register_page(id, 1, 3).unwrap_err();
299 assert!(err.message.contains("duplicate page"));
300 }
301
302 #[tokio::test(start_paused = true)]
303 async fn total_pages_mismatch_rejected() {
304 let mut tracker = ReportTracker::new();
305 let id = Uuid::new_v4();
306 tracker.register_page(id, 1, 3).unwrap();
307 let err = tracker.register_page(id, 2, 5).unwrap_err();
308 assert!(err.message.contains("total_pages changed"));
309 }
310
311 #[tokio::test(start_paused = true)]
312 async fn max_concurrent_reports_enforced() {
313 let mut tracker = ReportTracker::new();
314 for i in 0..MAX_PENDING_REPORTS_PER_CONNECTION {
315 let id = Uuid::from_u128(i as u128);
316 tracker.register_page(id, 1, 2).unwrap();
317 }
318 let extra_id = Uuid::from_u128(999);
319 let err = tracker.register_page(extra_id, 1, 2).unwrap_err();
320 assert!(err.message.contains("too many concurrent"));
321 }
322
323 #[tokio::test(start_paused = true)]
324 async fn idle_timeout_evicts() {
325 let mut tracker = ReportTracker::new();
326 let id = Uuid::new_v4();
327 tracker.register_page(id, 1, 3).unwrap();
328 assert_eq!(tracker.pending_count(), 1);
329
330 tokio::time::advance(REPORT_IDLE_TIMEOUT + std::time::Duration::from_secs(1)).await;
332 tracker.evict_expired();
333 assert_eq!(tracker.pending_count(), 0);
334 }
335
336 #[tokio::test(start_paused = true)]
337 async fn total_timeout_evicts() {
338 let mut tracker = ReportTracker::new();
339 let id = Uuid::new_v4();
340 tracker.register_page(id, 1, 3).unwrap();
341
342 for step in 0..25 {
345 tokio::time::advance(std::time::Duration::from_secs(13)).await;
346 if step == 0 {
350 }
354 }
355
356 tokio::time::advance(REPORT_TOTAL_TIMEOUT + std::time::Duration::from_secs(1)).await;
358 tracker.evict_expired();
359 assert_eq!(tracker.pending_count(), 0);
360 }
361
362 #[tokio::test(start_paused = true)]
363 async fn add_discovered_count_no_op_for_unknown() {
364 let mut tracker = ReportTracker::new();
365 tracker.add_discovered_count(Uuid::new_v4(), 42);
367 assert_eq!(tracker.pending_count(), 0);
368 }
369}