Skip to main content

uptrakit_wire/
report_tracker.rs

1//! Lightweight per-connection tracker for paginated reports.
2//!
3//! The [`ReportTracker`] lives in the WebSocket connection handler's local
4//! scope and tracks which pages of each paginated report have been received.
5//! It stores **no payload data** — each page is processed immediately and
6//! dropped. The tracker only holds page counts, timestamps, and a small
7//! accumulated notification counter.
8//!
9//! # Memory budget
10//!
11//! Each [`PendingReport`] is ~200 bytes. With a maximum of
12//! [`MAX_PENDING_REPORTS_PER_CONNECTION`](crate::limits::MAX_PENDING_REPORTS_PER_CONNECTION)
13//! concurrent reports, the tracker uses at most ~2 KB per connection.
14
15use 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/// Outcome of registering a page with the [`ReportTracker`].
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum PageOutcome {
27    /// More pages are expected; the caller should process this page's payload
28    /// but defer finalization.
29    Pending,
30    /// This was the final page; the caller should process the payload and run
31    /// finalization. Contains the accumulated `discovered_count` from all
32    /// prior pages plus the current page's contribution (added by the caller).
33    Final {
34        /// Sum of `discovered_count` values accumulated across all prior pages.
35        /// The caller adds the current page's count before using it.
36        accumulated_discovered_count: u32,
37    },
38}
39
40/// Tracks the state of a single paginated report.
41#[derive(Debug)]
42struct PendingReport {
43    /// Which 1-based page numbers have been received.
44    pages_received: BTreeSet<u32>,
45    /// Total pages expected.
46    total_pages: u32,
47    /// When the first page was received.
48    started_at: tokio::time::Instant,
49    /// When the most recent page was received.
50    last_page_at: tokio::time::Instant,
51    /// Accumulated discovered_count across all processed pages (discovery only).
52    discovered_count: u32,
53}
54
55/// Per-connection tracker for paginated reports.
56///
57/// Created when the authenticated message loop starts, dropped when the
58/// connection closes. No shared or global state.
59#[derive(Debug)]
60pub struct ReportTracker {
61    pending: HashMap<Uuid, PendingReport>,
62}
63
64impl ReportTracker {
65    /// Create a new empty tracker.
66    pub fn new() -> Self {
67        Self {
68            pending: HashMap::new(),
69        }
70    }
71
72    /// Register a page of a paginated report.
73    ///
74    /// Returns:
75    /// - `Ok(PageOutcome::Pending)` if more pages are expected.
76    /// - `Ok(PageOutcome::Final { .. })` if all pages have now been received.
77    /// - `Err(WireValidationError)` if limits are violated (duplicate page,
78    ///   too many concurrent reports, `total_pages` mismatch, etc.).
79    ///
80    /// The caller must process the page's payload regardless of the outcome.
81    pub fn register_page(
82        &mut self,
83        report_id: Uuid,
84        page: u32,
85        total_pages: u32,
86    ) -> Result<PageOutcome, WireValidationError> {
87        // Evict timed-out reports before checking limits.
88        self.evict_expired();
89
90        if let Some(existing) = self.pending.get_mut(&report_id) {
91            // Validate total_pages consistency.
92            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            // Reject duplicate pages.
103            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                // All pages received — remove from tracker and return Final.
115                #[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            // New report.
131            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            // Single-page "paginated" report: return Final immediately.
152            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    /// Add to the accumulated `discovered_count` for an in-progress report.
174    ///
175    /// No-op if the report has already been finalized or does not exist.
176    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    /// Evict reports that have exceeded the total or idle timeout.
183    ///
184    /// Called automatically by [`register_page`](Self::register_page). Can also
185    /// be called periodically from a background task.
186    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    /// Returns the number of currently pending reports.
217    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        // Page 1 of 3
252        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        // Page 2 of 3
259        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        // Page 3 of 3
265        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        // Advance past idle timeout.
335        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        // Keep the idle timeout from firing by advancing in small steps and
347        // sending another page each time.
348        for step in 0..25 {
349            tokio::time::advance(std::time::Duration::from_secs(13)).await;
350            // Pages 2..N keep the idle timer fresh (but won't reach total_pages
351            // since we only register up to 24 out of a hypothetical large total).
352            // Adjust: use a larger total_pages value for this test.
353            if step == 0 {
354                // Already registered page 1 with total_pages=3. Let's re-create
355                // with a large total so we can send many pages.
356                // Instead, just check total timeout logic directly.
357            }
358        }
359
360        // Advance past total timeout.
361        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        // No-op: report doesn't exist.
370        tracker.add_discovered_count(Uuid::new_v4(), 42);
371        assert_eq!(tracker.pending_count(), 0);
372    }
373}