Skip to main content

uptrakit_wire/
paginate.rs

1//! Payload pagination for wire protocol messages.
2//!
3//! When a service-to-controller report exceeds the
4//! [`PAGINATION_SIZE_THRESHOLD`](crate::limits::PAGINATION_SIZE_THRESHOLD),
5//! the sender splits it into multiple pages. Each page is a complete,
6//! independently processable message; the controller tracks page arrival
7//! via [`ReportTracker`](crate::report_tracker::ReportTracker) and defers
8//! only lightweight finalization until the last page.
9//!
10//! # Design constraints
11//!
12//! - Each [`DiscoveryPluginResult`](crate::payloads::DiscoveryPluginResult)
13//!   is kept whole across pages when it fits within
14//!   [`MAX_DISCOVERIES_PER_PLUGIN`](crate::limits::MAX_DISCOVERIES_PER_PLUGIN).
15//!   Plugin results that exceed this limit are **normalized** (chunked) into
16//!   multiple `DiscoveryPluginResult` entries before pagination so that every
17//!   individual entry respects the wire validation limit. The controller
18//!   processes each entry independently, so splitting is transparent.
19//! - No payload buffering on the controller: each page is processed and
20//!   dropped immediately.
21
22use serde::Serialize;
23use uuid::Uuid;
24
25use crate::envelope::ReportPagination;
26use crate::limits::{MAX_DISCOVERIES_PER_PLUGIN, PAGINATION_SIZE_THRESHOLD};
27use crate::messages::ServiceMessage;
28use crate::payloads::{
29    BatchUpdateResultPayload, DiscoveryPluginResult, DiscoveryResultsPayload, ReportHostsPayload,
30    ReportPageLimits, VersionCheckResultsPayload,
31};
32
33/// A trait for wire payloads whose primary `Vec` field can be split across
34/// pages when the serialized size exceeds the pagination threshold.
35///
36/// Implementors must keep non-vec fields (e.g. `host_machine_id`,
37/// `batch_id`, `agent_version`) identical across all pages.
38pub trait Paginatable: Serialize + Sized {
39    /// Type of items in the splittable vec.
40    type Item: Serialize + Clone;
41
42    /// Borrow the items that may be distributed across pages.
43    fn items(&self) -> &[Self::Item];
44
45    /// Reconstruct the payload with a subset of items.
46    fn with_items(&self, items: Vec<Self::Item>) -> Self;
47
48    /// Wrap the payload into a [`ServiceMessage`].
49    fn into_message(self) -> ServiceMessage;
50
51    /// Maximum number of items allowed on a single page for this payload type.
52    fn max_items_per_page(limits: &ReportPageLimits) -> usize;
53
54    /// Normalize the payload before pagination.
55    ///
56    /// Called at the start of [`paginate_payload`] to ensure nested
57    /// collections also respect their individual wire validation limits. The
58    /// default implementation is a no-op. Override for payloads with nested
59    /// vecs that may individually exceed their per-item wire limits.
60    fn normalize(self) -> Self {
61        self
62    }
63}
64
65impl Paginatable for DiscoveryResultsPayload {
66    type Item = DiscoveryPluginResult;
67
68    fn items(&self) -> &[Self::Item] {
69        &self.results
70    }
71
72    fn with_items(&self, items: Vec<Self::Item>) -> Self {
73        Self {
74            host_machine_id: self.host_machine_id.clone(),
75            results: items,
76        }
77    }
78
79    fn into_message(self) -> ServiceMessage {
80        ServiceMessage::DiscoveryResults(self)
81    }
82
83    fn max_items_per_page(limits: &ReportPageLimits) -> usize {
84        limits.discovery_results as usize
85    }
86
87    /// Split any [`DiscoveryPluginResult`] whose `discoveries` vec exceeds
88    /// [`MAX_DISCOVERIES_PER_PLUGIN`] into multiple entries so that every
89    /// entry passes wire validation. The controller processes each entry
90    /// independently, so splitting is transparent to the caller.
91    fn normalize(self) -> Self {
92        let results = self
93            .results
94            .into_iter()
95            .flat_map(|result| {
96                if result.discoveries.len() <= MAX_DISCOVERIES_PER_PLUGIN {
97                    vec![result]
98                } else {
99                    let plugin_config_id = result.plugin_config_id;
100                    let plugin_type = result.plugin_type.clone();
101                    let error = result.error.clone();
102                    result
103                        .discoveries
104                        .chunks(MAX_DISCOVERIES_PER_PLUGIN)
105                        .map(|chunk| DiscoveryPluginResult {
106                            plugin_config_id,
107                            plugin_type: plugin_type.clone(),
108                            discoveries: chunk.to_vec(),
109                            error: error.clone(),
110                        })
111                        .collect::<Vec<_>>()
112                }
113            })
114            .collect();
115        Self {
116            host_machine_id: self.host_machine_id,
117            results,
118        }
119    }
120}
121
122impl Paginatable for VersionCheckResultsPayload {
123    type Item = crate::payloads::VersionCheckResult;
124
125    fn items(&self) -> &[Self::Item] {
126        &self.results
127    }
128
129    fn with_items(&self, items: Vec<Self::Item>) -> Self {
130        Self { results: items }
131    }
132
133    fn into_message(self) -> ServiceMessage {
134        ServiceMessage::VersionCheckResults(self)
135    }
136
137    fn max_items_per_page(limits: &ReportPageLimits) -> usize {
138        limits.version_check_results as usize
139    }
140}
141
142impl Paginatable for ReportHostsPayload {
143    type Item = crate::payloads::HostInfo;
144
145    fn items(&self) -> &[Self::Item] {
146        &self.hosts
147    }
148
149    fn with_items(&self, items: Vec<Self::Item>) -> Self {
150        Self {
151            hosts: items,
152            agent_version: self.agent_version.clone(),
153            capabilities: self.capabilities.clone(),
154        }
155    }
156
157    fn into_message(self) -> ServiceMessage {
158        ServiceMessage::ReportHosts(self)
159    }
160
161    fn max_items_per_page(limits: &ReportPageLimits) -> usize {
162        limits.report_hosts as usize
163    }
164}
165
166impl Paginatable for BatchUpdateResultPayload {
167    type Item = crate::payloads::BatchUpdateItemResult;
168
169    fn items(&self) -> &[Self::Item] {
170        &self.results
171    }
172
173    fn with_items(&self, items: Vec<Self::Item>) -> Self {
174        Self {
175            batch_id: self.batch_id,
176            results: items,
177        }
178    }
179
180    fn into_message(self) -> ServiceMessage {
181        ServiceMessage::BatchUpdateResult(self)
182    }
183
184    fn max_items_per_page(limits: &ReportPageLimits) -> usize {
185        limits.batch_update_results as usize
186    }
187}
188
189/// A single page produced by [`paginate_payload`].
190#[derive(Debug)]
191pub struct PayloadPage<P> {
192    /// The page's payload (subset of items from the original).
193    pub payload: P,
194    /// Pagination metadata, `None` if the payload fit in a single message.
195    pub pagination: Option<ReportPagination>,
196}
197
198/// Split a [`Paginatable`] payload into pages that each serialize under the
199/// [`PAGINATION_SIZE_THRESHOLD`].
200///
201/// If the full payload is already under the threshold, returns a single page
202/// with `pagination: None` (no overhead).
203///
204/// Each item is kept whole — never split across pages. If a single item
205/// exceeds the threshold on its own, it becomes the sole item on its page
206/// (the 1 MB WebSocket frame limit is the hard cap).
207///
208/// # Errors
209///
210/// Returns `Err` if serialization fails.
211pub fn paginate_payload<P: Paginatable>(
212    payload: P,
213    limits: &ReportPageLimits,
214) -> Result<Vec<PayloadPage<P>>, serde_json::Error> {
215    // Normalize before pagination: split any nested collections that would
216    // individually exceed their wire validation limits.
217    let payload = payload.normalize();
218    let max_items_per_page = P::max_items_per_page(limits);
219    // Fast path: check full payload size first.
220    let full_json = serde_json::to_string(&payload)?;
221    if full_json.len() <= PAGINATION_SIZE_THRESHOLD && payload.items().len() <= max_items_per_page {
222        return Ok(vec![PayloadPage {
223            payload,
224            pagination: None,
225        }]);
226    }
227
228    let items = payload.items().to_vec();
229    if items.is_empty() {
230        return Ok(vec![PayloadPage {
231            payload,
232            pagination: None,
233        }]);
234    }
235
236    // Build pages by accumulating items until adding the next item would
237    // exceed the threshold. We estimate per-item overhead by measuring the
238    // empty payload size and subtracting it from the threshold.
239    let empty_payload = payload.with_items(Vec::new());
240    let empty_json_len = serde_json::to_string(&empty_payload)?.len();
241    // Account for the pagination envelope fields (~120 bytes for
242    // report_id UUID + page + total_pages JSON fields).
243    let envelope_overhead = empty_json_len + 150;
244    let budget = PAGINATION_SIZE_THRESHOLD.saturating_sub(envelope_overhead);
245
246    let mut pages: Vec<Vec<P::Item>> = Vec::new();
247    let mut current_page: Vec<P::Item> = Vec::new();
248    let mut current_size: usize = 0;
249
250    for item in items {
251        let item_json_len = serde_json::to_string(&item)?.len();
252        // +1 for the comma separator in the JSON array.
253        let item_cost = item_json_len + 1;
254
255        let page_full_by_size = !current_page.is_empty() && current_size + item_cost > budget;
256        let page_full_by_count = current_page.len() >= max_items_per_page;
257        if page_full_by_size || page_full_by_count {
258            // Current page is full, start a new one.
259            pages.push(std::mem::take(&mut current_page));
260            current_size = 0;
261        }
262
263        current_size += item_cost;
264        current_page.push(item);
265    }
266
267    if !current_page.is_empty() {
268        pages.push(current_page);
269    }
270
271    let total_pages = pages.len() as u32;
272    let report_id = Uuid::new_v4();
273
274    let result = pages
275        .into_iter()
276        .enumerate()
277        .map(|(i, items)| PayloadPage {
278            payload: payload.with_items(items),
279            pagination: Some(ReportPagination {
280                report_id,
281                page: (i as u32) + 1,
282                total_pages,
283            }),
284        })
285        .collect();
286
287    Ok(result)
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use crate::limits::MAX_DISCOVERIES_PER_PLUGIN;
294    use crate::payloads::{DiscoveryPluginResult, VersionCheckResult};
295    use uptrakit_shared_types::{DiscoveredSoftware, plugin_ids};
296
297    fn make_discovered_software(name: &str) -> DiscoveredSoftware {
298        DiscoveredSoftware {
299            package_identifier: format!("pkg-{name}"),
300            name: name.to_string(),
301            installed_version: "1.0.0".to_string(),
302            qualifier: None,
303            plugin_package_identifier: None,
304            featured: false,
305            targets: Vec::new(),
306            extra: None,
307            installed_display_version: None,
308        }
309    }
310
311    fn make_discovery_result(name: &str) -> DiscoveryPluginResult {
312        DiscoveryPluginResult {
313            plugin_config_id: Some(Uuid::new_v4()),
314            plugin_type: plugin_ids::PACKAGE_MANAGER_APT.clone(),
315            discoveries: vec![make_discovered_software(name)],
316            error: None,
317        }
318    }
319
320    fn make_discovery_result_with_count(count: usize) -> DiscoveryPluginResult {
321        DiscoveryPluginResult {
322            plugin_config_id: Some(Uuid::new_v4()),
323            plugin_type: plugin_ids::PACKAGE_MANAGER_APT.clone(),
324            discoveries: (0..count)
325                .map(|i| make_discovered_software(&format!("pkg-{i}")))
326                .collect(),
327            error: None,
328        }
329    }
330
331    #[test]
332    fn small_payload_not_paginated() {
333        let payload = DiscoveryResultsPayload {
334            host_machine_id: "machine-1".to_string(),
335            results: vec![make_discovery_result("small")],
336        };
337        let pages = paginate_payload(payload, &ReportPageLimits::default()).unwrap();
338        assert_eq!(pages.len(), 1);
339        assert!(pages[0].pagination.is_none());
340    }
341
342    #[test]
343    fn large_payload_paginated() {
344        // Create a payload with many results to exceed the threshold.
345        let results: Vec<_> = (0..5000)
346            .map(|i| make_discovery_result(&format!("pkg-{i}")))
347            .collect();
348        let payload = DiscoveryResultsPayload {
349            host_machine_id: "machine-1".to_string(),
350            results,
351        };
352
353        let full_size = serde_json::to_string(&payload).unwrap().len();
354        assert!(
355            full_size > PAGINATION_SIZE_THRESHOLD,
356            "test payload should exceed threshold, was {full_size}"
357        );
358
359        let pages = paginate_payload(payload, &ReportPageLimits::default()).unwrap();
360        assert!(pages.len() > 1, "should have multiple pages");
361
362        // All pages should have the same report_id.
363        let report_id = pages[0].pagination.as_ref().unwrap().report_id;
364        for (i, page) in pages.iter().enumerate() {
365            let p = page.pagination.as_ref().unwrap();
366            assert_eq!(p.report_id, report_id);
367            assert_eq!(p.page, (i as u32) + 1);
368            assert_eq!(p.total_pages, pages.len() as u32);
369        }
370
371        // All items should be accounted for.
372        let total_items: usize = pages.iter().map(|p| p.payload.results.len()).sum();
373        assert_eq!(total_items, 5000);
374
375        // Each page should have the same host_machine_id.
376        for page in &pages {
377            assert_eq!(page.payload.host_machine_id, "machine-1");
378        }
379    }
380
381    #[test]
382    fn empty_payload_not_paginated() {
383        let payload = VersionCheckResultsPayload {
384            results: Vec::new(),
385        };
386        let pages = paginate_payload(payload, &ReportPageLimits::default()).unwrap();
387        assert_eq!(pages.len(), 1);
388        assert!(pages[0].pagination.is_none());
389    }
390
391    #[test]
392    fn version_check_results_paginatable() {
393        let results: Vec<_> = (0..5000)
394            .map(|i| VersionCheckResult {
395                software_item_id: Uuid::new_v4(),
396                installed_version: Some(format!("1.0.{i}")),
397                installed_display_version: None,
398                latest_version: Some(format!("2.0.{i}")),
399                error: None,
400                host_software_item_id: None,
401                update_category: uptrakit_shared_types::UpdateCategory::Unknown,
402                not_ready: None,
403            })
404            .collect();
405        let payload = VersionCheckResultsPayload { results };
406        let pages = paginate_payload(payload, &ReportPageLimits::default()).unwrap();
407        let total_items: usize = pages.iter().map(|p| p.payload.results.len()).sum();
408        assert_eq!(total_items, 5000);
409    }
410
411    #[test]
412    fn payload_respects_item_count_limit_even_when_under_size_threshold() {
413        let payload = VersionCheckResultsPayload {
414            results: (0..5)
415                .map(|i| VersionCheckResult {
416                    software_item_id: Uuid::new_v4(),
417                    installed_version: Some(format!("1.0.{i}")),
418                    installed_display_version: None,
419                    latest_version: Some(format!("2.0.{i}")),
420                    error: None,
421                    host_software_item_id: None,
422                    update_category: uptrakit_shared_types::UpdateCategory::Unknown,
423                    not_ready: None,
424                })
425                .collect(),
426        };
427        let limits = ReportPageLimits {
428            version_check_results: 2,
429            ..ReportPageLimits::default()
430        };
431
432        let pages = paginate_payload(payload, &limits).unwrap();
433
434        assert_eq!(pages.len(), 3);
435        assert_eq!(pages[0].payload.results.len(), 2);
436        assert_eq!(pages[1].payload.results.len(), 2);
437        assert_eq!(pages[2].payload.results.len(), 1);
438    }
439
440    // normalize() tests
441
442    #[test]
443    fn normalize_noop_when_within_limit() {
444        // A single plugin result with exactly MAX_DISCOVERIES_PER_PLUGIN items
445        // must not be split.
446        let result = make_discovery_result_with_count(MAX_DISCOVERIES_PER_PLUGIN);
447        let payload = DiscoveryResultsPayload {
448            host_machine_id: "machine-1".to_string(),
449            results: vec![result],
450        };
451        let normalized = payload.normalize();
452        assert_eq!(normalized.results.len(), 1);
453        assert_eq!(
454            normalized.results[0].discoveries.len(),
455            MAX_DISCOVERIES_PER_PLUGIN
456        );
457    }
458
459    #[test]
460    fn normalize_splits_oversized_plugin_result() {
461        // A single plugin result with MAX + 132 items (simulating the 1132-item
462        // APT case that triggered the original bug) must be split into chunks.
463        let total = MAX_DISCOVERIES_PER_PLUGIN + 132;
464        let result = make_discovery_result_with_count(total);
465        let config_id = result.plugin_config_id;
466        let payload = DiscoveryResultsPayload {
467            host_machine_id: "machine-1".to_string(),
468            results: vec![result],
469        };
470        let normalized = payload.normalize();
471
472        // Should have produced 2 chunks.
473        assert_eq!(normalized.results.len(), 2);
474        assert_eq!(
475            normalized.results[0].discoveries.len(),
476            MAX_DISCOVERIES_PER_PLUGIN
477        );
478        assert_eq!(normalized.results[1].discoveries.len(), 132);
479
480        // Metadata preserved on every chunk.
481        for chunk in &normalized.results {
482            assert_eq!(chunk.plugin_config_id, config_id);
483            assert_eq!(chunk.plugin_type, plugin_ids::PACKAGE_MANAGER_APT.clone());
484            assert!(chunk.error.is_none());
485        }
486
487        // No discovery is lost.
488        let total_after: usize = normalized.results.iter().map(|r| r.discoveries.len()).sum();
489        assert_eq!(total_after, total);
490    }
491
492    #[test]
493    fn normalize_splits_then_paginate_validates_cleanly() {
494        // End-to-end: a payload that would fail wire validation without
495        // normalization must pass after paginate_payload (which calls normalize).
496        let total = MAX_DISCOVERIES_PER_PLUGIN + 132;
497        let result = make_discovery_result_with_count(total);
498        let payload = DiscoveryResultsPayload {
499            host_machine_id: "machine-1".to_string(),
500            results: vec![result],
501        };
502
503        let pages = paginate_payload(payload, &ReportPageLimits::default()).unwrap();
504
505        // All resulting plugin results must respect the wire limit.
506        for page in &pages {
507            for r in &page.payload.results {
508                assert!(
509                    r.discoveries.len() <= MAX_DISCOVERIES_PER_PLUGIN,
510                    "chunk has {} discoveries, exceeds limit",
511                    r.discoveries.len()
512                );
513            }
514        }
515
516        // No discovery is lost.
517        let total_after: usize = pages
518            .iter()
519            .flat_map(|p| p.payload.results.iter())
520            .map(|r| r.discoveries.len())
521            .sum();
522        assert_eq!(total_after, total);
523    }
524
525    #[test]
526    fn normalize_preserves_error_on_all_chunks() {
527        let total = MAX_DISCOVERIES_PER_PLUGIN + 1;
528        let mut result = make_discovery_result_with_count(total);
529        result.error = Some("partial failure".to_string());
530        let payload = DiscoveryResultsPayload {
531            host_machine_id: "machine-1".to_string(),
532            results: vec![result],
533        };
534        let normalized = payload.normalize();
535        assert_eq!(normalized.results.len(), 2);
536        for chunk in &normalized.results {
537            assert_eq!(chunk.error.as_deref(), Some("partial failure"));
538        }
539    }
540}