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(|a, b| b.last_written_at.cmp(&a.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(|a, b| a.started_at.cmp(&b.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::RECORD_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: RECORD_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 build_scope_args: Vec::new(),
429 env_vars: BTreeMap::new(),
430 parent_run_id: None,
431 sizes: RecordedSizes {
432 log: ComponentSizes::default(),
433 store: ComponentSizes {
434 compressed: total_compressed_size,
435 uncompressed: total_compressed_size * 3,
437 entries: 0,
438 },
439 },
440 status,
441 }
442 }
443
444 fn completed_status() -> RecordedRunStatus {
446 RecordedRunStatus::Completed(CompletedRunStats {
447 initial_run_count: 10,
448 passed: 10,
449 failed: 0,
450 exit_code: 0,
451 })
452 }
453
454 fn incomplete_status() -> RecordedRunStatus {
456 RecordedRunStatus::Incomplete
457 }
458
459 const BASE_YEAR: i32 = 2024;
462
463 fn run_start_time(secs_offset: i64) -> DateTime<FixedOffset> {
465 FixedOffset::east_opt(0)
466 .unwrap()
467 .with_ymd_and_hms(BASE_YEAR, 1, 1, 0, 0, 0)
468 .unwrap()
469 + chrono::Duration::seconds(secs_offset)
470 }
471
472 fn now_time() -> DateTime<Utc> {
474 Utc.with_ymd_and_hms(BASE_YEAR, 1, 1, 0, 0, 0).unwrap() + chrono::Duration::days(60)
475 }
476
477 #[test]
478 fn test_no_limits_keeps_all() {
479 let policy = RecordRetentionPolicy::default();
480 let runs = vec![
481 make_run(
482 ReportUuid::new_v4(),
483 run_start_time(0),
484 1000,
485 completed_status(),
486 ),
487 make_run(
488 ReportUuid::new_v4(),
489 run_start_time(100),
490 2000,
491 completed_status(),
492 ),
493 ];
494
495 let to_delete = policy.compute_runs_to_delete(&runs, now_time());
496 assert!(to_delete.is_empty());
497 }
498
499 #[test]
500 fn test_incomplete_runs_not_automatically_deleted() {
501 let policy = RecordRetentionPolicy {
502 max_count: Some(2),
503 ..Default::default()
504 };
505
506 let oldest_id = ReportUuid::new_v4();
507 let runs = vec![
508 make_run(oldest_id, run_start_time(0), 1000, completed_status()),
509 make_run(
510 ReportUuid::new_v4(),
511 run_start_time(100),
512 2000,
513 incomplete_status(),
514 ), make_run(
516 ReportUuid::new_v4(),
517 run_start_time(200),
518 1000,
519 completed_status(),
520 ),
521 ];
522
523 let to_delete = policy.compute_runs_to_delete(&runs, now_time());
524 assert_eq!(to_delete.len(), 1);
525 assert_eq!(to_delete[0], oldest_id);
526 }
527
528 #[test]
529 fn test_count_limit() {
530 let policy = RecordRetentionPolicy {
531 max_count: Some(2),
532 ..Default::default()
533 };
534
535 let oldest_id = ReportUuid::new_v4();
536 let runs = vec![
537 make_run(oldest_id, run_start_time(0), 1000, completed_status()),
538 make_run(
539 ReportUuid::new_v4(),
540 run_start_time(100),
541 1000,
542 completed_status(),
543 ),
544 make_run(
545 ReportUuid::new_v4(),
546 run_start_time(200),
547 1000,
548 completed_status(),
549 ),
550 ];
551
552 let to_delete = policy.compute_runs_to_delete(&runs, now_time());
553 assert_eq!(to_delete.len(), 1);
554 assert_eq!(to_delete[0], oldest_id);
555 }
556
557 #[test]
558 fn test_size_limit() {
559 let policy = RecordRetentionPolicy {
560 max_total_size: Some(ByteSize::b(2500)),
561 ..Default::default()
562 };
563
564 let oldest_id = ReportUuid::new_v4();
565 let runs = vec![
566 make_run(oldest_id, run_start_time(0), 1000, completed_status()),
567 make_run(
568 ReportUuid::new_v4(),
569 run_start_time(100),
570 1000,
571 completed_status(),
572 ),
573 make_run(
574 ReportUuid::new_v4(),
575 run_start_time(200),
576 1000,
577 completed_status(),
578 ),
579 ];
580
581 let to_delete = policy.compute_runs_to_delete(&runs, now_time());
582 assert_eq!(to_delete.len(), 1);
583 assert_eq!(to_delete[0], oldest_id);
584 }
585
586 #[test]
587 fn test_age_limit() {
588 let policy = RecordRetentionPolicy {
589 max_age: Some(Duration::from_secs(30 * 24 * 60 * 60)), ..Default::default()
591 };
592
593 let old_id = ReportUuid::new_v4();
594 let runs = vec![
595 make_run(old_id, run_start_time(0), 1000, completed_status()), make_run(
597 ReportUuid::new_v4(),
598 run_start_time(45 * 24 * 60 * 60), 1000,
600 completed_status(),
601 ),
602 ];
603
604 let to_delete = policy.compute_runs_to_delete(&runs, now_time());
605 assert_eq!(to_delete.len(), 1);
606 assert_eq!(to_delete[0], old_id);
607 }
608
609 #[test]
610 fn test_combined_limits() {
611 let policy = RecordRetentionPolicy {
612 max_count: Some(2),
613 max_total_size: Some(ByteSize::b(2500)),
614 max_age: Some(Duration::from_secs(30 * 24 * 60 * 60)),
615 };
616
617 let old_id = ReportUuid::new_v4();
618 let runs = vec![
619 make_run(old_id, run_start_time(0), 1000, completed_status()), make_run(
621 ReportUuid::new_v4(),
622 run_start_time(45 * 24 * 60 * 60), 1000,
624 completed_status(),
625 ),
626 make_run(
627 ReportUuid::new_v4(),
628 run_start_time(50 * 24 * 60 * 60), 1000,
630 completed_status(),
631 ),
632 ];
633
634 let to_delete = policy.compute_runs_to_delete(&runs, now_time());
635 assert_eq!(to_delete.len(), 1);
636 assert_eq!(to_delete[0], old_id);
637 }
638
639 #[test]
640 fn test_from_record_config() {
641 let config = RecordConfig {
642 enabled: true,
643 max_records: 50,
644 max_total_size: ByteSize::gb(2),
645 max_age: Duration::from_secs(7 * 24 * 60 * 60),
646 max_output_size: ByteSize::mb(10),
647 };
648
649 let policy = RecordRetentionPolicy::from(&config);
650
651 assert_eq!(policy.max_count, Some(50));
652 assert_eq!(policy.max_total_size, Some(ByteSize::gb(2)));
653 assert_eq!(policy.max_age, Some(Duration::from_secs(7 * 24 * 60 * 60)));
654 }
655
656 #[test]
657 fn test_all_runs_deleted_by_age() {
658 let policy = RecordRetentionPolicy {
659 max_age: Some(Duration::from_secs(7 * 24 * 60 * 60)), ..Default::default()
661 };
662
663 let id1 = ReportUuid::new_v4();
664 let id2 = ReportUuid::new_v4();
665 let id3 = ReportUuid::new_v4();
666 let runs = vec![
667 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()), ];
671
672 let to_delete = policy.compute_runs_to_delete(&runs, now_time());
673 assert_eq!(to_delete.len(), 3, "all runs should be deleted");
674 assert!(to_delete.contains(&id1));
675 assert!(to_delete.contains(&id2));
676 assert!(to_delete.contains(&id3));
677 }
678
679 #[test]
680 fn test_all_runs_deleted_by_count_zero() {
681 let policy = RecordRetentionPolicy {
682 max_count: Some(0),
683 ..Default::default()
684 };
685
686 let id1 = ReportUuid::new_v4();
687 let id2 = ReportUuid::new_v4();
688 let runs = vec![
689 make_run(id1, run_start_time(0), 1000, completed_status()),
690 make_run(id2, run_start_time(100), 1000, completed_status()),
691 ];
692
693 let to_delete = policy.compute_runs_to_delete(&runs, now_time());
694 assert_eq!(
695 to_delete.len(),
696 2,
697 "all runs should be deleted with max_count=0"
698 );
699 assert!(to_delete.contains(&id1));
700 assert!(to_delete.contains(&id2));
701 }
702
703 #[test]
704 fn test_all_runs_deleted_by_size() {
705 let policy = RecordRetentionPolicy {
706 max_total_size: Some(ByteSize::b(0)),
707 ..Default::default()
708 };
709
710 let id1 = ReportUuid::new_v4();
711 let id2 = ReportUuid::new_v4();
712 let runs = vec![
713 make_run(id1, run_start_time(0), 1000, completed_status()),
714 make_run(id2, run_start_time(100), 1000, completed_status()),
715 ];
716
717 let to_delete = policy.compute_runs_to_delete(&runs, now_time());
718 assert_eq!(
719 to_delete.len(),
720 2,
721 "all runs should be deleted with max_total_size=0"
722 );
723 assert!(to_delete.contains(&id1));
724 assert!(to_delete.contains(&id2));
725 }
726
727 #[test]
728 fn test_empty_runs_list() {
729 let policy = RecordRetentionPolicy {
730 max_count: Some(5),
731 max_total_size: Some(ByteSize::mb(100)),
732 max_age: Some(Duration::from_secs(7 * 24 * 60 * 60)),
733 };
734
735 let runs: Vec<RecordedRunInfo> = vec![];
736 let to_delete = policy.compute_runs_to_delete(&runs, now_time());
737 assert!(
738 to_delete.is_empty(),
739 "empty input should return empty output"
740 );
741 }
742
743 #[test]
744 fn test_clock_skew_negative_age_saturates_to_zero() {
745 let policy = RecordRetentionPolicy {
749 max_age: Some(Duration::from_secs(7 * 24 * 60 * 60)), ..Default::default()
751 };
752
753 let future_start = run_start_time(61 * 24 * 60 * 60); let future_id = ReportUuid::new_v4();
756
757 let old_id = ReportUuid::new_v4();
759 let runs = vec![
760 make_run(old_id, run_start_time(0), 1000, completed_status()), make_run(future_id, future_start, 1000, completed_status()), ];
763
764 let to_delete = policy.compute_runs_to_delete(&runs, now_time());
765
766 assert_eq!(to_delete.len(), 1, "only old run should be deleted");
769 assert_eq!(to_delete[0], old_id);
770 assert!(
771 !to_delete.contains(&future_id),
772 "future run should be kept due to clock skew handling"
773 );
774 }
775
776 #[test]
777 fn test_limits_exceeded_by_factor() {
778 let count_policy = RecordRetentionPolicy {
780 max_count: Some(10),
781 ..Default::default()
782 };
783
784 let runs_15: Vec<_> = (0..15)
786 .map(|i| {
787 make_run(
788 ReportUuid::new_v4(),
789 run_start_time(i),
790 100,
791 completed_status(),
792 )
793 })
794 .collect();
795
796 assert!(
798 !count_policy.limits_exceeded_by_factor(&runs_15, 1.5),
799 "15 runs should not exceed 1.5x limit of 10"
800 );
801
802 let mut runs_16 = runs_15.clone();
804 runs_16.push(make_run(
805 ReportUuid::new_v4(),
806 run_start_time(16),
807 100,
808 completed_status(),
809 ));
810 assert!(
811 count_policy.limits_exceeded_by_factor(&runs_16, 1.5),
812 "16 runs should exceed 1.5x limit of 10"
813 );
814
815 let size_policy = RecordRetentionPolicy {
817 max_total_size: Some(ByteSize::b(1000)),
818 ..Default::default()
819 };
820
821 let runs_1500 = vec![make_run(
823 ReportUuid::new_v4(),
824 run_start_time(0),
825 1500,
826 completed_status(),
827 )];
828 assert!(
829 !size_policy.limits_exceeded_by_factor(&runs_1500, 1.5),
830 "1500 bytes should not exceed 1.5x limit of 1000"
831 );
832
833 let runs_1501 = vec![make_run(
835 ReportUuid::new_v4(),
836 run_start_time(0),
837 1501,
838 completed_status(),
839 )];
840 assert!(
841 size_policy.limits_exceeded_by_factor(&runs_1501, 1.5),
842 "1501 bytes should exceed 1.5x limit of 1000"
843 );
844
845 let no_limits_policy = RecordRetentionPolicy::default();
847 let many_runs: Vec<_> = (0..100)
848 .map(|i| {
849 make_run(
850 ReportUuid::new_v4(),
851 run_start_time(i),
852 1_000_000,
853 completed_status(),
854 )
855 })
856 .collect();
857 assert!(
858 !no_limits_policy.limits_exceeded_by_factor(&many_runs, 1.5),
859 "no limits set should never be exceeded"
860 );
861
862 let runs_empty: Vec<RecordedRunInfo> = vec![];
864 assert!(
865 !count_policy.limits_exceeded_by_factor(&runs_empty, 1.5),
866 "empty runs should not exceed limits"
867 );
868 }
869}