use std::path::PathBuf;
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::Serialize;
use crate::cli::drive::format::JsonlSerialize;
use crate::drive::client::DriveClient;
use crate::drive::error::DriveError;
use crate::drive::files_api::FilesApi;
use crate::drive::lease::ledger::{
offload_short_blocking_io, LeaseBackup, LeaseLedger, LeaseRecord, LedgerLock,
};
pub struct PruneOptions {
pub older_than: Option<DateTime<Utc>>,
pub max_size: Option<u64>,
pub dry_run: bool,
pub ledger_path: PathBuf,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct PruneOutcome {
pub removed: usize,
pub kept: usize,
pub bytes_freed: u64,
pub trashed_drive_copies: usize,
pub failed: usize,
}
impl JsonlSerialize for PruneOutcome {
fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<()> {
crate::cli::drive::format::write_scalar_jsonl(self, out)
}
}
fn backup_size(backup: &LeaseBackup) -> u64 {
match backup {
LeaseBackup::Bytes { size, .. } => *size,
LeaseBackup::DriveCopy { .. } => 0,
}
}
fn is_drive_not_found(err: &anyhow::Error) -> bool {
matches!(
err.downcast_ref::<DriveError>(),
Some(DriveError::ApiRequestFailed { status: 404, .. })
)
}
pub(super) async fn clear_backup(files_api: &FilesApi<'_>, backup: &LeaseBackup) -> Result<()> {
match backup {
LeaseBackup::Bytes { path, .. } => match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
},
LeaseBackup::DriveCopy { file_id } => match files_api.trash(file_id).await {
Ok(_) => Ok(()),
Err(e) if is_drive_not_found(&e) => Ok(()),
Err(e) => Err(e),
},
}
}
fn account_removal(outcome: &mut PruneOutcome, backup: &LeaseBackup) {
outcome.removed += 1;
match backup {
LeaseBackup::Bytes { size, .. } => outcome.bytes_freed += size,
LeaseBackup::DriveCopy { .. } => outcome.trashed_drive_copies += 1,
}
}
fn record_prune_attempt(rec: &LeaseRecord, verdict: &str, error: Option<String>) {
let (backup_location, backup_sha256, backup_size) = rec.backup.audit_fields();
let outcome = crate::request_log::AuditOutcome {
command: vec!["drive".to_string(), "lease-prune".to_string()],
integration: "drive",
file_id: rec.file_id.clone(),
lease_id: Some(rec.token.clone()),
verdict: verdict.to_string(),
backup_location,
backup_sha256,
backup_size,
error,
..Default::default()
};
if let Err(err) = crate::request_log::record_audit_event(outcome) {
tracing::warn!("drive lease prune: failed to write audit record: {err}");
}
}
fn keep_count_by_size(sorted_desc: &[&LeaseRecord], max: u64) -> usize {
let mut acc = 0u64;
let mut keep = 0usize;
for rec in sorted_desc {
acc += backup_size(&rec.backup);
if acc > max {
break;
}
keep += 1;
}
if keep == 0 && !sorted_desc.is_empty() {
keep = 1;
}
keep
}
fn is_unresolved_supersede_orphan(rec: &LeaseRecord) -> bool {
rec.superseded_by.is_some() && rec.restored_at.is_none()
}
fn compute_removals(opts: &PruneOptions) -> Result<(usize, Vec<LeaseRecord>)> {
let now = Utc::now();
let _lock = LedgerLock::acquire(&opts.ledger_path)?;
let ledger = LeaseLedger::load(&opts.ledger_path)?;
let (live_count, non_live): (usize, Vec<LeaseRecord>) = {
let mut live_count = 0usize;
let mut non_live = Vec::new();
for rec in ledger.iter() {
if rec.is_live(now) {
live_count += 1;
} else {
non_live.push(rec.clone());
}
}
(live_count, non_live)
};
let (mut age_survivors, mut removed): (Vec<LeaseRecord>, Vec<LeaseRecord>) =
non_live.into_iter().partition(|rec| match opts.older_than {
Some(cutoff) => rec.expires_at >= cutoff,
None => true,
});
if let Some(max) = opts.max_size {
let (mut bytes_survivors, budget_exempt): (Vec<LeaseRecord>, Vec<LeaseRecord>) =
age_survivors.into_iter().partition(|rec| {
matches!(rec.backup, LeaseBackup::Bytes { .. })
&& !is_unresolved_supersede_orphan(rec)
});
bytes_survivors.sort_by_key(|rec| std::cmp::Reverse(rec.expires_at));
let refs: Vec<&LeaseRecord> = bytes_survivors.iter().collect();
let keep = keep_count_by_size(&refs, max);
removed.extend(bytes_survivors.split_off(keep));
age_survivors = bytes_survivors;
age_survivors.extend(budget_exempt);
}
Ok((live_count + age_survivors.len(), removed))
}
pub fn plan(opts: &PruneOptions) -> Result<PruneOutcome> {
let (kept, removed) = compute_removals(opts)?;
let mut outcome = PruneOutcome {
kept,
..Default::default()
};
for rec in &removed {
account_removal(&mut outcome, &rec.backup);
}
Ok(outcome)
}
pub async fn prune(client: &DriveClient, opts: &PruneOptions) -> Result<PruneOutcome> {
let (kept_after_filters, removed) = compute_removals(opts)?;
let mut outcome = PruneOutcome {
kept: kept_after_filters,
..Default::default()
};
if opts.dry_run {
for rec in &removed {
account_removal(&mut outcome, &rec.backup);
}
return Ok(outcome);
}
let files_api = FilesApi::new(client);
for rec in &removed {
let lock = offload_short_blocking_io(|| match LedgerLock::acquire(&opts.ledger_path) {
Ok(lock) => Some(lock),
Err(err) => {
tracing::warn!(
"drive lease prune: failed to lock the ledger to remove lease {} (file \
{}): {err:#}; leaving it for a future prune",
rec.token,
rec.file_id
);
record_prune_attempt(rec, "prune-failed", Some(err.to_string()));
None
}
});
let Some(lock) = lock else {
outcome.failed += 1;
outcome.kept += 1;
continue;
};
match clear_backup(&files_api, &rec.backup).await {
Ok(()) => {
let mutated = offload_short_blocking_io(|| {
let result = LeaseLedger::mutate(&lock, &opts.ledger_path, |ledger| {
ledger.remove(&rec.token);
});
if result.is_ok() {
record_prune_attempt(rec, "pruned", None);
}
result
});
match mutated {
Ok(()) => account_removal(&mut outcome, &rec.backup),
Err(err) => {
return Err(err.context(format!(
"drive lease prune: cleared the backup for lease {} but failed to \
persist its ledger removal — that row may now dangle, pointing at \
a backup that no longer exists; remove it from the ledger by hand",
rec.token
)));
}
}
}
Err(err) => {
offload_short_blocking_io(|| {
tracing::warn!(
"drive lease prune: failed to clear the backup for lease {} (file {}): \
{err:#}; leaving it for a future prune",
rec.token,
rec.file_id
);
record_prune_attempt(rec, "prune-failed", Some(err.to_string()));
});
outcome.failed += 1;
outcome.kept += 1;
}
}
drop(lock);
}
Ok(outcome)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
use crate::test_support::AuditLogGuard as AuditGuard;
use crate::utils::secret::Secret;
use chrono::Duration as ChronoDuration;
fn test_credentials() -> DriveCredentials {
DriveCredentials {
client_id: "client-1".to_string(),
client_secret: Secret::new("secret-1"),
refresh_token: Secret::new("refresh-1"),
scope: DriveGrantedScopes::READONLY,
}
}
async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/token"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "test-token",
"expires_in": 3600,
})),
)
.mount(server)
.await;
let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
crate::drive::client::test_support::replace_session(
&mut client,
&test_credentials(),
&format!("{}/token", server.uri()),
);
client
}
fn bytes_record(
token: &str,
expires_at: DateTime<Utc>,
size: u64,
path: PathBuf,
) -> LeaseRecord {
LeaseRecord {
token: token.to_string(),
file_id: format!("file-{token}"),
version: "1".to_string(),
modified_time: None,
backup: LeaseBackup::Bytes {
path,
sha256: "deadbeef".to_string(),
size,
},
acquired_at: expires_at - ChronoDuration::minutes(30),
expires_at,
released_at: None,
superseded_by: None,
restored_at: None,
restored_sheet_id: None,
}
}
fn drive_copy_record(token: &str, expires_at: DateTime<Utc>, file_id: &str) -> LeaseRecord {
LeaseRecord {
token: token.to_string(),
file_id: format!("file-{token}"),
version: "1".to_string(),
modified_time: None,
backup: LeaseBackup::DriveCopy {
file_id: file_id.to_string(),
},
acquired_at: expires_at - ChronoDuration::minutes(30),
expires_at,
released_at: None,
superseded_by: None,
restored_at: None,
restored_sheet_id: None,
}
}
#[test]
fn backup_size_is_zero_for_a_drive_copy() {
assert_eq!(
backup_size(&LeaseBackup::DriveCopy {
file_id: "drive-copy-1".to_string(),
}),
0
);
}
#[test]
fn prune_outcome_serializes_to_jsonl() {
let mut buf = Vec::new();
PruneOutcome {
removed: 1,
kept: 2,
bytes_freed: 3,
trashed_drive_copies: 1,
failed: 0,
}
.write_jsonl(&mut buf)
.unwrap();
let text = String::from_utf8(buf).unwrap();
assert!(text.contains("\"removed\":1"), "{text}");
assert!(text.contains("\"kept\":2"), "{text}");
}
#[tokio::test]
async fn a_live_lease_is_never_pruned_regardless_of_bounds() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let backup_path = dir.path().join("live-backup");
std::fs::write(&backup_path, b"x").unwrap();
let mut ledger = LeaseLedger::default();
ledger.insert(bytes_record(
"live",
Utc::now() + ChronoDuration::hours(1),
1,
backup_path.clone(),
));
ledger.save(&ledger_path).unwrap();
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let outcome = prune(
&client,
&PruneOptions {
older_than: Some(Utc::now() + ChronoDuration::days(365)),
max_size: Some(0),
dry_run: false,
ledger_path: ledger_path.clone(),
},
)
.await
.unwrap();
assert_eq!(outcome.removed, 0);
assert_eq!(outcome.kept, 1);
assert!(backup_path.exists());
assert_eq!(LeaseLedger::load(&ledger_path).unwrap().iter().count(), 1);
}
#[tokio::test]
async fn releasing_a_row_early_puts_its_bytes_into_the_max_size_budget() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let released_backup = dir.path().join("released-backup");
let older_backup = dir.path().join("older-backup");
std::fs::write(&released_backup, b"xx").unwrap();
std::fs::write(&older_backup, b"xx").unwrap();
let mut ledger = LeaseLedger::default();
let mut released = bytes_record(
"released",
Utc::now() + ChronoDuration::hours(1),
2,
released_backup.clone(),
);
released.released_at = Some(Utc::now());
ledger.insert(released);
ledger.insert(bytes_record(
"older",
Utc::now() - ChronoDuration::hours(1),
2,
older_backup.clone(),
));
ledger.save(&ledger_path).unwrap();
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let outcome = prune(
&client,
&PruneOptions {
older_than: None,
max_size: Some(2),
dry_run: false,
ledger_path: ledger_path.clone(),
},
)
.await
.unwrap();
assert_eq!(outcome.removed, 1);
assert!(
released_backup.exists(),
"the released row expires furthest in the future, so it is the last evicted"
);
assert!(
!older_backup.exists(),
"the released row's bytes now count against the budget, evicting the older row"
);
}
#[tokio::test]
async fn max_size_never_evicts_a_bytes_backup_orphaned_by_a_failed_supersede() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let orphan_backup = dir.path().join("orphan-backup");
let superseder_backup = dir.path().join("superseder-backup");
let newer_backup = dir.path().join("newer-backup");
std::fs::write(&orphan_backup, b"xx").unwrap();
std::fs::write(&superseder_backup, b"xx").unwrap();
std::fs::write(&newer_backup, b"xx").unwrap();
let mut ledger = LeaseLedger::default();
let mut orphan = bytes_record(
"old-real-backup",
Utc::now() - ChronoDuration::hours(3),
2,
orphan_backup.clone(),
);
orphan.released_at = Some(Utc::now() - ChronoDuration::hours(3));
orphan.superseded_by = Some("fresh-token".to_string());
ledger.insert(orphan);
ledger.insert(bytes_record(
"fresh-token",
Utc::now() - ChronoDuration::hours(2),
2,
superseder_backup.clone(),
));
ledger.insert(bytes_record(
"newer-unrelated",
Utc::now() - ChronoDuration::hours(1),
2,
newer_backup.clone(),
));
ledger.save(&ledger_path).unwrap();
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let outcome = prune(
&client,
&PruneOptions {
older_than: None,
max_size: Some(2),
dry_run: false,
ledger_path: ledger_path.clone(),
},
)
.await
.unwrap();
assert_eq!(outcome.removed, 1);
assert!(
orphan_backup.exists(),
"the unresolved supersede orphan must survive regardless of the budget"
);
assert!(
newer_backup.exists(),
"the more-recently-expired unrelated row wins the budget fairly"
);
assert!(
!superseder_backup.exists(),
"the superseding lease's own backup is not exempt and loses to the newer row"
);
}
#[tokio::test]
async fn max_size_still_competes_a_successfully_restored_supersede_source() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let restored_backup = dir.path().join("restored-backup");
let superseder_backup = dir.path().join("superseder-backup");
std::fs::write(&restored_backup, b"xx").unwrap();
std::fs::write(&superseder_backup, b"xx").unwrap();
let mut ledger = LeaseLedger::default();
let mut restored = bytes_record(
"old-real-backup",
Utc::now() - ChronoDuration::hours(2),
2,
restored_backup.clone(),
);
restored.released_at = Some(Utc::now() - ChronoDuration::hours(2));
restored.superseded_by = Some("fresh-token".to_string());
restored.restored_at = Some(Utc::now() - ChronoDuration::hours(2));
ledger.insert(restored);
ledger.insert(bytes_record(
"fresh-token",
Utc::now() - ChronoDuration::hours(1),
2,
superseder_backup.clone(),
));
ledger.save(&ledger_path).unwrap();
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let outcome = prune(
&client,
&PruneOptions {
older_than: None,
max_size: Some(2),
dry_run: false,
ledger_path: ledger_path.clone(),
},
)
.await
.unwrap();
assert_eq!(outcome.removed, 1);
assert!(
superseder_backup.exists(),
"the superseding lease expires furthest in the future, so it wins the budget"
);
assert!(
!restored_backup.exists(),
"a successfully-restored-from row is not exempted — it competes normally"
);
}
#[tokio::test]
async fn older_than_still_reaches_an_unresolved_supersede_orphan() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let orphan_backup = dir.path().join("orphan-backup");
std::fs::write(&orphan_backup, b"x").unwrap();
let mut ledger = LeaseLedger::default();
let mut orphan = bytes_record(
"old-real-backup",
Utc::now() - ChronoDuration::days(10),
1,
orphan_backup.clone(),
);
orphan.released_at = Some(Utc::now() - ChronoDuration::days(10));
orphan.superseded_by = Some("fresh-token".to_string());
ledger.insert(orphan);
ledger.save(&ledger_path).unwrap();
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let outcome = prune(
&client,
&PruneOptions {
older_than: Some(Utc::now() - ChronoDuration::days(1)),
max_size: None,
dry_run: false,
ledger_path: ledger_path.clone(),
},
)
.await
.unwrap();
assert_eq!(outcome.removed, 1);
assert!(!orphan_backup.exists());
}
#[tokio::test]
async fn older_than_alone_drops_only_rows_past_the_cutoff() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let old_backup = dir.path().join("old-backup");
let recent_backup = dir.path().join("recent-backup");
std::fs::write(&old_backup, b"old").unwrap();
std::fs::write(&recent_backup, b"recent").unwrap();
let mut ledger = LeaseLedger::default();
ledger.insert(bytes_record(
"old",
Utc::now() - ChronoDuration::days(10),
3,
old_backup.clone(),
));
ledger.insert(bytes_record(
"recent",
Utc::now() - ChronoDuration::hours(1),
6,
recent_backup.clone(),
));
ledger.save(&ledger_path).unwrap();
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let outcome = prune(
&client,
&PruneOptions {
older_than: Some(Utc::now() - ChronoDuration::days(1)),
max_size: None,
dry_run: false,
ledger_path: ledger_path.clone(),
},
)
.await
.unwrap();
assert_eq!(outcome.removed, 1);
assert_eq!(outcome.kept, 1);
assert_eq!(outcome.bytes_freed, 3);
assert!(!old_backup.exists());
assert!(recent_backup.exists());
let reloaded = LeaseLedger::load(&ledger_path).unwrap();
assert!(reloaded.get("old").is_none());
assert!(reloaded.get("recent").is_some());
}
#[tokio::test]
async fn older_than_boundary_keeps_a_row_expiring_exactly_at_the_cutoff() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let backup_path = dir.path().join("backup");
std::fs::write(&backup_path, b"x").unwrap();
let cutoff = Utc::now() - ChronoDuration::days(1);
let mut ledger = LeaseLedger::default();
ledger.insert(bytes_record(
"on-the-boundary",
cutoff,
1,
backup_path.clone(),
));
ledger.save(&ledger_path).unwrap();
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let outcome = prune(
&client,
&PruneOptions {
older_than: Some(cutoff),
max_size: None,
dry_run: false,
ledger_path: ledger_path.clone(),
},
)
.await
.unwrap();
assert_eq!(
outcome.removed, 0,
"a row expiring exactly at the cutoff must survive, mirroring \
`request_log::keep_by_age`'s inclusive `>=` boundary"
);
assert_eq!(outcome.kept, 1);
assert!(backup_path.exists());
assert!(LeaseLedger::load(&ledger_path)
.unwrap()
.get("on-the-boundary")
.is_some());
}
#[tokio::test]
async fn a_mixed_batch_persists_successful_removals_independently_of_a_failing_one() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let mut ledger = LeaseLedger::default();
ledger.insert(drive_copy_record(
"ok",
Utc::now() - ChronoDuration::hours(2),
"drive-copy-ok",
));
ledger.insert(drive_copy_record(
"bad",
Utc::now() - ChronoDuration::hours(2),
"drive-copy-bad",
));
ledger.save(&ledger_path).unwrap();
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/drive/v3/files/drive-copy-ok"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "drive-copy-ok", "name": "backup", "trashed": true,
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/drive/v3/files/drive-copy-bad"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let outcome = prune(
&client,
&PruneOptions {
older_than: Some(Utc::now()),
max_size: None,
dry_run: false,
ledger_path: ledger_path.clone(),
},
)
.await
.unwrap();
assert_eq!(outcome.removed, 1);
assert_eq!(outcome.failed, 1);
assert_eq!(outcome.kept, 1);
let reloaded = LeaseLedger::load(&ledger_path).unwrap();
assert!(
reloaded.get("ok").is_none(),
"the successful removal must be persisted regardless of the other row's outcome"
);
assert!(reloaded.get("bad").is_some());
}
#[tokio::test]
async fn max_size_alone_keeps_the_most_recently_expired_that_fit() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let b1 = dir.path().join("b1");
let b2 = dir.path().join("b2");
let b3 = dir.path().join("b3");
for p in [&b1, &b2, &b3] {
std::fs::write(p, b"x").unwrap();
}
let mut ledger = LeaseLedger::default();
ledger.insert(bytes_record(
"t1",
Utc::now() - ChronoDuration::hours(3),
10,
b1.clone(),
));
ledger.insert(bytes_record(
"t2",
Utc::now() - ChronoDuration::hours(2),
10,
b2.clone(),
));
ledger.insert(bytes_record(
"t3",
Utc::now() - ChronoDuration::hours(1),
10,
b3.clone(),
));
ledger.save(&ledger_path).unwrap();
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let outcome = prune(
&client,
&PruneOptions {
older_than: None,
max_size: Some(20),
dry_run: false,
ledger_path: ledger_path.clone(),
},
)
.await
.unwrap();
assert_eq!(outcome.removed, 1);
assert_eq!(outcome.bytes_freed, 10);
assert!(!b1.exists(), "the oldest-expiring backup should be dropped");
assert!(b2.exists());
assert!(b3.exists());
let reloaded = LeaseLedger::load(&ledger_path).unwrap();
assert!(reloaded.get("t1").is_none());
assert!(reloaded.get("t2").is_some());
assert!(reloaded.get("t3").is_some());
}
#[tokio::test]
async fn max_size_alone_never_prunes_a_drive_copy_since_it_is_zero_bytes() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let mut ledger = LeaseLedger::default();
ledger.insert(drive_copy_record(
"copy",
Utc::now() - ChronoDuration::hours(2),
"drive-copy-1",
));
ledger.save(&ledger_path).unwrap();
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let outcome = prune(
&client,
&PruneOptions {
older_than: None,
max_size: Some(0),
dry_run: false,
ledger_path: ledger_path.clone(),
},
)
.await
.unwrap();
assert_eq!(outcome.removed, 0);
assert_eq!(outcome.kept, 1);
assert!(LeaseLedger::load(&ledger_path)
.unwrap()
.get("copy")
.is_some());
}
#[tokio::test]
async fn max_size_never_trashes_a_drive_copy_sorted_behind_an_oversized_bytes_backup() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let big_backup = dir.path().join("big-backup");
std::fs::write(&big_backup, b"x").unwrap();
let mut ledger = LeaseLedger::default();
ledger.insert(bytes_record(
"big",
Utc::now() - ChronoDuration::hours(1),
1_000_000,
big_backup.clone(),
));
ledger.insert(drive_copy_record(
"copy",
Utc::now() - ChronoDuration::hours(2),
"drive-copy-1",
));
ledger.save(&ledger_path).unwrap();
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let outcome = prune(
&client,
&PruneOptions {
older_than: None,
max_size: Some(1), dry_run: false,
ledger_path: ledger_path.clone(),
},
)
.await
.unwrap();
assert_eq!(outcome.removed, 0);
assert_eq!(outcome.trashed_drive_copies, 0);
let reloaded = LeaseLedger::load(&ledger_path).unwrap();
assert!(
reloaded.get("copy").is_some(),
"a DriveCopy must never be pruned by --max-size alone"
);
assert!(reloaded.get("big").is_some());
}
#[tokio::test]
async fn a_drive_copy_backup_is_trashed_and_dropped_together_with_its_row() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let mut ledger = LeaseLedger::default();
ledger.insert(drive_copy_record(
"copy",
Utc::now() - ChronoDuration::hours(2),
"drive-copy-1",
));
ledger.save(&ledger_path).unwrap();
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/drive/v3/files/drive-copy-1"))
.and(wiremock::matchers::body_json(
serde_json::json!({"trashed": true}),
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "drive-copy-1", "name": "backup", "trashed": true,
})),
)
.expect(1)
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let outcome = prune(
&client,
&PruneOptions {
older_than: Some(Utc::now()),
max_size: None,
dry_run: false,
ledger_path: ledger_path.clone(),
},
)
.await
.unwrap();
assert_eq!(outcome.removed, 1);
assert_eq!(outcome.trashed_drive_copies, 1);
assert!(LeaseLedger::load(&ledger_path)
.unwrap()
.get("copy")
.is_none());
}
#[tokio::test]
async fn a_drive_copy_already_trashed_is_tolerated_as_success() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let mut ledger = LeaseLedger::default();
ledger.insert(drive_copy_record(
"copy",
Utc::now() - ChronoDuration::hours(2),
"drive-copy-1",
));
ledger.save(&ledger_path).unwrap();
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/drive/v3/files/drive-copy-1"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let outcome = prune(
&client,
&PruneOptions {
older_than: Some(Utc::now()),
max_size: None,
dry_run: false,
ledger_path: ledger_path.clone(),
},
)
.await
.unwrap();
assert_eq!(outcome.removed, 1);
assert_eq!(outcome.failed, 0);
assert!(LeaseLedger::load(&ledger_path)
.unwrap()
.get("copy")
.is_none());
}
#[tokio::test]
async fn a_bytes_backup_already_removed_is_tolerated_as_success() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let backup_path = dir.path().join("already-gone");
let mut ledger = LeaseLedger::default();
ledger.insert(bytes_record(
"old",
Utc::now() - ChronoDuration::days(1),
1,
backup_path,
));
ledger.save(&ledger_path).unwrap();
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let outcome = prune(
&client,
&PruneOptions {
older_than: Some(Utc::now()),
max_size: None,
dry_run: false,
ledger_path: ledger_path.clone(),
},
)
.await
.unwrap();
assert_eq!(outcome.removed, 1);
assert_eq!(outcome.failed, 0);
assert!(LeaseLedger::load(&ledger_path)
.unwrap()
.get("old")
.is_none());
}
#[tokio::test]
async fn a_bytes_backup_deletion_failure_leaves_the_row_in_place() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let backup_path = dir.path().join("not-a-file");
std::fs::create_dir(&backup_path).unwrap();
let mut ledger = LeaseLedger::default();
ledger.insert(bytes_record(
"old",
Utc::now() - ChronoDuration::days(1),
1,
backup_path,
));
ledger.save(&ledger_path).unwrap();
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let outcome = prune(
&client,
&PruneOptions {
older_than: Some(Utc::now()),
max_size: None,
dry_run: false,
ledger_path: ledger_path.clone(),
},
)
.await
.unwrap();
assert_eq!(outcome.removed, 0);
assert_eq!(outcome.failed, 1);
assert_eq!(outcome.kept, 1);
assert!(LeaseLedger::load(&ledger_path)
.unwrap()
.get("old")
.is_some());
}
#[tokio::test]
async fn a_backup_deletion_failure_leaves_the_row_in_place() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let mut ledger = LeaseLedger::default();
ledger.insert(drive_copy_record(
"copy",
Utc::now() - ChronoDuration::hours(2),
"drive-copy-1",
));
ledger.save(&ledger_path).unwrap();
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/drive/v3/files/drive-copy-1"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let outcome = prune(
&client,
&PruneOptions {
older_than: Some(Utc::now()),
max_size: None,
dry_run: false,
ledger_path: ledger_path.clone(),
},
)
.await
.unwrap();
assert_eq!(outcome.removed, 0);
assert_eq!(outcome.failed, 1);
assert_eq!(outcome.kept, 1);
assert!(LeaseLedger::load(&ledger_path)
.unwrap()
.get("copy")
.is_some());
}
#[tokio::test]
async fn dry_run_reports_without_deleting_or_saving() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let backup_path = dir.path().join("backup");
std::fs::write(&backup_path, b"x").unwrap();
let mut ledger = LeaseLedger::default();
ledger.insert(bytes_record(
"old",
Utc::now() - ChronoDuration::days(1),
1,
backup_path.clone(),
));
ledger.save(&ledger_path).unwrap();
let before = std::fs::read(&ledger_path).unwrap();
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let outcome = prune(
&client,
&PruneOptions {
older_than: Some(Utc::now()),
max_size: None,
dry_run: true,
ledger_path: ledger_path.clone(),
},
)
.await
.unwrap();
assert_eq!(outcome.removed, 1);
assert_eq!(outcome.bytes_freed, 1);
assert!(backup_path.exists(), "dry-run must not delete the backup");
let after = std::fs::read(&ledger_path).unwrap();
assert_eq!(before, after, "dry-run must not modify the ledger");
}
#[test]
fn plan_matches_prune_dry_run_outcome_without_a_client() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let backup_path = dir.path().join("backup");
std::fs::write(&backup_path, b"x").unwrap();
let mut ledger = LeaseLedger::default();
ledger.insert(bytes_record(
"old",
Utc::now() - ChronoDuration::days(1),
1,
backup_path.clone(),
));
ledger.save(&ledger_path).unwrap();
let opts = PruneOptions {
older_than: Some(Utc::now()),
max_size: None,
dry_run: true,
ledger_path,
};
let outcome = plan(&opts).unwrap();
assert_eq!(outcome.removed, 1);
assert_eq!(outcome.kept, 0);
assert_eq!(outcome.bytes_freed, 1);
assert!(backup_path.exists(), "plan must not delete the backup");
}
#[tokio::test]
async fn no_op_prune_does_not_rewrite_the_ledger() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let mut ledger = LeaseLedger::default();
ledger.insert(bytes_record(
"live",
Utc::now() + ChronoDuration::hours(1),
1,
dir.path().join("backup"),
));
ledger.save(&ledger_path).unwrap();
let before_mtime = std::fs::metadata(&ledger_path).unwrap().modified().unwrap();
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
prune(
&client,
&PruneOptions {
older_than: Some(Utc::now() - ChronoDuration::days(1)),
max_size: None,
dry_run: false,
ledger_path: ledger_path.clone(),
},
)
.await
.unwrap();
let after_mtime = std::fs::metadata(&ledger_path).unwrap().modified().unwrap();
assert_eq!(before_mtime, after_mtime);
}
#[tokio::test]
async fn a_successful_removal_writes_a_pruned_audit_record() {
let dir = tempfile::tempdir().unwrap();
let audit = AuditGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let backup_path = dir.path().join("backup");
std::fs::write(&backup_path, b"x").unwrap();
let mut ledger = LeaseLedger::default();
ledger.insert(bytes_record(
"old",
Utc::now() - ChronoDuration::days(1),
1,
backup_path,
));
ledger.save(&ledger_path).unwrap();
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
prune(
&client,
&PruneOptions {
older_than: Some(Utc::now()),
max_size: None,
dry_run: false,
ledger_path: ledger_path.clone(),
},
)
.await
.unwrap();
assert_eq!(
audit.verdicts(),
vec!["pruned".to_string()],
"a pruned lease's removal must be independently discoverable via `omni-dev log \
--audit`, not just the transient CLI summary"
);
}
#[tokio::test]
async fn a_failed_removal_writes_a_prune_failed_audit_record_carrying_the_error() {
let dir = tempfile::tempdir().unwrap();
let audit = AuditGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let mut ledger = LeaseLedger::default();
ledger.insert(drive_copy_record(
"copy",
Utc::now() - ChronoDuration::hours(2),
"drive-copy-1",
));
ledger.save(&ledger_path).unwrap();
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/drive/v3/files/drive-copy-1"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
prune(
&client,
&PruneOptions {
older_than: Some(Utc::now()),
max_size: None,
dry_run: false,
ledger_path: ledger_path.clone(),
},
)
.await
.unwrap();
assert_eq!(audit.verdicts(), vec!["prune-failed".to_string()]);
let records = audit.records();
assert_eq!(records.len(), 1);
assert!(
records[0].error.is_some(),
"the underlying clear_backup error must be captured, not just an opaque count"
);
}
#[tokio::test]
async fn a_best_effort_audit_write_failure_is_warned_and_swallowed() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(dir.path());
std::fs::create_dir(dir.path().join("audit.jsonl")).unwrap();
let ledger_path = dir.path().join("lease-ledger.jsonl");
let backup_path = dir.path().join("backup");
std::fs::write(&backup_path, b"x").unwrap();
let mut ledger = LeaseLedger::default();
ledger.insert(bytes_record(
"old",
Utc::now() - ChronoDuration::days(1),
1,
backup_path.clone(),
));
ledger.save(&ledger_path).unwrap();
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let outcome = prune(
&client,
&PruneOptions {
older_than: Some(Utc::now()),
max_size: None,
dry_run: false,
ledger_path: ledger_path.clone(),
},
)
.await
.unwrap();
assert_eq!(outcome.removed, 1);
assert!(!backup_path.exists());
assert!(LeaseLedger::load(&ledger_path)
.unwrap()
.get("old")
.is_none());
}
#[cfg(unix)]
#[tokio::test]
async fn a_ledger_persist_failure_after_clearing_the_backup_is_reported_loudly() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let mut ledger = LeaseLedger::default();
ledger.insert(drive_copy_record(
"copy",
Utc::now() - ChronoDuration::hours(2),
"drive-copy-1",
));
ledger.save(&ledger_path).unwrap();
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/drive/v3/files/drive-copy-1"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({
"id": "drive-copy-1", "name": "backup", "trashed": true,
}))
.set_delay(std::time::Duration::from_millis(150)),
)
.mount(&server)
.await;
let client = client_with_bootstrapped_token(&server).await;
let dir_path = dir.path().to_path_buf();
let jammer = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(30));
std::fs::set_permissions(&dir_path, std::fs::Permissions::from_mode(0o500)).unwrap();
});
let err = prune(
&client,
&PruneOptions {
older_than: Some(Utc::now()),
max_size: None,
dry_run: false,
ledger_path: ledger_path.clone(),
},
)
.await
.unwrap_err();
jammer.join().unwrap();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(err.to_string().contains("may now dangle"), "{err:?}");
}
#[tokio::test]
async fn prune_refuses_while_another_lock_is_held() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let _held = LedgerLock::acquire(&ledger_path).unwrap();
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let err = prune(
&client,
&PruneOptions {
older_than: Some(Utc::now()),
max_size: None,
dry_run: false,
ledger_path: ledger_path.clone(),
},
)
.await
.unwrap_err();
assert!(err.to_string().contains("already be in progress"));
}
#[tokio::test(flavor = "multi_thread")]
async fn prune_writes_audit_records_on_the_calling_thread_on_a_multi_thread_runtime() {
let dir = tempfile::tempdir().unwrap();
let audit = AuditGuard::redirect(dir.path());
let ledger_path = dir.path().join("lease-ledger.jsonl");
let backup_path = dir.path().join("backup");
std::fs::write(&backup_path, b"x").unwrap();
let mut ledger = LeaseLedger::default();
ledger.insert(bytes_record(
"old",
Utc::now() - ChronoDuration::days(1),
1,
backup_path.clone(),
));
ledger.save(&ledger_path).unwrap();
let server = wiremock::MockServer::start().await;
let client = client_with_bootstrapped_token(&server).await;
let outcome = prune(
&client,
&PruneOptions {
older_than: Some(Utc::now()),
max_size: None,
dry_run: false,
ledger_path: ledger_path.clone(),
},
)
.await
.unwrap();
assert_eq!(outcome.removed, 1);
assert_eq!(
audit.verdicts(),
vec!["pruned".to_string()],
"on a multi-thread runtime, offload_short_blocking_io uses block_in_place \
(same OS thread) so this test's thread-local audit redirect must still see \
the record; a regression to spawn_blocking would silently misroute it"
);
}
}