1use super::{
11 display::{DisplayPrunePlan, DisplayPruneResult, Styles},
12 run_id_index::RunIdIndex,
13 store::StoreRunsDir,
14};
15use crate::{
16 errors::RecordPruneError, record::RecordedRunInfo, redact::Redactor,
17 user_config::elements::RecordConfig,
18};
19use bytesize::ByteSize;
20use chrono::{DateTime, TimeDelta, Utc};
21use quick_junit::ReportUuid;
22use std::{collections::HashSet, time::Duration};
23
24#[derive(Clone, Debug, Default)]
39pub struct RecordRetentionPolicy {
40 pub max_count: Option<usize>,
42
43 pub max_total_size: Option<ByteSize>,
45
46 pub max_age: Option<Duration>,
48}
49
50impl RecordRetentionPolicy {
51 pub(crate) fn compute_runs_to_delete(
59 &self,
60 runs: &[RecordedRunInfo],
61 now: DateTime<Utc>,
62 ) -> Vec<ReportUuid> {
63 let mut sorted_runs: Vec<_> = runs.iter().collect();
65 sorted_runs.sort_by_key(|run| std::cmp::Reverse(run.last_written_at));
66
67 let mut to_delete = Vec::new();
68 let mut kept_count = 0usize;
69 let mut kept_size = 0u64;
70
71 for run in sorted_runs {
72 let mut should_delete = false;
73
74 if let Some(max_count) = self.max_count
75 && kept_count >= max_count
76 {
77 should_delete = true;
78 }
79
80 if let Some(max_total_size) = self.max_total_size
81 && kept_size + run.sizes.total_compressed() > max_total_size.as_u64()
82 {
83 should_delete = true;
84 }
85
86 if let Some(max_age) = self.max_age {
87 let time_since_last_use = now
91 .signed_duration_since(run.last_written_at)
92 .max(TimeDelta::zero());
93 if time_since_last_use > TimeDelta::from_std(max_age).unwrap_or(TimeDelta::MAX) {
94 should_delete = true;
95 }
96 }
97
98 if should_delete {
99 to_delete.push(run.run_id);
100 } else {
101 kept_count += 1;
102 kept_size += run.sizes.total_compressed();
103 }
104 }
105
106 to_delete
107 }
108
109 pub(crate) fn limits_exceeded_by_factor(&self, runs: &[RecordedRunInfo], factor: f64) -> bool {
120 if let Some(max_count) = self.max_count {
122 let threshold = (max_count as f64 * factor) as usize;
123 if runs.len() > threshold {
124 return true;
125 }
126 }
127
128 if let Some(max_total_size) = self.max_total_size {
130 let total_size: u64 = runs.iter().map(|r| r.sizes.total_compressed()).sum();
131 let threshold = (max_total_size.as_u64() as f64 * factor) as u64;
132 if total_size > threshold {
133 return true;
134 }
135 }
136
137 false
138 }
139}
140
141impl From<&RecordConfig> for RecordRetentionPolicy {
142 fn from(config: &RecordConfig) -> Self {
143 Self {
144 max_count: Some(config.max_records),
145 max_total_size: Some(config.max_total_size),
146 max_age: Some(config.max_age),
147 }
148 }
149}
150
151#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
153pub enum PruneKind {
154 #[default]
156 Explicit,
157
158 Implicit,
160}
161
162#[derive(Debug, Default)]
164pub struct PruneResult {
165 pub kind: PruneKind,
167
168 pub deleted_count: usize,
170
171 pub orphans_deleted: usize,
177
178 pub freed_bytes: u64,
180
181 pub errors: Vec<RecordPruneError>,
186}
187
188impl PruneResult {
189 pub fn display<'a>(&'a self, styles: &'a Styles) -> DisplayPruneResult<'a> {
191 DisplayPruneResult {
192 result: self,
193 styles,
194 }
195 }
196}
197
198#[derive(Clone, Debug)]
203pub struct PrunePlan {
204 runs: Vec<RecordedRunInfo>,
206}
207
208impl PrunePlan {
209 pub(crate) fn new(mut runs: Vec<RecordedRunInfo>) -> Self {
213 runs.sort_by_key(|run| run.started_at);
214 Self { runs }
215 }
216
217 pub(super) fn compute(runs: &[RecordedRunInfo], policy: &RecordRetentionPolicy) -> Self {
218 let now = Utc::now();
219 let to_delete: HashSet<_> = policy
220 .compute_runs_to_delete(runs, now)
221 .into_iter()
222 .collect();
223
224 let runs_to_delete: Vec<_> = runs
225 .iter()
226 .filter(|r| to_delete.contains(&r.run_id))
227 .cloned()
228 .collect();
229
230 Self::new(runs_to_delete)
231 }
232
233 pub fn runs(&self) -> &[RecordedRunInfo] {
235 &self.runs
236 }
237
238 pub fn run_count(&self) -> usize {
240 self.runs.len()
241 }
242
243 pub fn total_bytes(&self) -> u64 {
245 self.runs.iter().map(|r| r.sizes.total_compressed()).sum()
246 }
247
248 pub fn display<'a>(
256 &'a self,
257 run_id_index: &'a RunIdIndex,
258 styles: &'a Styles,
259 redactor: &'a Redactor,
260 ) -> DisplayPrunePlan<'a> {
261 DisplayPrunePlan {
262 plan: self,
263 run_id_index,
264 styles,
265 redactor,
266 }
267 }
268}
269
270pub(crate) fn delete_runs(
280 runs_dir: StoreRunsDir<'_>,
281 runs: &mut Vec<RecordedRunInfo>,
282 to_delete: &HashSet<ReportUuid>,
283) -> PruneResult {
284 let mut result = PruneResult::default();
285
286 for run_id in to_delete {
287 let run_dir = runs_dir.run_dir(*run_id);
288
289 let size_bytes = runs
290 .iter()
291 .find(|r| &r.run_id == run_id)
292 .map(|r| r.sizes.total_compressed())
293 .unwrap_or(0);
294
295 match std::fs::remove_dir_all(&run_dir) {
296 Ok(()) => {
297 result.deleted_count += 1;
298 result.freed_bytes += size_bytes;
299 }
300 Err(error) => {
301 if error.kind() != std::io::ErrorKind::NotFound {
304 result.errors.push(RecordPruneError::DeleteRun {
305 run_id: *run_id,
306 path: run_dir,
307 error,
308 });
309 } else {
310 result.deleted_count += 1;
312 result.freed_bytes += size_bytes;
313 }
314 }
315 }
316 }
317
318 runs.retain(|run| !to_delete.contains(&run.run_id));
319
320 result
321}
322
323pub(crate) fn delete_orphaned_dirs(
329 runs_dir: StoreRunsDir<'_>,
330 known_runs: &HashSet<ReportUuid>,
331 result: &mut PruneResult,
332) {
333 let runs_path = runs_dir.as_path();
334 let entries = match runs_path.read_dir_utf8() {
335 Ok(entries) => entries,
336 Err(error) => {
337 if error.kind() != std::io::ErrorKind::NotFound {
339 result.errors.push(RecordPruneError::ReadRunsDir {
340 path: runs_path.to_owned(),
341 error,
342 });
343 }
344 return;
345 }
346 };
347
348 for entry in entries {
349 let entry = match entry {
350 Ok(entry) => entry,
351 Err(error) => {
352 result.errors.push(RecordPruneError::ReadDirEntry {
353 dir: runs_path.to_owned(),
354 error,
355 });
356 continue;
357 }
358 };
359
360 let entry_path = entry.path();
361 let file_type = match entry.file_type() {
362 Ok(ft) => ft,
363 Err(error) => {
364 result.errors.push(RecordPruneError::ReadFileType {
365 path: entry_path.to_owned(),
366 error,
367 });
368 continue;
369 }
370 };
371 if !file_type.is_dir() {
372 continue;
373 }
374
375 let dir_name = entry.file_name();
376 let run_id = match dir_name.parse::<ReportUuid>() {
377 Ok(id) => id,
378 Err(_) => continue,
380 };
381
382 if known_runs.contains(&run_id) {
383 continue;
384 }
385
386 let path = runs_dir.run_dir(run_id);
387 match std::fs::remove_dir_all(&path) {
388 Ok(()) => {
389 result.orphans_deleted += 1;
390 }
391 Err(error) => {
392 if error.kind() != std::io::ErrorKind::NotFound {
393 result
394 .errors
395 .push(RecordPruneError::DeleteOrphan { path, error });
396 }
397 }
398 }
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use crate::record::{
406 CompletedRunStats, ComponentSizes, RecordedRunStatus, RecordedSizes,
407 format::STORE_FORMAT_VERSION,
408 };
409 use chrono::{FixedOffset, TimeZone};
410 use semver::Version;
411 use std::collections::BTreeMap;
412
413 fn make_run(
414 run_id: ReportUuid,
415 started_at: DateTime<FixedOffset>,
416 total_compressed_size: u64,
417 status: RecordedRunStatus,
418 ) -> RecordedRunInfo {
419 RecordedRunInfo {
421 run_id,
422 store_format_version: STORE_FORMAT_VERSION,
423 nextest_version: Version::new(0, 1, 0),
424 started_at,
425 last_written_at: started_at,
426 duration_secs: Some(1.0),
427 cli_args: Vec::new(),
428 env_vars: BTreeMap::new(),
429 parent_run_id: None,
430 sizes: RecordedSizes {
431 log: ComponentSizes::default(),
432 store: ComponentSizes {
433 compressed: total_compressed_size,
434 uncompressed: total_compressed_size * 3,
436 entries: 0,
437 },
438 },
439 status,
440 }
441 }
442
443 fn completed_status() -> RecordedRunStatus {
445 RecordedRunStatus::Completed(CompletedRunStats {
446 initial_run_count: 10,
447 passed: 10,
448 failed: 0,
449 exit_code: 0,
450 })
451 }
452
453 fn incomplete_status() -> RecordedRunStatus {
455 RecordedRunStatus::Incomplete
456 }
457
458 const BASE_YEAR: i32 = 2024;
461
462 fn run_start_time(secs_offset: i64) -> DateTime<FixedOffset> {
464 FixedOffset::east_opt(0)
465 .unwrap()
466 .with_ymd_and_hms(BASE_YEAR, 1, 1, 0, 0, 0)
467 .unwrap()
468 + chrono::Duration::seconds(secs_offset)
469 }
470
471 fn now_time() -> DateTime<Utc> {
473 Utc.with_ymd_and_hms(BASE_YEAR, 1, 1, 0, 0, 0).unwrap() + chrono::Duration::days(60)
474 }
475
476 #[test]
477 fn test_no_limits_keeps_all() {
478 let policy = RecordRetentionPolicy::default();
479 let runs = vec![
480 make_run(
481 ReportUuid::new_v4(),
482 run_start_time(0),
483 1000,
484 completed_status(),
485 ),
486 make_run(
487 ReportUuid::new_v4(),
488 run_start_time(100),
489 2000,
490 completed_status(),
491 ),
492 ];
493
494 let to_delete = policy.compute_runs_to_delete(&runs, now_time());
495 assert!(to_delete.is_empty());
496 }
497
498 #[test]
499 fn test_incomplete_runs_not_automatically_deleted() {
500 let policy = RecordRetentionPolicy {
501 max_count: Some(2),
502 ..Default::default()
503 };
504
505 let oldest_id = ReportUuid::new_v4();
506 let runs = vec![
507 make_run(oldest_id, run_start_time(0), 1000, completed_status()),
508 make_run(
509 ReportUuid::new_v4(),
510 run_start_time(100),
511 2000,
512 incomplete_status(),
513 ), make_run(
515 ReportUuid::new_v4(),
516 run_start_time(200),
517 1000,
518 completed_status(),
519 ),
520 ];
521
522 let to_delete = policy.compute_runs_to_delete(&runs, now_time());
523 assert_eq!(to_delete.len(), 1);
524 assert_eq!(to_delete[0], oldest_id);
525 }
526
527 #[test]
528 fn test_count_limit() {
529 let policy = RecordRetentionPolicy {
530 max_count: Some(2),
531 ..Default::default()
532 };
533
534 let oldest_id = ReportUuid::new_v4();
535 let runs = vec![
536 make_run(oldest_id, run_start_time(0), 1000, completed_status()),
537 make_run(
538 ReportUuid::new_v4(),
539 run_start_time(100),
540 1000,
541 completed_status(),
542 ),
543 make_run(
544 ReportUuid::new_v4(),
545 run_start_time(200),
546 1000,
547 completed_status(),
548 ),
549 ];
550
551 let to_delete = policy.compute_runs_to_delete(&runs, now_time());
552 assert_eq!(to_delete.len(), 1);
553 assert_eq!(to_delete[0], oldest_id);
554 }
555
556 #[test]
557 fn test_size_limit() {
558 let policy = RecordRetentionPolicy {
559 max_total_size: Some(ByteSize::b(2500)),
560 ..Default::default()
561 };
562
563 let oldest_id = ReportUuid::new_v4();
564 let runs = vec![
565 make_run(oldest_id, run_start_time(0), 1000, completed_status()),
566 make_run(
567 ReportUuid::new_v4(),
568 run_start_time(100),
569 1000,
570 completed_status(),
571 ),
572 make_run(
573 ReportUuid::new_v4(),
574 run_start_time(200),
575 1000,
576 completed_status(),
577 ),
578 ];
579
580 let to_delete = policy.compute_runs_to_delete(&runs, now_time());
581 assert_eq!(to_delete.len(), 1);
582 assert_eq!(to_delete[0], oldest_id);
583 }
584
585 #[test]
586 fn test_age_limit() {
587 let policy = RecordRetentionPolicy {
588 max_age: Some(Duration::from_secs(30 * 24 * 60 * 60)), ..Default::default()
590 };
591
592 let old_id = ReportUuid::new_v4();
593 let runs = vec![
594 make_run(old_id, run_start_time(0), 1000, completed_status()), make_run(
596 ReportUuid::new_v4(),
597 run_start_time(45 * 24 * 60 * 60), 1000,
599 completed_status(),
600 ),
601 ];
602
603 let to_delete = policy.compute_runs_to_delete(&runs, now_time());
604 assert_eq!(to_delete.len(), 1);
605 assert_eq!(to_delete[0], old_id);
606 }
607
608 #[test]
609 fn test_combined_limits() {
610 let policy = RecordRetentionPolicy {
611 max_count: Some(2),
612 max_total_size: Some(ByteSize::b(2500)),
613 max_age: Some(Duration::from_secs(30 * 24 * 60 * 60)),
614 };
615
616 let old_id = ReportUuid::new_v4();
617 let runs = vec![
618 make_run(old_id, run_start_time(0), 1000, completed_status()), make_run(
620 ReportUuid::new_v4(),
621 run_start_time(45 * 24 * 60 * 60), 1000,
623 completed_status(),
624 ),
625 make_run(
626 ReportUuid::new_v4(),
627 run_start_time(50 * 24 * 60 * 60), 1000,
629 completed_status(),
630 ),
631 ];
632
633 let to_delete = policy.compute_runs_to_delete(&runs, now_time());
634 assert_eq!(to_delete.len(), 1);
635 assert_eq!(to_delete[0], old_id);
636 }
637
638 #[test]
639 fn test_from_record_config() {
640 let config = RecordConfig {
641 enabled: true,
642 max_records: 50,
643 max_total_size: ByteSize::gb(2),
644 max_age: Duration::from_secs(7 * 24 * 60 * 60),
645 max_output_size: ByteSize::mb(10),
646 };
647
648 let policy = RecordRetentionPolicy::from(&config);
649
650 assert_eq!(policy.max_count, Some(50));
651 assert_eq!(policy.max_total_size, Some(ByteSize::gb(2)));
652 assert_eq!(policy.max_age, Some(Duration::from_secs(7 * 24 * 60 * 60)));
653 }
654
655 #[test]
656 fn test_all_runs_deleted_by_age() {
657 let policy = RecordRetentionPolicy {
658 max_age: Some(Duration::from_secs(7 * 24 * 60 * 60)), ..Default::default()
660 };
661
662 let id1 = ReportUuid::new_v4();
663 let id2 = ReportUuid::new_v4();
664 let id3 = ReportUuid::new_v4();
665 let runs = vec![
666 make_run(id1, run_start_time(0), 1000, completed_status()), make_run(id2, run_start_time(100), 1000, completed_status()), make_run(id3, run_start_time(200), 1000, completed_status()), ];
670
671 let to_delete = policy.compute_runs_to_delete(&runs, now_time());
672 assert_eq!(to_delete.len(), 3, "all runs should be deleted");
673 assert!(to_delete.contains(&id1));
674 assert!(to_delete.contains(&id2));
675 assert!(to_delete.contains(&id3));
676 }
677
678 #[test]
679 fn test_all_runs_deleted_by_count_zero() {
680 let policy = RecordRetentionPolicy {
681 max_count: Some(0),
682 ..Default::default()
683 };
684
685 let id1 = ReportUuid::new_v4();
686 let id2 = ReportUuid::new_v4();
687 let runs = vec![
688 make_run(id1, run_start_time(0), 1000, completed_status()),
689 make_run(id2, run_start_time(100), 1000, completed_status()),
690 ];
691
692 let to_delete = policy.compute_runs_to_delete(&runs, now_time());
693 assert_eq!(
694 to_delete.len(),
695 2,
696 "all runs should be deleted with max_count=0"
697 );
698 assert!(to_delete.contains(&id1));
699 assert!(to_delete.contains(&id2));
700 }
701
702 #[test]
703 fn test_all_runs_deleted_by_size() {
704 let policy = RecordRetentionPolicy {
705 max_total_size: Some(ByteSize::b(0)),
706 ..Default::default()
707 };
708
709 let id1 = ReportUuid::new_v4();
710 let id2 = ReportUuid::new_v4();
711 let runs = vec![
712 make_run(id1, run_start_time(0), 1000, completed_status()),
713 make_run(id2, run_start_time(100), 1000, completed_status()),
714 ];
715
716 let to_delete = policy.compute_runs_to_delete(&runs, now_time());
717 assert_eq!(
718 to_delete.len(),
719 2,
720 "all runs should be deleted with max_total_size=0"
721 );
722 assert!(to_delete.contains(&id1));
723 assert!(to_delete.contains(&id2));
724 }
725
726 #[test]
727 fn test_empty_runs_list() {
728 let policy = RecordRetentionPolicy {
729 max_count: Some(5),
730 max_total_size: Some(ByteSize::mb(100)),
731 max_age: Some(Duration::from_secs(7 * 24 * 60 * 60)),
732 };
733
734 let runs: Vec<RecordedRunInfo> = vec![];
735 let to_delete = policy.compute_runs_to_delete(&runs, now_time());
736 assert!(
737 to_delete.is_empty(),
738 "empty input should return empty output"
739 );
740 }
741
742 #[test]
743 fn test_clock_skew_negative_age_saturates_to_zero() {
744 let policy = RecordRetentionPolicy {
748 max_age: Some(Duration::from_secs(7 * 24 * 60 * 60)), ..Default::default()
750 };
751
752 let future_start = run_start_time(61 * 24 * 60 * 60); let future_id = ReportUuid::new_v4();
755
756 let old_id = ReportUuid::new_v4();
758 let runs = vec![
759 make_run(old_id, run_start_time(0), 1000, completed_status()), make_run(future_id, future_start, 1000, completed_status()), ];
762
763 let to_delete = policy.compute_runs_to_delete(&runs, now_time());
764
765 assert_eq!(to_delete.len(), 1, "only old run should be deleted");
768 assert_eq!(to_delete[0], old_id);
769 assert!(
770 !to_delete.contains(&future_id),
771 "future run should be kept due to clock skew handling"
772 );
773 }
774
775 #[test]
776 fn test_limits_exceeded_by_factor() {
777 let count_policy = RecordRetentionPolicy {
779 max_count: Some(10),
780 ..Default::default()
781 };
782
783 let runs_15: Vec<_> = (0..15)
785 .map(|i| {
786 make_run(
787 ReportUuid::new_v4(),
788 run_start_time(i),
789 100,
790 completed_status(),
791 )
792 })
793 .collect();
794
795 assert!(
797 !count_policy.limits_exceeded_by_factor(&runs_15, 1.5),
798 "15 runs should not exceed 1.5x limit of 10"
799 );
800
801 let mut runs_16 = runs_15.clone();
803 runs_16.push(make_run(
804 ReportUuid::new_v4(),
805 run_start_time(16),
806 100,
807 completed_status(),
808 ));
809 assert!(
810 count_policy.limits_exceeded_by_factor(&runs_16, 1.5),
811 "16 runs should exceed 1.5x limit of 10"
812 );
813
814 let size_policy = RecordRetentionPolicy {
816 max_total_size: Some(ByteSize::b(1000)),
817 ..Default::default()
818 };
819
820 let runs_1500 = vec![make_run(
822 ReportUuid::new_v4(),
823 run_start_time(0),
824 1500,
825 completed_status(),
826 )];
827 assert!(
828 !size_policy.limits_exceeded_by_factor(&runs_1500, 1.5),
829 "1500 bytes should not exceed 1.5x limit of 1000"
830 );
831
832 let runs_1501 = vec![make_run(
834 ReportUuid::new_v4(),
835 run_start_time(0),
836 1501,
837 completed_status(),
838 )];
839 assert!(
840 size_policy.limits_exceeded_by_factor(&runs_1501, 1.5),
841 "1501 bytes should exceed 1.5x limit of 1000"
842 );
843
844 let no_limits_policy = RecordRetentionPolicy::default();
846 let many_runs: Vec<_> = (0..100)
847 .map(|i| {
848 make_run(
849 ReportUuid::new_v4(),
850 run_start_time(i),
851 1_000_000,
852 completed_status(),
853 )
854 })
855 .collect();
856 assert!(
857 !no_limits_policy.limits_exceeded_by_factor(&many_runs, 1.5),
858 "no limits set should never be exceeded"
859 );
860
861 let runs_empty: Vec<RecordedRunInfo> = vec![];
863 assert!(
864 !count_policy.limits_exceeded_by_factor(&runs_empty, 1.5),
865 "empty runs should not exceed limits"
866 );
867 }
868}