1use 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
33pub trait Paginatable: Serialize + Sized {
39 type Item: Serialize + Clone;
41
42 fn items(&self) -> &[Self::Item];
44
45 fn with_items(&self, items: Vec<Self::Item>) -> Self;
47
48 fn into_message(self) -> ServiceMessage;
50
51 fn max_items_per_page(limits: &ReportPageLimits) -> usize;
53
54 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 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#[derive(Debug)]
191pub struct PayloadPage<P> {
192 pub payload: P,
194 pub pagination: Option<ReportPagination>,
196}
197
198pub fn paginate_payload<P: Paginatable>(
212 payload: P,
213 limits: &ReportPageLimits,
214) -> Result<Vec<PayloadPage<P>>, serde_json::Error> {
215 let payload = payload.normalize();
218 let max_items_per_page = P::max_items_per_page(limits);
219 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 let empty_payload = payload.with_items(Vec::new());
240 let empty_json_len = serde_json::to_string(&empty_payload)?.len();
241 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 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 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 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 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 let total_items: usize = pages.iter().map(|p| p.payload.results.len()).sum();
373 assert_eq!(total_items, 5000);
374
375 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 #[test]
443 fn normalize_noop_when_within_limit() {
444 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 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 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 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 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 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 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 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}