use super::{
display::{DisplayPrunePlan, DisplayPruneResult, Styles},
run_id_index::RunIdIndex,
store::StoreRunsDir,
};
use crate::{
errors::RecordPruneError, record::RecordedRunInfo, redact::Redactor,
user_config::elements::RecordConfig,
};
use bytesize::ByteSize;
use chrono::{DateTime, TimeDelta, Utc};
use quick_junit::ReportUuid;
use std::{collections::HashSet, time::Duration};
#[derive(Clone, Debug, Default)]
pub struct RecordRetentionPolicy {
pub max_count: Option<usize>,
pub max_total_size: Option<ByteSize>,
pub max_age: Option<Duration>,
}
impl RecordRetentionPolicy {
pub(crate) fn compute_runs_to_delete(
&self,
runs: &[RecordedRunInfo],
now: DateTime<Utc>,
) -> Vec<ReportUuid> {
let mut sorted_runs: Vec<_> = runs.iter().collect();
sorted_runs.sort_by(|a, b| b.last_written_at.cmp(&a.last_written_at));
let mut to_delete = Vec::new();
let mut kept_count = 0usize;
let mut kept_size = 0u64;
for run in sorted_runs {
let mut should_delete = false;
if let Some(max_count) = self.max_count
&& kept_count >= max_count
{
should_delete = true;
}
if let Some(max_total_size) = self.max_total_size
&& kept_size + run.sizes.total_compressed() > max_total_size.as_u64()
{
should_delete = true;
}
if let Some(max_age) = self.max_age {
let time_since_last_use = now
.signed_duration_since(run.last_written_at)
.max(TimeDelta::zero());
if time_since_last_use > TimeDelta::from_std(max_age).unwrap_or(TimeDelta::MAX) {
should_delete = true;
}
}
if should_delete {
to_delete.push(run.run_id);
} else {
kept_count += 1;
kept_size += run.sizes.total_compressed();
}
}
to_delete
}
pub(crate) fn limits_exceeded_by_factor(&self, runs: &[RecordedRunInfo], factor: f64) -> bool {
if let Some(max_count) = self.max_count {
let threshold = (max_count as f64 * factor) as usize;
if runs.len() > threshold {
return true;
}
}
if let Some(max_total_size) = self.max_total_size {
let total_size: u64 = runs.iter().map(|r| r.sizes.total_compressed()).sum();
let threshold = (max_total_size.as_u64() as f64 * factor) as u64;
if total_size > threshold {
return true;
}
}
false
}
}
impl From<&RecordConfig> for RecordRetentionPolicy {
fn from(config: &RecordConfig) -> Self {
Self {
max_count: Some(config.max_records),
max_total_size: Some(config.max_total_size),
max_age: Some(config.max_age),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PruneKind {
#[default]
Explicit,
Implicit,
}
#[derive(Debug, Default)]
pub struct PruneResult {
pub kind: PruneKind,
pub deleted_count: usize,
pub orphans_deleted: usize,
pub freed_bytes: u64,
pub errors: Vec<RecordPruneError>,
}
impl PruneResult {
pub fn display<'a>(&'a self, styles: &'a Styles) -> DisplayPruneResult<'a> {
DisplayPruneResult {
result: self,
styles,
}
}
}
#[derive(Clone, Debug)]
pub struct PrunePlan {
runs: Vec<RecordedRunInfo>,
}
impl PrunePlan {
pub(crate) fn new(mut runs: Vec<RecordedRunInfo>) -> Self {
runs.sort_by(|a, b| a.started_at.cmp(&b.started_at));
Self { runs }
}
pub(super) fn compute(runs: &[RecordedRunInfo], policy: &RecordRetentionPolicy) -> Self {
let now = Utc::now();
let to_delete: HashSet<_> = policy
.compute_runs_to_delete(runs, now)
.into_iter()
.collect();
let runs_to_delete: Vec<_> = runs
.iter()
.filter(|r| to_delete.contains(&r.run_id))
.cloned()
.collect();
Self::new(runs_to_delete)
}
pub fn runs(&self) -> &[RecordedRunInfo] {
&self.runs
}
pub fn run_count(&self) -> usize {
self.runs.len()
}
pub fn total_bytes(&self) -> u64 {
self.runs.iter().map(|r| r.sizes.total_compressed()).sum()
}
pub fn display<'a>(
&'a self,
run_id_index: &'a RunIdIndex,
styles: &'a Styles,
redactor: &'a Redactor,
) -> DisplayPrunePlan<'a> {
DisplayPrunePlan {
plan: self,
run_id_index,
styles,
redactor,
}
}
}
pub(crate) fn delete_runs(
runs_dir: StoreRunsDir<'_>,
runs: &mut Vec<RecordedRunInfo>,
to_delete: &HashSet<ReportUuid>,
) -> PruneResult {
let mut result = PruneResult::default();
for run_id in to_delete {
let run_dir = runs_dir.run_dir(*run_id);
let size_bytes = runs
.iter()
.find(|r| &r.run_id == run_id)
.map(|r| r.sizes.total_compressed())
.unwrap_or(0);
match std::fs::remove_dir_all(&run_dir) {
Ok(()) => {
result.deleted_count += 1;
result.freed_bytes += size_bytes;
}
Err(error) => {
if error.kind() != std::io::ErrorKind::NotFound {
result.errors.push(RecordPruneError::DeleteRun {
run_id: *run_id,
path: run_dir,
error,
});
} else {
result.deleted_count += 1;
result.freed_bytes += size_bytes;
}
}
}
}
runs.retain(|run| !to_delete.contains(&run.run_id));
result
}
pub(crate) fn delete_orphaned_dirs(
runs_dir: StoreRunsDir<'_>,
known_runs: &HashSet<ReportUuid>,
result: &mut PruneResult,
) {
let runs_path = runs_dir.as_path();
let entries = match runs_path.read_dir_utf8() {
Ok(entries) => entries,
Err(error) => {
if error.kind() != std::io::ErrorKind::NotFound {
result.errors.push(RecordPruneError::ReadRunsDir {
path: runs_path.to_owned(),
error,
});
}
return;
}
};
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
result.errors.push(RecordPruneError::ReadDirEntry {
dir: runs_path.to_owned(),
error,
});
continue;
}
};
let entry_path = entry.path();
let file_type = match entry.file_type() {
Ok(ft) => ft,
Err(error) => {
result.errors.push(RecordPruneError::ReadFileType {
path: entry_path.to_owned(),
error,
});
continue;
}
};
if !file_type.is_dir() {
continue;
}
let dir_name = entry.file_name();
let run_id = match dir_name.parse::<ReportUuid>() {
Ok(id) => id,
Err(_) => continue,
};
if known_runs.contains(&run_id) {
continue;
}
let path = runs_dir.run_dir(run_id);
match std::fs::remove_dir_all(&path) {
Ok(()) => {
result.orphans_deleted += 1;
}
Err(error) => {
if error.kind() != std::io::ErrorKind::NotFound {
result
.errors
.push(RecordPruneError::DeleteOrphan { path, error });
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::record::{
CompletedRunStats, ComponentSizes, RecordedRunStatus, RecordedSizes,
format::STORE_FORMAT_VERSION,
};
use chrono::{FixedOffset, TimeZone};
use semver::Version;
use std::collections::BTreeMap;
fn make_run(
run_id: ReportUuid,
started_at: DateTime<FixedOffset>,
total_compressed_size: u64,
status: RecordedRunStatus,
) -> RecordedRunInfo {
RecordedRunInfo {
run_id,
store_format_version: STORE_FORMAT_VERSION,
nextest_version: Version::new(0, 1, 0),
started_at,
last_written_at: started_at,
duration_secs: Some(1.0),
cli_args: Vec::new(),
build_scope_args: Vec::new(),
env_vars: BTreeMap::new(),
parent_run_id: None,
sizes: RecordedSizes {
log: ComponentSizes::default(),
store: ComponentSizes {
compressed: total_compressed_size,
uncompressed: total_compressed_size * 3,
entries: 0,
},
},
status,
}
}
fn completed_status() -> RecordedRunStatus {
RecordedRunStatus::Completed(CompletedRunStats {
initial_run_count: 10,
passed: 10,
failed: 0,
exit_code: 0,
})
}
fn incomplete_status() -> RecordedRunStatus {
RecordedRunStatus::Incomplete
}
const BASE_YEAR: i32 = 2024;
fn run_start_time(secs_offset: i64) -> DateTime<FixedOffset> {
FixedOffset::east_opt(0)
.unwrap()
.with_ymd_and_hms(BASE_YEAR, 1, 1, 0, 0, 0)
.unwrap()
+ chrono::Duration::seconds(secs_offset)
}
fn now_time() -> DateTime<Utc> {
Utc.with_ymd_and_hms(BASE_YEAR, 1, 1, 0, 0, 0).unwrap() + chrono::Duration::days(60)
}
#[test]
fn test_no_limits_keeps_all() {
let policy = RecordRetentionPolicy::default();
let runs = vec![
make_run(
ReportUuid::new_v4(),
run_start_time(0),
1000,
completed_status(),
),
make_run(
ReportUuid::new_v4(),
run_start_time(100),
2000,
completed_status(),
),
];
let to_delete = policy.compute_runs_to_delete(&runs, now_time());
assert!(to_delete.is_empty());
}
#[test]
fn test_incomplete_runs_not_automatically_deleted() {
let policy = RecordRetentionPolicy {
max_count: Some(2),
..Default::default()
};
let oldest_id = ReportUuid::new_v4();
let runs = vec![
make_run(oldest_id, run_start_time(0), 1000, completed_status()),
make_run(
ReportUuid::new_v4(),
run_start_time(100),
2000,
incomplete_status(),
), make_run(
ReportUuid::new_v4(),
run_start_time(200),
1000,
completed_status(),
),
];
let to_delete = policy.compute_runs_to_delete(&runs, now_time());
assert_eq!(to_delete.len(), 1);
assert_eq!(to_delete[0], oldest_id);
}
#[test]
fn test_count_limit() {
let policy = RecordRetentionPolicy {
max_count: Some(2),
..Default::default()
};
let oldest_id = ReportUuid::new_v4();
let runs = vec![
make_run(oldest_id, run_start_time(0), 1000, completed_status()),
make_run(
ReportUuid::new_v4(),
run_start_time(100),
1000,
completed_status(),
),
make_run(
ReportUuid::new_v4(),
run_start_time(200),
1000,
completed_status(),
),
];
let to_delete = policy.compute_runs_to_delete(&runs, now_time());
assert_eq!(to_delete.len(), 1);
assert_eq!(to_delete[0], oldest_id);
}
#[test]
fn test_size_limit() {
let policy = RecordRetentionPolicy {
max_total_size: Some(ByteSize::b(2500)),
..Default::default()
};
let oldest_id = ReportUuid::new_v4();
let runs = vec![
make_run(oldest_id, run_start_time(0), 1000, completed_status()),
make_run(
ReportUuid::new_v4(),
run_start_time(100),
1000,
completed_status(),
),
make_run(
ReportUuid::new_v4(),
run_start_time(200),
1000,
completed_status(),
),
];
let to_delete = policy.compute_runs_to_delete(&runs, now_time());
assert_eq!(to_delete.len(), 1);
assert_eq!(to_delete[0], oldest_id);
}
#[test]
fn test_age_limit() {
let policy = RecordRetentionPolicy {
max_age: Some(Duration::from_secs(30 * 24 * 60 * 60)), ..Default::default()
};
let old_id = ReportUuid::new_v4();
let runs = vec![
make_run(old_id, run_start_time(0), 1000, completed_status()), make_run(
ReportUuid::new_v4(),
run_start_time(45 * 24 * 60 * 60), 1000,
completed_status(),
),
];
let to_delete = policy.compute_runs_to_delete(&runs, now_time());
assert_eq!(to_delete.len(), 1);
assert_eq!(to_delete[0], old_id);
}
#[test]
fn test_combined_limits() {
let policy = RecordRetentionPolicy {
max_count: Some(2),
max_total_size: Some(ByteSize::b(2500)),
max_age: Some(Duration::from_secs(30 * 24 * 60 * 60)),
};
let old_id = ReportUuid::new_v4();
let runs = vec![
make_run(old_id, run_start_time(0), 1000, completed_status()), make_run(
ReportUuid::new_v4(),
run_start_time(45 * 24 * 60 * 60), 1000,
completed_status(),
),
make_run(
ReportUuid::new_v4(),
run_start_time(50 * 24 * 60 * 60), 1000,
completed_status(),
),
];
let to_delete = policy.compute_runs_to_delete(&runs, now_time());
assert_eq!(to_delete.len(), 1);
assert_eq!(to_delete[0], old_id);
}
#[test]
fn test_from_record_config() {
let config = RecordConfig {
enabled: true,
max_records: 50,
max_total_size: ByteSize::gb(2),
max_age: Duration::from_secs(7 * 24 * 60 * 60),
max_output_size: ByteSize::mb(10),
};
let policy = RecordRetentionPolicy::from(&config);
assert_eq!(policy.max_count, Some(50));
assert_eq!(policy.max_total_size, Some(ByteSize::gb(2)));
assert_eq!(policy.max_age, Some(Duration::from_secs(7 * 24 * 60 * 60)));
}
#[test]
fn test_all_runs_deleted_by_age() {
let policy = RecordRetentionPolicy {
max_age: Some(Duration::from_secs(7 * 24 * 60 * 60)), ..Default::default()
};
let id1 = ReportUuid::new_v4();
let id2 = ReportUuid::new_v4();
let id3 = ReportUuid::new_v4();
let runs = vec![
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()), ];
let to_delete = policy.compute_runs_to_delete(&runs, now_time());
assert_eq!(to_delete.len(), 3, "all runs should be deleted");
assert!(to_delete.contains(&id1));
assert!(to_delete.contains(&id2));
assert!(to_delete.contains(&id3));
}
#[test]
fn test_all_runs_deleted_by_count_zero() {
let policy = RecordRetentionPolicy {
max_count: Some(0),
..Default::default()
};
let id1 = ReportUuid::new_v4();
let id2 = ReportUuid::new_v4();
let runs = vec![
make_run(id1, run_start_time(0), 1000, completed_status()),
make_run(id2, run_start_time(100), 1000, completed_status()),
];
let to_delete = policy.compute_runs_to_delete(&runs, now_time());
assert_eq!(
to_delete.len(),
2,
"all runs should be deleted with max_count=0"
);
assert!(to_delete.contains(&id1));
assert!(to_delete.contains(&id2));
}
#[test]
fn test_all_runs_deleted_by_size() {
let policy = RecordRetentionPolicy {
max_total_size: Some(ByteSize::b(0)),
..Default::default()
};
let id1 = ReportUuid::new_v4();
let id2 = ReportUuid::new_v4();
let runs = vec![
make_run(id1, run_start_time(0), 1000, completed_status()),
make_run(id2, run_start_time(100), 1000, completed_status()),
];
let to_delete = policy.compute_runs_to_delete(&runs, now_time());
assert_eq!(
to_delete.len(),
2,
"all runs should be deleted with max_total_size=0"
);
assert!(to_delete.contains(&id1));
assert!(to_delete.contains(&id2));
}
#[test]
fn test_empty_runs_list() {
let policy = RecordRetentionPolicy {
max_count: Some(5),
max_total_size: Some(ByteSize::mb(100)),
max_age: Some(Duration::from_secs(7 * 24 * 60 * 60)),
};
let runs: Vec<RecordedRunInfo> = vec![];
let to_delete = policy.compute_runs_to_delete(&runs, now_time());
assert!(
to_delete.is_empty(),
"empty input should return empty output"
);
}
#[test]
fn test_clock_skew_negative_age_saturates_to_zero() {
let policy = RecordRetentionPolicy {
max_age: Some(Duration::from_secs(7 * 24 * 60 * 60)), ..Default::default()
};
let future_start = run_start_time(61 * 24 * 60 * 60); let future_id = ReportUuid::new_v4();
let old_id = ReportUuid::new_v4();
let runs = vec![
make_run(old_id, run_start_time(0), 1000, completed_status()), make_run(future_id, future_start, 1000, completed_status()), ];
let to_delete = policy.compute_runs_to_delete(&runs, now_time());
assert_eq!(to_delete.len(), 1, "only old run should be deleted");
assert_eq!(to_delete[0], old_id);
assert!(
!to_delete.contains(&future_id),
"future run should be kept due to clock skew handling"
);
}
#[test]
fn test_limits_exceeded_by_factor() {
let count_policy = RecordRetentionPolicy {
max_count: Some(10),
..Default::default()
};
let runs_15: Vec<_> = (0..15)
.map(|i| {
make_run(
ReportUuid::new_v4(),
run_start_time(i),
100,
completed_status(),
)
})
.collect();
assert!(
!count_policy.limits_exceeded_by_factor(&runs_15, 1.5),
"15 runs should not exceed 1.5x limit of 10"
);
let mut runs_16 = runs_15.clone();
runs_16.push(make_run(
ReportUuid::new_v4(),
run_start_time(16),
100,
completed_status(),
));
assert!(
count_policy.limits_exceeded_by_factor(&runs_16, 1.5),
"16 runs should exceed 1.5x limit of 10"
);
let size_policy = RecordRetentionPolicy {
max_total_size: Some(ByteSize::b(1000)),
..Default::default()
};
let runs_1500 = vec![make_run(
ReportUuid::new_v4(),
run_start_time(0),
1500,
completed_status(),
)];
assert!(
!size_policy.limits_exceeded_by_factor(&runs_1500, 1.5),
"1500 bytes should not exceed 1.5x limit of 1000"
);
let runs_1501 = vec![make_run(
ReportUuid::new_v4(),
run_start_time(0),
1501,
completed_status(),
)];
assert!(
size_policy.limits_exceeded_by_factor(&runs_1501, 1.5),
"1501 bytes should exceed 1.5x limit of 1000"
);
let no_limits_policy = RecordRetentionPolicy::default();
let many_runs: Vec<_> = (0..100)
.map(|i| {
make_run(
ReportUuid::new_v4(),
run_start_time(i),
1_000_000,
completed_status(),
)
})
.collect();
assert!(
!no_limits_policy.limits_exceeded_by_factor(&many_runs, 1.5),
"no limits set should never be exceeded"
);
let runs_empty: Vec<RecordedRunInfo> = vec![];
assert!(
!count_policy.limits_exceeded_by_factor(&runs_empty, 1.5),
"empty runs should not exceed limits"
);
}
}