From 597c84147a08ff775fa47ec595733a09be916b67 Mon Sep 17 00:00:00 2001 From: chrischeng-c4 Date: Sat, 24 Oct 2026 08:36:22 +0800 Subject: [PATCH] feat(service-backup): add SetKeyLayout and plan_retention domain logic --- crates/service-backup/src/domain.rs | 4 + .../src/domain/retention_plan.rs | 97 ++++ .../src/domain/set_key_layout.rs | 192 ++++++++ crates/service-backup/src/lib.rs | 5 +- crates/service-backup/tests/it/main.rs | 1 + .../tests/it/projected_admin_transport.rs | 2 +- crates/service-backup/tests/it/set_layout.rs | 415 ++++++++++++++++++ 7 files changed, 713 insertions(+), 3 deletions(-) create mode 100644 crates/service-backup/src/domain/retention_plan.rs create mode 100644 crates/service-backup/src/domain/set_key_layout.rs create mode 100644 crates/service-backup/tests/it/set_layout.rs diff --git a/crates/service-backup/src/domain.rs b/crates/service-backup/src/domain.rs index ef939c7..fe257b8 100644 --- a/crates/service-backup/src/domain.rs +++ b/crates/service-backup/src/domain.rs @@ -13,6 +13,8 @@ mod destination; mod destination_error; mod policy; mod policy_error; +mod retention_plan; +mod set_key_layout; #[cfg(feature = "http-client")] mod snapshot_source; #[cfg(feature = "http-client")] @@ -28,6 +30,8 @@ pub use destination::{BackupDestination, SchemeInfo, SUPPORTED_SCHEMES}; pub use destination_error::DestinationError; pub use policy::{BackupPolicy, RetentionPolicy, ScheduledBackupPolicy}; pub use policy_error::PolicyError; +pub use retention_plan::{plan_retention, RetentionPlan, SetInventory, SetState}; +pub use set_key_layout::{SetKeyError, SetKeyLayout}; #[cfg(feature = "http-client")] pub use snapshot_source::SnapshotSource; #[cfg(feature = "http-client")] diff --git a/crates/service-backup/src/domain/retention_plan.rs b/crates/service-backup/src/domain/retention_plan.rs new file mode 100644 index 0000000..4b5ba76 --- /dev/null +++ b/crates/service-backup/src/domain/retention_plan.rs @@ -0,0 +1,97 @@ +//! Pure retention planning for backup sets in storage sinks. + +use std::time::{Duration, SystemTime}; + +/// Completion state of a backup set in inventory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SetState { + /// The set's manifest object exists. + Complete { created_at: SystemTime }, + /// Objects exist under the set prefix, but no manifest exists. + Partial, +} + +/// Description of one backup set prefix discovered in a sink. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SetInventory { + pub set_id: String, + pub state: SetState, + pub newest_object_at: SystemTime, +} + +/// Decision on which backup sets to retain, prune, or delete as orphans. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct RetentionPlan { + pub keep: Vec, + pub delete_complete: Vec, + pub delete_orphans: Vec, +} + +/// Plan retention across discovered sets deterministically. +/// +/// Rules: +/// - The `keep_latest` newest `Complete` sets are kept, with `keep_latest` raised +/// to at least 1 so the plan never deletes the last complete set. +/// - Older `Complete` sets go to `delete_complete`. +/// - With zero `Complete` sets, nothing in `delete_complete` is produced. +/// - A `Partial` set goes to `delete_orphans` only when `now - newest_object_at >= orphan_grace`, +/// otherwise it is neither kept nor deleted (left out of all three lists). +/// - An empty inventory produces an empty plan. +/// - Ties on `created_at` break by `set_id`. +/// - All output lists (`keep`, `delete_complete`, `delete_orphans`) are sorted by `set_id`. +pub fn plan_retention( + inventory: &[SetInventory], + keep_latest: usize, + now: SystemTime, + orphan_grace: Duration, +) -> RetentionPlan { + let mut complete_sets: Vec<(&str, SystemTime)> = Vec::new(); + let mut delete_orphans: Vec = Vec::new(); + + for item in inventory { + match &item.state { + SetState::Complete { created_at } => { + complete_sets.push((&item.set_id, *created_at)); + } + SetState::Partial => { + if let Ok(age) = now.duration_since(item.newest_object_at) { + if age >= orphan_grace { + delete_orphans.push(item.set_id.clone()); + } + } + } + } + } + + // Sort ascending by (created_at, set_id); newest sets are at the end. + complete_sets.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(b.0))); + + let effective_keep = keep_latest.max(1); + let mut keep = Vec::new(); + let mut delete_complete = Vec::new(); + + if !complete_sets.is_empty() { + let keep_count = effective_keep.min(complete_sets.len()); + let split_idx = complete_sets.len() - keep_count; + for (id, _) in &complete_sets[split_idx..] { + keep.push((*id).to_string()); + } + for (id, _) in &complete_sets[..split_idx] { + delete_complete.push((*id).to_string()); + } + } + + // Never delete a partial set that has the same id as a complete set. + delete_orphans.retain(|orphan| !keep.contains(orphan) && !delete_complete.contains(orphan)); + + keep.sort(); + delete_complete.sort(); + delete_orphans.sort(); + delete_orphans.dedup(); + + RetentionPlan { + keep, + delete_complete, + delete_orphans, + } +} diff --git a/crates/service-backup/src/domain/set_key_layout.rs b/crates/service-backup/src/domain/set_key_layout.rs new file mode 100644 index 0000000..3294bd0 --- /dev/null +++ b/crates/service-backup/src/domain/set_key_layout.rs @@ -0,0 +1,192 @@ +//! Immutable, collision-safe layout of backup set keys in object storage sinks. + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum SetKeyError { + #[error("prefix has leading slash: {0:?}")] + LeadingSlashInPrefix(String), + #[error("prefix has trailing slash: {0:?}")] + TrailingSlashInPrefix(String), + #[error("prefix contains empty segment: {0:?}")] + EmptyPrefixSegment(String), + #[error("prefix segment is dot or dot-dot: {0:?}")] + DotSegmentInPrefix(String), + #[error("prefix segment contains invalid character: {0:?}")] + InvalidPrefixCharacter(String), + + #[error("set id is empty")] + EmptySetId, + #[error("set id exceeds 128 bytes: {0}")] + SetIdTooLong(usize), + #[error("set id contains slash: {0:?}")] + SlashInSetId(String), + #[error("set id contains invalid character: {0:?}")] + InvalidSetIdCharacter(String), + + #[error("artifact name is empty")] + EmptyArtifactName, + #[error("artifact name exceeds 256 bytes: {0}")] + ArtifactNameTooLong(usize), + #[error("artifact name has leading slash: {0:?}")] + LeadingSlashInArtifactName(String), + #[error("artifact name has trailing slash: {0:?}")] + TrailingSlashInArtifactName(String), + #[error("artifact name contains empty segment: {0:?}")] + EmptyArtifactNameSegment(String), + #[error("artifact name segment is dot or dot-dot: {0:?}")] + DotSegmentInArtifactName(String), + #[error("artifact name segment contains invalid character: {0:?}")] + InvalidArtifactNameCharacter(String), +} + +fn is_valid_char(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-' +} + +fn validate_prefix(prefix: &str) -> Result<(), SetKeyError> { + if prefix.is_empty() { + return Ok(()); + } + if prefix.starts_with('/') { + return Err(SetKeyError::LeadingSlashInPrefix(prefix.to_string())); + } + if prefix.ends_with('/') { + return Err(SetKeyError::TrailingSlashInPrefix(prefix.to_string())); + } + for seg in prefix.split('/') { + if seg.is_empty() { + return Err(SetKeyError::EmptyPrefixSegment(prefix.to_string())); + } + if seg == "." || seg == ".." { + return Err(SetKeyError::DotSegmentInPrefix(prefix.to_string())); + } + if !seg.bytes().all(is_valid_char) { + return Err(SetKeyError::InvalidPrefixCharacter(prefix.to_string())); + } + } + Ok(()) +} + +fn validate_set_id(set_id: &str) -> Result<(), SetKeyError> { + if set_id.is_empty() { + return Err(SetKeyError::EmptySetId); + } + if set_id.len() > 128 { + return Err(SetKeyError::SetIdTooLong(set_id.len())); + } + if set_id.contains('/') { + return Err(SetKeyError::SlashInSetId(set_id.to_string())); + } + if !set_id.bytes().all(is_valid_char) { + return Err(SetKeyError::InvalidSetIdCharacter(set_id.to_string())); + } + Ok(()) +} + +fn validate_artifact_name(name: &str) -> Result<(), SetKeyError> { + if name.is_empty() { + return Err(SetKeyError::EmptyArtifactName); + } + if name.len() > 256 { + return Err(SetKeyError::ArtifactNameTooLong(name.len())); + } + if name.starts_with('/') { + return Err(SetKeyError::LeadingSlashInArtifactName(name.to_string())); + } + if name.ends_with('/') { + return Err(SetKeyError::TrailingSlashInArtifactName(name.to_string())); + } + for seg in name.split('/') { + if seg.is_empty() { + return Err(SetKeyError::EmptyArtifactNameSegment(name.to_string())); + } + if seg == "." || seg == ".." { + return Err(SetKeyError::DotSegmentInArtifactName(name.to_string())); + } + if !seg.bytes().all(is_valid_char) { + return Err(SetKeyError::InvalidArtifactNameCharacter(name.to_string())); + } + } + Ok(()) +} + +/// Key layout mapping set ids to immutable object storage keys. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SetKeyLayout { + prefix: String, +} + +impl SetKeyLayout { + /// Construct a layout under `prefix`. + /// + /// An empty prefix places keys at top-level. Non-empty prefixes must be + /// `/`-separated segments of `[A-Za-z0-9._-]` without leading/trailing `/` + /// or empty/dot segments. + pub fn new(prefix: &str) -> Result { + validate_prefix(prefix)?; + Ok(Self { + prefix: prefix.to_string(), + }) + } + + /// Return the configured prefix. + pub fn prefix(&self) -> &str { + &self.prefix + } + + /// Key for an immutable artifact within a set: `/sets//artifacts/`. + pub fn artifact_key(&self, set_id: &str, name: &str) -> Result { + validate_set_id(set_id)?; + validate_artifact_name(name)?; + if self.prefix.is_empty() { + Ok(format!("sets/{set_id}/artifacts/{name}")) + } else { + Ok(format!("{}/sets/{set_id}/artifacts/{name}", self.prefix)) + } + } + + /// Key for the manifest marking set completion: `/sets//manifest.json`. + pub fn manifest_key(&self, set_id: &str) -> Result { + validate_set_id(set_id)?; + if self.prefix.is_empty() { + Ok(format!("sets/{set_id}/manifest.json")) + } else { + Ok(format!("{}/sets/{set_id}/manifest.json", self.prefix)) + } + } + + /// Key for the latest successful backup pointer: `/latest.json`. + pub fn latest_key(&self) -> String { + if self.prefix.is_empty() { + "latest.json".to_string() + } else { + format!("{}/latest.json", self.prefix) + } + } + + /// Common prefix for all objects in a set: `/sets//`. + pub fn set_prefix(&self, set_id: &str) -> Result { + validate_set_id(set_id)?; + if self.prefix.is_empty() { + Ok(format!("sets/{set_id}/")) + } else { + Ok(format!("{}/sets/{set_id}/", self.prefix)) + } + } + + /// Parse the set id from any key that lies under `/sets//`. + /// + /// Returns `None` if the key does not match this layout's prefix or does + /// not lie directly under a valid set prefix. + pub fn parse_set_id<'a>(&self, key: &'a str) -> Option<&'a str> { + let rem = if self.prefix.is_empty() { + key.strip_prefix("sets/")? + } else { + key.strip_prefix(&self.prefix)?.strip_prefix("/sets/")? + }; + let (id, _rest) = rem.split_once('/')?; + if id.is_empty() || id.len() > 128 || !id.bytes().all(is_valid_char) { + return None; + } + Some(id) + } +} diff --git a/crates/service-backup/src/lib.rs b/crates/service-backup/src/lib.rs index d6ae94d..3fb9452 100644 --- a/crates/service-backup/src/lib.rs +++ b/crates/service-backup/src/lib.rs @@ -27,8 +27,9 @@ pub use app::run_admin_snapshot_backup; pub use app::sink_from_destination; pub use application::{run_backup_once, BackupObject, BackupRunResult}; pub use domain::{ - BackupDestination, BackupPolicy, BackupSink, BackupSinkError, DestinationError, PolicyError, - RetentionPolicy, ScheduledBackupPolicy, SchemeInfo, SUPPORTED_SCHEMES, + plan_retention, BackupDestination, BackupPolicy, BackupSink, BackupSinkError, DestinationError, + PolicyError, RetentionPlan, RetentionPolicy, ScheduledBackupPolicy, SchemeInfo, SetInventory, + SetKeyError, SetKeyLayout, SetState, SUPPORTED_SCHEMES, }; pub use infrastructure::{fetch_backup_object, GcsSink, LocalFsSink, UnsupportedCloudSink}; #[cfg(feature = "http-client")] diff --git a/crates/service-backup/tests/it/main.rs b/crates/service-backup/tests/it/main.rs index af1b4e8..69b11f5 100644 --- a/crates/service-backup/tests/it/main.rs +++ b/crates/service-backup/tests/it/main.rs @@ -5,5 +5,6 @@ mod admin_snapshot_backup; #[cfg(feature = "http-client")] mod projected_admin_transport; mod schema_golden; +mod set_layout; mod sink_error; mod wire_golden; diff --git a/crates/service-backup/tests/it/projected_admin_transport.rs b/crates/service-backup/tests/it/projected_admin_transport.rs index a0c918d..4af1675 100644 --- a/crates/service-backup/tests/it/projected_admin_transport.rs +++ b/crates/service-backup/tests/it/projected_admin_transport.rs @@ -82,7 +82,7 @@ async fn non_success_diagnostic_is_bounded_and_structured() { .unwrap_err(); let diagnostic = error.diagnostic().expect("status error has diagnostics"); assert_eq!(diagnostic.status, 503); - assert_eq!(diagnostic.body.as_bytes().len(), 10); + assert_eq!(diagnostic.body.len(), 10); assert!(diagnostic.truncated); assert_eq!(diagnostic.operation.as_str(), "backup"); } diff --git a/crates/service-backup/tests/it/set_layout.rs b/crates/service-backup/tests/it/set_layout.rs new file mode 100644 index 0000000..761c06b --- /dev/null +++ b/crates/service-backup/tests/it/set_layout.rs @@ -0,0 +1,415 @@ +use std::collections::HashSet; +use std::time::{Duration, UNIX_EPOCH}; + +use service_backup::{plan_retention, SetInventory, SetKeyError, SetKeyLayout, SetState}; + +#[test] +fn key_shapes_with_and_without_prefix() { + let layout_with = SetKeyLayout::new("backups/cluster1").unwrap(); + assert_eq!(layout_with.prefix(), "backups/cluster1"); + assert_eq!( + layout_with.artifact_key("set-1", "data.tar").unwrap(), + "backups/cluster1/sets/set-1/artifacts/data.tar" + ); + assert_eq!( + layout_with.manifest_key("set-1").unwrap(), + "backups/cluster1/sets/set-1/manifest.json" + ); + assert_eq!(layout_with.latest_key(), "backups/cluster1/latest.json"); + assert_eq!( + layout_with.set_prefix("set-1").unwrap(), + "backups/cluster1/sets/set-1/" + ); + + let layout_empty = SetKeyLayout::new("").unwrap(); + assert_eq!(layout_empty.prefix(), ""); + assert_eq!( + layout_empty.artifact_key("set-1", "data.tar").unwrap(), + "sets/set-1/artifacts/data.tar" + ); + assert_eq!( + layout_empty.manifest_key("set-1").unwrap(), + "sets/set-1/manifest.json" + ); + assert_eq!(layout_empty.latest_key(), "latest.json"); + assert_eq!(layout_empty.set_prefix("set-1").unwrap(), "sets/set-1/"); +} + +#[test] +fn invalid_prefix_set_id_and_name() { + // Prefix rules + assert!(matches!( + SetKeyLayout::new("/backups"), + Err(SetKeyError::LeadingSlashInPrefix(_)) + )); + assert!(matches!( + SetKeyLayout::new("backups/"), + Err(SetKeyError::TrailingSlashInPrefix(_)) + )); + assert!(matches!( + SetKeyLayout::new("backups//prod"), + Err(SetKeyError::EmptyPrefixSegment(_)) + )); + assert!(matches!( + SetKeyLayout::new("."), + Err(SetKeyError::DotSegmentInPrefix(_)) + )); + assert!(matches!( + SetKeyLayout::new("backups/./prod"), + Err(SetKeyError::DotSegmentInPrefix(_)) + )); + assert!(matches!( + SetKeyLayout::new(".."), + Err(SetKeyError::DotSegmentInPrefix(_)) + )); + assert!(matches!( + SetKeyLayout::new("backups/../prod"), + Err(SetKeyError::DotSegmentInPrefix(_)) + )); + assert!(matches!( + SetKeyLayout::new("backups/prod@1"), + Err(SetKeyError::InvalidPrefixCharacter(_)) + )); + assert!(matches!( + SetKeyLayout::new("backups prod"), + Err(SetKeyError::InvalidPrefixCharacter(_)) + )); + + let layout = SetKeyLayout::new("prefix").unwrap(); + + // Set id rules + assert_eq!(layout.manifest_key(""), Err(SetKeyError::EmptySetId)); + let long_id = "a".repeat(129); + assert_eq!( + layout.manifest_key(&long_id), + Err(SetKeyError::SetIdTooLong(129)) + ); + assert!(matches!( + layout.manifest_key("set/1"), + Err(SetKeyError::SlashInSetId(_)) + )); + assert!(matches!( + layout.manifest_key("set:1"), + Err(SetKeyError::InvalidSetIdCharacter(_)) + )); + assert!(matches!( + layout.manifest_key("set 1"), + Err(SetKeyError::InvalidSetIdCharacter(_)) + )); + assert!(matches!( + layout.set_prefix("set/1"), + Err(SetKeyError::SlashInSetId(_)) + )); + + // Artifact name rules + assert_eq!( + layout.artifact_key("s1", ""), + Err(SetKeyError::EmptyArtifactName) + ); + let long_name = "b".repeat(257); + assert_eq!( + layout.artifact_key("s1", &long_name), + Err(SetKeyError::ArtifactNameTooLong(257)) + ); + assert!(matches!( + layout.artifact_key("s1", "/data.tar"), + Err(SetKeyError::LeadingSlashInArtifactName(_)) + )); + assert!(matches!( + layout.artifact_key("s1", "data.tar/"), + Err(SetKeyError::TrailingSlashInArtifactName(_)) + )); + assert!(matches!( + layout.artifact_key("s1", "data//tar"), + Err(SetKeyError::EmptyArtifactNameSegment(_)) + )); + assert!(matches!( + layout.artifact_key("s1", "."), + Err(SetKeyError::DotSegmentInArtifactName(_)) + )); + assert!(matches!( + layout.artifact_key("s1", "a/./b"), + Err(SetKeyError::DotSegmentInArtifactName(_)) + )); + assert!(matches!( + layout.artifact_key("s1", ".."), + Err(SetKeyError::DotSegmentInArtifactName(_)) + )); + assert!(matches!( + layout.artifact_key("s1", "a/../b"), + Err(SetKeyError::DotSegmentInArtifactName(_)) + )); + assert!(matches!( + layout.artifact_key("s1", "data:tar"), + Err(SetKeyError::InvalidArtifactNameCharacter(_)) + )); + assert!(matches!( + layout.artifact_key("s1", "data tar"), + Err(SetKeyError::InvalidArtifactNameCharacter(_)) + )); +} + +#[test] +fn parse_set_id_round_trip_and_rejection() { + let layout = SetKeyLayout::new("backup/v1").unwrap(); + let key = layout.artifact_key("set.1-a_b", "a/b.bin").unwrap(); + assert_eq!(layout.parse_set_id(&key), Some("set.1-a_b")); + assert_eq!(layout.parse_set_id(&layout.latest_key()), None); + assert_eq!( + layout.parse_set_id("other/sets/set.1-a_b/manifest.json"), + None + ); + assert_eq!(layout.parse_set_id("backup/v1/sets/"), None); + assert_eq!(layout.parse_set_id("backup/v1/sets/set.1-a_b"), None); + + let layout_empty = SetKeyLayout::new("").unwrap(); + let key_empty = layout_empty.artifact_key("set.1-a_b", "a/b.bin").unwrap(); + assert_eq!(layout_empty.parse_set_id(&key_empty), Some("set.1-a_b")); + assert_eq!(layout_empty.parse_set_id(&layout_empty.latest_key()), None); + assert_eq!( + layout_empty.parse_set_id("other/sets/set.1-a_b/manifest.json"), + None + ); +} + +#[test] +fn collision_cross_product() { + let layouts = [ + SetKeyLayout::new("").unwrap(), + SetKeyLayout::new("backup").unwrap(), + ]; + let set_ids = ["s1", "s2", "set-a.1_b"]; + let names = [ + "manifest.json", + "latest.json", + "data.bin", + "nested/part.0", + "artifacts", + ]; + + for layout in &layouts { + let mut seen = HashSet::new(); + let latest = layout.latest_key(); + + for set_id in &set_ids { + let manifest = layout.manifest_key(set_id).unwrap(); + assert_ne!(manifest, latest); + + for name in &names { + let artifact = layout.artifact_key(set_id, name).unwrap(); + assert_ne!(artifact, manifest); + assert_ne!(artifact, latest); + assert!( + seen.insert(artifact), + "collision detected for set_id={set_id}, name={name}" + ); + } + } + } +} + +#[test] +fn retention_keeps_newest_n() { + let t0 = UNIX_EPOCH + Duration::from_secs(1_000_000); + let inventory = vec![ + SetInventory { + set_id: "set-1".to_string(), + state: SetState::Complete { + created_at: t0 + Duration::from_secs(10), + }, + newest_object_at: t0 + Duration::from_secs(10), + }, + SetInventory { + set_id: "set-2".to_string(), + state: SetState::Complete { + created_at: t0 + Duration::from_secs(20), + }, + newest_object_at: t0 + Duration::from_secs(20), + }, + SetInventory { + set_id: "set-3".to_string(), + state: SetState::Complete { + created_at: t0 + Duration::from_secs(30), + }, + newest_object_at: t0 + Duration::from_secs(30), + }, + SetInventory { + set_id: "set-4".to_string(), + state: SetState::Complete { + created_at: t0 + Duration::from_secs(40), + }, + newest_object_at: t0 + Duration::from_secs(40), + }, + SetInventory { + set_id: "set-5".to_string(), + state: SetState::Complete { + created_at: t0 + Duration::from_secs(50), + }, + newest_object_at: t0 + Duration::from_secs(50), + }, + ]; + + let plan = plan_retention( + &inventory, + 3, + t0 + Duration::from_secs(100), + Duration::from_secs(3600), + ); + assert_eq!(plan.keep, vec!["set-3", "set-4", "set-5"]); + assert_eq!(plan.delete_complete, vec!["set-1", "set-2"]); + assert!(plan.delete_orphans.is_empty()); +} + +#[test] +fn keep_latest_zero_still_keeps_one() { + let t0 = UNIX_EPOCH + Duration::from_secs(1_000_000); + let inventory = vec![ + SetInventory { + set_id: "set-1".to_string(), + state: SetState::Complete { + created_at: t0 + Duration::from_secs(10), + }, + newest_object_at: t0 + Duration::from_secs(10), + }, + SetInventory { + set_id: "set-2".to_string(), + state: SetState::Complete { + created_at: t0 + Duration::from_secs(20), + }, + newest_object_at: t0 + Duration::from_secs(20), + }, + ]; + + let plan = plan_retention( + &inventory, + 0, + t0 + Duration::from_secs(100), + Duration::from_secs(3600), + ); + assert_eq!(plan.keep, vec!["set-2"]); + assert_eq!(plan.delete_complete, vec!["set-1"]); + assert!(plan.delete_orphans.is_empty()); +} + +#[test] +fn young_partial_set_is_untouched() { + let now = UNIX_EPOCH + Duration::from_secs(1_000_000); + let grace = Duration::from_secs(3600); + let inventory = vec![SetInventory { + set_id: "partial-young".to_string(), + state: SetState::Partial, + newest_object_at: now - Duration::from_secs(300), // 5 min old + }]; + + let plan = plan_retention(&inventory, 1, now, grace); + assert!(plan.keep.is_empty()); + assert!(plan.delete_complete.is_empty()); + assert!(plan.delete_orphans.is_empty()); +} + +#[test] +fn aged_partial_set_is_orphan() { + let now = UNIX_EPOCH + Duration::from_secs(1_000_000); + let grace = Duration::from_secs(3600); + let inventory = vec![SetInventory { + set_id: "partial-aged".to_string(), + state: SetState::Partial, + newest_object_at: now - Duration::from_secs(7200), // 2 hours old + }]; + + let plan = plan_retention(&inventory, 1, now, grace); + assert!(plan.keep.is_empty()); + assert!(plan.delete_complete.is_empty()); + assert_eq!(plan.delete_orphans, vec!["partial-aged"]); +} + +#[test] +fn input_order_does_not_change_plan() { + let now = UNIX_EPOCH + Duration::from_secs(1_000_000); + let grace = Duration::from_secs(3600); + + let item1 = SetInventory { + set_id: "complete-a".to_string(), + state: SetState::Complete { + created_at: now - Duration::from_secs(500), + }, + newest_object_at: now - Duration::from_secs(500), + }; + let item2 = SetInventory { + set_id: "complete-b".to_string(), + state: SetState::Complete { + created_at: now - Duration::from_secs(500), // tie with complete-a + }, + newest_object_at: now - Duration::from_secs(500), + }; + let item3 = SetInventory { + set_id: "complete-c".to_string(), + state: SetState::Complete { + created_at: now - Duration::from_secs(200), + }, + newest_object_at: now - Duration::from_secs(200), + }; + let item4 = SetInventory { + set_id: "partial-young".to_string(), + state: SetState::Partial, + newest_object_at: now - Duration::from_secs(100), + }; + let item5 = SetInventory { + set_id: "partial-aged".to_string(), + state: SetState::Partial, + newest_object_at: now - Duration::from_secs(5000), + }; + + let order1 = vec![ + item1.clone(), + item2.clone(), + item3.clone(), + item4.clone(), + item5.clone(), + ]; + let mut order2 = order1.clone(); + order2.reverse(); + let order3 = vec![ + item3.clone(), + item5.clone(), + item1.clone(), + item4.clone(), + item2.clone(), + ]; + + let plan1 = plan_retention(&order1, 2, now, grace); + let plan2 = plan_retention(&order2, 2, now, grace); + let plan3 = plan_retention(&order3, 2, now, grace); + + assert_eq!(plan1, plan2); + assert_eq!(plan1, plan3); +} + +#[test] +fn inventory_with_only_partial_sets_deletes_no_complete_set() { + let now = UNIX_EPOCH + Duration::from_secs(1_000_000); + let grace = Duration::from_secs(3600); + + let inventory = vec![ + SetInventory { + set_id: "p1-young".to_string(), + state: SetState::Partial, + newest_object_at: now - Duration::from_secs(60), + }, + SetInventory { + set_id: "p2-aged".to_string(), + state: SetState::Partial, + newest_object_at: now - Duration::from_secs(4000), + }, + SetInventory { + set_id: "p3-aged".to_string(), + state: SetState::Partial, + newest_object_at: now - Duration::from_secs(8000), + }, + ]; + + let plan = plan_retention(&inventory, 2, now, grace); + assert!(plan.keep.is_empty()); + assert!(plan.delete_complete.is_empty()); + assert_eq!(plan.delete_orphans, vec!["p2-aged", "p3-aged"]); +}