kopiur_api/retention.rs
1//! Grandfather-father-son (GFS) retention selection (ADR §4.4).
2//!
3//! `SnapshotPolicy.spec.retention` is the **only** successful-retention driver
4//! (SKILL "Retention is GFS-only"). The operator periodically runs this selection
5//! over the `Snapshot` CRs for one `(identity, source)` tuple and deletes the CRs
6//! that fall outside the kept set; each deleted CR's `deletionPolicy` then governs
7//! the snapshot (§4.5). This module is the pure selection kernel — no kube types,
8//! no clock — so it's unit-testable with lightweight fakes.
9//!
10//! ## Algorithm (ADR-0001 §4.4, steps 2–4)
11//!
12//! 1. Sort candidates by end time, newest first.
13//! 2. Apply buckets in order: `keepLatest`, `keepHourly`, `keepDaily`,
14//! `keepWeekly`, `keepMonthly`, `keepAnnual`.
15//! - `keepLatest: N` keeps the N newest backups outright.
16//! - Each time bucket keeps the **most recent** backup within each distinct
17//! period (hour / day / ISO-week / month / year), up to its count `N`,
18//! walking newest→oldest.
19//! 3. A backup kept by **any** bucket survives (union). Everything else is deleted.
20//!
21//! This is deliberately *not* a flat count: a backup that is the newest of its
22//! year is held by `keepAnnual` even if hundreds of newer dailies exist — the
23//! exact case a flat cap would silently drop (ADR §4.4 "Why not flat-count").
24//!
25//! ## Empty-policy semantics
26//!
27//! An all-`None` [`Retention`] selects **no** buckets, so the kept set is empty and
28//! every backup is marked for deletion. The caller (controller) is responsible for
29//! only invoking GFS when a retention policy is actually configured; this function
30//! reports faithfully what the given policy implies. This is documented and tested.
31
32use crate::common::Retention;
33use chrono::{DateTime, Datelike, Utc};
34use std::collections::BTreeSet;
35
36/// Anything that can stand in for a `Snapshot` during retention selection. Kept tiny
37/// so tests use trivial fakes instead of constructing full `Snapshot` CRs.
38pub trait SnapshotLike {
39 /// The snapshot's completion time — the GFS bucketing key (ADR §4.4 step 2).
40 fn end_time(&self) -> DateTime<Utc>;
41 /// A stable identifier (kopia snapshot ID or CR name) used in the result sets.
42 fn id(&self) -> &str;
43 /// Whether this snapshot is pinned (`Snapshot.spec.pin`, ADR-0005 §13(c)). A
44 /// pinned snapshot is exempt from GFS retention: [`select_kept`] never places it
45 /// in `delete`, regardless of the policy. Defaults `false` so existing impls
46 /// (and discovered snapshots) keep their behavior.
47 fn pinned(&self) -> bool {
48 false
49 }
50}
51
52/// The outcome of a GFS selection: which ids to keep and which to delete. Both are
53/// returned explicitly so callers never have to recompute the complement.
54#[derive(Debug, Clone, PartialEq, Eq, Default)]
55pub struct KeptSet {
56 /// Ids retained by at least one bucket.
57 pub keep: Vec<String>,
58 /// Ids selected by no bucket — eligible for pruning.
59 pub delete: Vec<String>,
60}
61
62/// Calendar period a timestamp falls in, used to deduplicate "one per period."
63/// Distinct values mean distinct periods; comparing these is how each bucket keeps
64/// the newest entry per period.
65fn hour_key(t: DateTime<Utc>) -> (i32, u32, u32) {
66 (t.year(), t.ordinal(), t.hour())
67}
68fn day_key(t: DateTime<Utc>) -> (i32, u32) {
69 (t.year(), t.ordinal())
70}
71fn week_key(t: DateTime<Utc>) -> (i32, u32) {
72 let iso = t.iso_week();
73 (iso.year(), iso.week())
74}
75fn month_key(t: DateTime<Utc>) -> (i32, u32) {
76 (t.year(), t.month())
77}
78fn year_key(t: DateTime<Utc>) -> i32 {
79 t.year()
80}
81
82use chrono::Timelike;
83
84/// Walk `sorted` (newest→oldest) and collect the index of the newest entry in each
85/// distinct period, stopping once `count` periods have been kept.
86fn keep_per_period<K, F>(
87 sorted: &[usize],
88 times: &[DateTime<Utc>],
89 count: usize,
90 key: F,
91) -> Vec<usize>
92where
93 K: Ord,
94 F: Fn(DateTime<Utc>) -> K,
95{
96 let mut kept = Vec::new();
97 let mut seen: BTreeSet<K> = BTreeSet::new();
98 for &idx in sorted {
99 if kept.len() >= count {
100 break;
101 }
102 let k = key(times[idx]);
103 if seen.insert(k) {
104 // First (= newest, since sorted desc) entry in this period.
105 kept.push(idx);
106 }
107 }
108 kept
109}
110
111/// Select the GFS-kept set from `backups` under `policy` (ADR §4.4).
112///
113/// Returns a [`KeptSet`] partitioning every input id into `keep`/`delete`. Input
114/// order is irrelevant; `keep` is returned newest-first, `delete` newest-first too.
115/// Ties on `end_time` are broken by id for determinism.
116///
117/// ```
118/// use chrono::{DateTime, TimeZone, Utc};
119/// use kopiur_api::{select_kept, SnapshotLike};
120/// use kopiur_api::common::Retention;
121///
122/// // A trivial fake honoring SnapshotLike — no kube CRs needed for selection.
123/// struct Snap { id: String, end: DateTime<Utc> }
124/// impl SnapshotLike for Snap {
125/// fn end_time(&self) -> DateTime<Utc> { self.end }
126/// fn id(&self) -> &str { &self.id }
127/// }
128/// let day = |d: u32| Utc.with_ymd_and_hms(2026, 5, d, 2, 0, 0).single().unwrap();
129/// let snaps = vec![
130/// Snap { id: "d24".into(), end: day(24) },
131/// Snap { id: "d23".into(), end: day(23) },
132/// Snap { id: "d22".into(), end: day(22) },
133/// ];
134///
135/// // keepDaily: 2 — keep the newest per day for the 2 newest days; prune the rest.
136/// let policy: Retention =
137/// serde_json::from_value(serde_json::json!({ "keepDaily": 2 })).unwrap();
138/// let kept = select_kept(&snaps, &policy);
139/// assert_eq!(kept.keep, vec!["d24", "d23"]); // newest-first
140/// assert_eq!(kept.delete, vec!["d22"]);
141/// ```
142pub fn select_kept<T: SnapshotLike>(backups: &[T], policy: &Retention) -> KeptSet {
143 if backups.is_empty() {
144 return KeptSet::default();
145 }
146
147 let times: Vec<DateTime<Utc>> = backups.iter().map(|b| b.end_time()).collect();
148
149 // Indices sorted by end_time descending; id as a deterministic tiebreaker.
150 let mut order: Vec<usize> = (0..backups.len()).collect();
151 order.sort_by(|&a, &b| {
152 times[b]
153 .cmp(×[a])
154 .then_with(|| backups[a].id().cmp(backups[b].id()))
155 });
156
157 let mut keep_idx: BTreeSet<usize> = BTreeSet::new();
158
159 // keepLatest: the N newest outright.
160 if let Some(n) = policy.keep_latest {
161 for &idx in order.iter().take(n as usize) {
162 keep_idx.insert(idx);
163 }
164 }
165 if let Some(n) = policy.keep_hourly {
166 keep_idx.extend(keep_per_period(&order, ×, n as usize, hour_key));
167 }
168 if let Some(n) = policy.keep_daily {
169 keep_idx.extend(keep_per_period(&order, ×, n as usize, day_key));
170 }
171 if let Some(n) = policy.keep_weekly {
172 keep_idx.extend(keep_per_period(&order, ×, n as usize, week_key));
173 }
174 if let Some(n) = policy.keep_monthly {
175 keep_idx.extend(keep_per_period(&order, ×, n as usize, month_key));
176 }
177 if let Some(n) = policy.keep_annual {
178 keep_idx.extend(keep_per_period(&order, ×, n as usize, year_key));
179 }
180
181 let mut keep = Vec::new();
182 let mut delete = Vec::new();
183 for &idx in &order {
184 // A pinned snapshot is exempt from GFS retention (ADR-0005 §13(c)): it always
185 // survives a prune even when no bucket selected it — kopia would also refuse to
186 // expire it. Kept newest-first alongside bucket-kept ids.
187 if keep_idx.contains(&idx) || backups[idx].pinned() {
188 keep.push(backups[idx].id().to_string());
189 } else {
190 delete.push(backups[idx].id().to_string());
191 }
192 }
193 KeptSet { keep, delete }
194}
195
196// --- the population, and how it is bucketed ---------------------------------
197
198/// A minimal view of a `Snapshot` for retention selection: its CR name (the id
199/// used in delete decisions) and its snapshot end time (the GFS bucketing key).
200///
201/// `Clone` so the adoption retention gate (adoption inv. 8) can union these
202/// views with candidate views without re-deriving them from the CRs.
203#[derive(Debug, Clone)]
204pub struct SnapshotRetentionView {
205 /// CR name — the stable id returned in the kept/delete sets.
206 pub name: String,
207 /// Snapshot completion time (from `status.snapshot`/`status.timing`).
208 pub end_time: DateTime<Utc>,
209 /// Whether the `Snapshot` is pinned (`spec.pin`, ADR-0005 §13(c)) — exempt
210 /// from GFS retention (never selected for deletion).
211 pub pinned: bool,
212}
213
214impl SnapshotLike for SnapshotRetentionView {
215 fn end_time(&self) -> DateTime<Utc> {
216 self.end_time
217 }
218 fn id(&self) -> &str {
219 &self.name
220 }
221 fn pinned(&self) -> bool {
222 self.pinned
223 }
224}
225
226/// Build a retention view from a `Snapshot` CR, using `status.timing.endTime`
227/// (falling back to the CR creation timestamp). Returns `None` if the backup is
228/// not in a terminal successful state — only successful snapshots participate in
229/// GFS (failures are bounded separately by `failedJobsHistoryLimit`).
230///
231/// Lives in `kopiur-api` rather than in the controller because the web UI's
232/// "would retention keep this?" preview has to select the same population the
233/// prune does. A preview built from a different population is not a preview: one
234/// that admitted `Deleting` rows, or dropped rows whose `endTime` was missing,
235/// answers about a set the operator will never evaluate.
236pub fn retention_view(b: &crate::Snapshot) -> Option<SnapshotRetentionView> {
237 use crate::SnapshotPhase;
238 let status = b.status.as_ref()?;
239 // Exhaustive, not `!= Succeeded`: GFS membership is a CLASSIFICATION whose
240 // "no" side spans four unrelated meanings (in-flight, failed, deduped,
241 // foreign). A new phase silently defaulting to "not retention-governed"
242 // would quietly stop protecting a real restore point, so the compiler asks
243 // here first. Deliberately NOT `is_terminal()`: `Discovered`/`Unchanged`
244 // are terminal but must not claim a GFS bucket.
245 let participates_in_gfs = status.phase.as_ref().is_some_and(|p| match p {
246 SnapshotPhase::Succeeded => true,
247 // `Unchanged` owns no manifest, so it must never displace one that
248 // exists; `Discovered` is bounded by the catalog, not by this policy's
249 // retention; the rest are not terminal successes at all.
250 SnapshotPhase::Unchanged
251 | SnapshotPhase::Discovered
252 | SnapshotPhase::Pending
253 | SnapshotPhase::Running
254 | SnapshotPhase::Failed
255 | SnapshotPhase::Deleting => false,
256 // Never let a phase this build cannot read enter a set whose losers get
257 // DELETED from the repository.
258 SnapshotPhase::Unknown(_) => false,
259 });
260 if !participates_in_gfs {
261 return None;
262 }
263 // PROVENANCE (defense in depth): a `Succeeded` row only participates in GFS
264 // when it carries CONTROLLER-WRITTEN provenance (`status.snapshot`, the kopia
265 // id the operator produced or adopted). This closes the phantom-Succeeded
266 // displacement even if a phase were ever pinned without one — a forged bare
267 // `origin: adopted` label whose creationTimestamp fallback would otherwise
268 // claim a GFS bucket and displace a real snapshot into the retention delete
269 // set. Every genuine produced/adopted row has `status.snapshot`.
270 status.snapshot.as_ref()?;
271 let end_time = status
272 .timing
273 .as_ref()
274 .and_then(|t| t.end_time.as_deref())
275 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
276 .map(|dt| dt.with_timezone(&Utc))
277 .or_else(|| {
278 // metadata.creationTimestamp is a k8s-openapi `Time` wrapping a
279 // jiff `Timestamp`; convert via unix seconds to chrono.
280 b.metadata
281 .creation_timestamp
282 .as_ref()
283 .and_then(|t| DateTime::<Utc>::from_timestamp(t.0.as_second(), 0))
284 })?;
285 Some(SnapshotRetentionView {
286 name: b.metadata.name.clone().unwrap_or_default(),
287 end_time,
288 // A pinned Snapshot is exempt from GFS pruning (ADR-0005 §13(c)).
289 // `spec.pin` is what the user asked for and what the prune reads;
290 // `status.pinned` is what kopia currently holds, and during an unpin the
291 // two disagree — reading status there would promise a keep the next
292 // prune will not honor.
293 pinned: b.spec.pin,
294 })
295}
296
297/// **Pure.** The retention bucket a `Snapshot` belongs to.
298///
299/// One bucket per distinct backup source, so GFS keeps `keepDaily` days *of
300/// each PVC* rather than `keepDaily` snapshots across all of them. Empty for an
301/// un-fanned Snapshot, which is what makes a single-source policy one bucket
302/// and therefore unchanged.
303///
304/// **Multi-repo fan-out (#368):** while the policy is CURRENTLY multi-repo
305/// (`policy_is_multi`), the key also carries the child's mint-time repository
306/// pin (`spec.repository`, normalized at mint), so GFS keeps `keepDaily` days
307/// per (source, repository) — the N repositories are independent captures and
308/// must retain independently. The repo component comes from the SPEC pin ONLY
309/// (never `status.resolved` — a status-derived key would flap with backfills),
310/// and applies ONLY while the policy is multi-repo:
311///
312/// - single-repo policy (including after a multi→single edit): source-only
313/// buckets, byte-identical to today. Old pinned children merge back into the
314/// flat buckets — a documented TRANSIENT GFS mixing (the surviving repo's
315/// rows and the removed repo's leftovers compete in one bucket) that
316/// self-resolves as the removed repo's rows age out of every keep window.
317/// - multi-repo policy: (source, pin) buckets. Rows with NO pin (pre-feature
318/// children minted before the single→multi edit) land in the ""-repo bucket
319/// and age out; the policy reconciler's spec-pin backfill converges them into
320/// their real buckets first, so the ""-bucket is a shrinking transition set,
321/// not a steady state.
322pub fn retention_group_key(b: &crate::Snapshot, policy_is_multi: bool) -> String {
323 let source = match b.spec.source.as_ref().map(|s| &s.target) {
324 Some(crate::SnapshotSourceTarget::Pvc(t)) => {
325 format!("pvc/{}/{}", t.namespace, t.name)
326 }
327 None => String::new(),
328 };
329 if !policy_is_multi {
330 return source;
331 }
332 let repo = b
333 .spec
334 .repository
335 .as_ref()
336 .map(|r| crate::common::repo_key(r, b.metadata.namespace.as_deref().unwrap_or_default()))
337 .unwrap_or_default();
338 // '\n' can appear in neither component (DNS names / repo keys), so the
339 // joined key is injective over (source, repo).
340 format!("{source}\n{repo}")
341}
342
343/// **Pure.** The GFS population of `backups`, bucketed exactly as a prune would
344/// bucket it.
345///
346/// This is the one place the *shape* of a retention run is decided:
347/// [`retention_view`] filters the population and `retention_group_key` splits
348/// it. `backups_to_delete` runs `select_kept` over these buckets and returns the
349/// losers; the web UI runs the same `select_kept` over the same buckets and asks
350/// whether one id is a winner. Sharing this means a preview cannot answer about
351/// a set the operator will never evaluate.
352///
353/// Terminating rows are excluded (#382 C1): with the population served from a
354/// reflector store, an externally-deleted-but-still-cached NEWER snapshot must
355/// not claim a keep slot and push a LIVE row into the delete set.
356pub fn retention_buckets(
357 backups: &[&crate::Snapshot],
358 policy_is_multi: bool,
359) -> std::collections::BTreeMap<String, Vec<SnapshotRetentionView>> {
360 let mut buckets: std::collections::BTreeMap<String, Vec<SnapshotRetentionView>> =
361 std::collections::BTreeMap::new();
362 for b in backups {
363 if b.metadata.deletion_timestamp.is_some() {
364 continue;
365 }
366 if let Some(v) = retention_view(b) {
367 buckets
368 .entry(retention_group_key(b, policy_is_multi))
369 .or_default()
370 .push(v);
371 }
372 }
373 buckets
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use chrono::TimeZone;
380
381 /// Minimal fake honoring `SnapshotLike` — no kube CRs in retention tests.
382 struct Fake {
383 id: String,
384 end: DateTime<Utc>,
385 pinned: bool,
386 }
387 impl SnapshotLike for Fake {
388 fn end_time(&self) -> DateTime<Utc> {
389 self.end
390 }
391 fn id(&self) -> &str {
392 &self.id
393 }
394 fn pinned(&self) -> bool {
395 self.pinned
396 }
397 }
398
399 fn at(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> DateTime<Utc> {
400 Utc.with_ymd_and_hms(y, mo, d, h, mi, 0).single().unwrap()
401 }
402 fn fake(id: &str, t: DateTime<Utc>) -> Fake {
403 Fake {
404 id: id.into(),
405 end: t,
406 pinned: false,
407 }
408 }
409 fn pinned(id: &str, t: DateTime<Utc>) -> Fake {
410 Fake {
411 id: id.into(),
412 end: t,
413 pinned: true,
414 }
415 }
416
417 fn policy(
418 latest: Option<u32>,
419 hourly: Option<u32>,
420 daily: Option<u32>,
421 weekly: Option<u32>,
422 monthly: Option<u32>,
423 annual: Option<u32>,
424 ) -> Retention {
425 Retention {
426 keep_latest: latest,
427 keep_hourly: hourly,
428 keep_daily: daily,
429 keep_weekly: weekly,
430 keep_monthly: monthly,
431 keep_annual: annual,
432 }
433 }
434
435 fn as_set(v: &[String]) -> BTreeSet<&str> {
436 v.iter().map(String::as_str).collect()
437 }
438
439 #[test]
440 fn empty_input_yields_empty_sets() {
441 let got = select_kept::<Fake>(&[], &policy(Some(5), None, None, None, None, None));
442 assert!(got.keep.is_empty());
443 assert!(got.delete.is_empty());
444 }
445
446 #[test]
447 fn empty_policy_keeps_nothing() {
448 // All-None policy → no buckets selected → everything deleted.
449 let backups = vec![
450 fake("a", at(2026, 5, 24, 2, 0)),
451 fake("b", at(2026, 5, 23, 2, 0)),
452 ];
453 let got = select_kept(&backups, &Retention::default());
454 assert!(got.keep.is_empty(), "empty policy keeps nothing");
455 assert_eq!(as_set(&got.delete), ["a", "b"].into_iter().collect());
456 }
457
458 #[test]
459 fn keep_latest_keeps_n_newest() {
460 let backups = vec![
461 fake("d1", at(2026, 5, 24, 2, 0)),
462 fake("d2", at(2026, 5, 23, 2, 0)),
463 fake("d3", at(2026, 5, 22, 2, 0)),
464 fake("d4", at(2026, 5, 21, 2, 0)),
465 ];
466 let got = select_kept(&backups, &policy(Some(2), None, None, None, None, None));
467 assert_eq!(as_set(&got.keep), ["d1", "d2"].into_iter().collect());
468 assert_eq!(as_set(&got.delete), ["d3", "d4"].into_iter().collect());
469 }
470
471 #[test]
472 fn keep_daily_keeps_one_newest_per_day() {
473 // Three backups on day 24 (keep the 02:00 one), one each on 23 and 22.
474 let backups = vec![
475 fake("a", at(2026, 5, 24, 0, 5)),
476 fake("b", at(2026, 5, 24, 1, 30)),
477 fake("c", at(2026, 5, 24, 2, 0)), // newest on the 24th
478 fake("d", at(2026, 5, 23, 2, 0)),
479 fake("e", at(2026, 5, 22, 2, 0)),
480 ];
481 let got = select_kept(&backups, &policy(None, None, Some(14), None, None, None));
482 // One per distinct day, newest within the day.
483 assert_eq!(as_set(&got.keep), ["c", "d", "e"].into_iter().collect());
484 assert_eq!(as_set(&got.delete), ["a", "b"].into_iter().collect());
485 }
486
487 #[test]
488 fn keep_daily_count_caps_number_of_days() {
489 let backups = vec![
490 fake("d24", at(2026, 5, 24, 2, 0)),
491 fake("d23", at(2026, 5, 23, 2, 0)),
492 fake("d22", at(2026, 5, 22, 2, 0)),
493 fake("d21", at(2026, 5, 21, 2, 0)),
494 ];
495 let got = select_kept(&backups, &policy(None, None, Some(2), None, None, None));
496 // Only the 2 newest days kept.
497 assert_eq!(as_set(&got.keep), ["d24", "d23"].into_iter().collect());
498 assert_eq!(as_set(&got.delete), ["d22", "d21"].into_iter().collect());
499 }
500
501 #[test]
502 fn keep_latest_unions_with_keep_daily() {
503 // Two backups same day: keepDaily keeps the newest (c), keepLatest:2 also
504 // pulls in the second-newest overall (b) even though it shares c's day.
505 let backups = vec![
506 fake("c", at(2026, 5, 24, 6, 0)),
507 fake("b", at(2026, 5, 24, 5, 0)),
508 fake("a", at(2026, 5, 23, 5, 0)),
509 ];
510 let got = select_kept(&backups, &policy(Some(2), None, Some(7), None, None, None));
511 // c kept by both; b kept by keepLatest; a kept by keepDaily (day 23).
512 assert_eq!(as_set(&got.keep), ["a", "b", "c"].into_iter().collect());
513 assert!(got.delete.is_empty());
514 }
515
516 #[test]
517 fn annual_snapshot_survives_flood_of_newer_dailies() {
518 // The §4.4 "why not flat-count" case. One old end-of-2024 snapshot plus a
519 // pile of 2026 dailies. keepDaily:3 + keepAnnual:2 must retain the 2024
520 // snapshot as the newest-of-its-year even though it's far down the list.
521 let mut backups = vec![fake("y2024", at(2024, 12, 31, 23, 0))];
522 for d in 1..=10u32 {
523 backups.push(fake(&format!("y2026-{d:02}"), at(2026, 5, d, 2, 0)));
524 }
525 // Newest 2026 daily is day 10; year 2026's representative is day 10,
526 // year 2024's representative is y2024.
527 let got = select_kept(&backups, &policy(None, None, Some(3), None, None, Some(2)));
528 let keep = as_set(&got.keep);
529 assert!(
530 keep.contains("y2024"),
531 "annual snapshot must not be dropped by daily flood; kept={keep:?}"
532 );
533 // keepDaily:3 keeps the 3 newest days of 2026.
534 assert!(keep.contains("y2026-10"));
535 assert!(keep.contains("y2026-09"));
536 assert!(keep.contains("y2026-08"));
537 // Older 2026 dailies not covered by any bucket are deleted.
538 assert!(got.delete.contains(&"y2026-01".to_string()));
539 }
540
541 #[test]
542 fn monthly_and_weekly_pick_newest_in_period() {
543 let backups = vec![
544 fake("may-late", at(2026, 5, 28, 2, 0)),
545 fake("may-early", at(2026, 5, 2, 2, 0)),
546 fake("apr", at(2026, 4, 15, 2, 0)),
547 fake("mar", at(2026, 3, 15, 2, 0)),
548 ];
549 let got = select_kept(&backups, &policy(None, None, None, None, Some(2), None));
550 // keepMonthly:2 → newest of May (may-late) and newest of April (apr).
551 assert_eq!(as_set(&got.keep), ["may-late", "apr"].into_iter().collect());
552 }
553
554 #[test]
555 fn pinned_snapshot_survives_a_prune_that_would_delete_it() {
556 // ADR-0005 §13(c): a pinned snapshot is exempt from GFS retention. With
557 // keepLatest:1, the two older snapshots would normally be deleted — but the
558 // pinned one must survive while the unpinned one is pruned.
559 let backups = vec![
560 fake("newest", at(2026, 5, 24, 2, 0)),
561 pinned("pinned-old", at(2026, 5, 20, 2, 0)),
562 fake("unpinned-old", at(2026, 5, 19, 2, 0)),
563 ];
564 let got = select_kept(&backups, &policy(Some(1), None, None, None, None, None));
565 let keep = as_set(&got.keep);
566 let del = as_set(&got.delete);
567 assert!(keep.contains("newest"), "keepLatest:1 keeps the newest");
568 assert!(
569 keep.contains("pinned-old"),
570 "a pinned snapshot must survive a prune that would otherwise delete it"
571 );
572 assert!(
573 del.contains("unpinned-old"),
574 "the unpinned older snapshot is pruned"
575 );
576 assert!(!del.contains("pinned-old"), "pinned is never in delete");
577 }
578
579 #[test]
580 fn every_backup_kept_by_any_bucket_survives() {
581 // Mixed policy; assert the kept set is exactly the union and no kept id
582 // appears in delete.
583 let backups = vec![
584 fake("now", at(2026, 5, 24, 12, 0)),
585 fake("earlier-today", at(2026, 5, 24, 1, 0)),
586 fake("yesterday", at(2026, 5, 23, 1, 0)),
587 fake("last-week", at(2026, 5, 16, 1, 0)),
588 ];
589 let got = select_kept(
590 &backups,
591 &policy(Some(1), None, Some(2), Some(2), None, None),
592 );
593 let keep = as_set(&got.keep);
594 let del = as_set(&got.delete);
595 for id in keep.iter() {
596 assert!(!del.contains(id), "id {id} in both keep and delete");
597 }
598 // Every input is accounted for exactly once.
599 assert_eq!(keep.len() + del.len(), 4);
600 }
601
602 #[test]
603 fn e2e_gfs_history_partitions_exactly_as_the_retention_e2e_expects() {
604 // The EXACT history + spec the `gfs_time_buckets_prune_backdated_history`
605 // e2e (crates/e2e/tests/retention.rs) seeds, pinned hermetically so the
606 // e2e's in-test expectation can never drift from the kernel. Note
607 // keepAnnual: 2 — buckets are the N most recent periods CONTAINING
608 // snapshots, so holding a prior-year snapshot needs the 2026 bucket
609 // (the newest snapshot's year) plus one more.
610 let backups = vec![
611 fake("e2e-gfs-1", at(2025, 4, 10, 10, 0)),
612 fake("e2e-gfs-2", at(2026, 3, 2, 10, 0)),
613 fake("e2e-gfs-3", at(2026, 4, 6, 10, 0)),
614 fake("e2e-gfs-4", at(2026, 5, 25, 10, 0)),
615 fake("e2e-gfs-5", at(2026, 6, 1, 10, 0)),
616 fake("e2e-gfs-6", at(2026, 6, 8, 10, 0)),
617 fake("e2e-gfs-7", at(2026, 6, 8, 11, 0)),
618 ];
619 let policy: Retention = serde_json::from_value(serde_json::json!({
620 "keepLatest": 1, "keepDaily": 2, "keepWeekly": 2,
621 "keepMonthly": 2, "keepAnnual": 2
622 }))
623 .unwrap();
624 let got = select_kept(&backups, &policy);
625 assert_eq!(
626 as_set(&got.keep),
627 ["e2e-gfs-1", "e2e-gfs-4", "e2e-gfs-5", "e2e-gfs-7"]
628 .into_iter()
629 .collect(),
630 "keep: latest+daily+weekly (gfs-7/5), monthly #2 (gfs-4), annual #2 (gfs-1)"
631 );
632 assert_eq!(
633 as_set(&got.delete),
634 ["e2e-gfs-2", "e2e-gfs-3", "e2e-gfs-6"]
635 .into_iter()
636 .collect(),
637 "delete: months outside keepMonthly:2 and the same-day older duplicate"
638 );
639 }
640
641 /// Convergence keystone for adoption invariant 8 (controller crate): the
642 /// selection is stable on its own kept set — re-running it over exactly the
643 /// survivors deletes nothing and keeps the same set. This is what makes the
644 /// adoption gate's pre-prune evaluation equal the next retention pass's
645 /// decision: pruning the non-kept rows never re-selects (or de-selects) a
646 /// survivor. Holds because the selection is time-invariant (buckets derive
647 /// purely from end times), a non-kept row is never a newest-in-period
648 /// representative, and ties break deterministically by id.
649 #[test]
650 fn select_kept_is_stable_on_its_own_kept_set() {
651 let populations: Vec<Vec<Fake>> = vec![
652 // Dense multi-day spread with a same-instant id tie and a pinned
653 // straggler far outside every bucket.
654 vec![
655 fake("tie-a", at(2026, 5, 24, 2, 0)),
656 fake("tie-b", at(2026, 5, 24, 2, 0)),
657 fake("d23", at(2026, 5, 23, 2, 0)),
658 fake("d22-am", at(2026, 5, 22, 2, 0)),
659 fake("d22-pm", at(2026, 5, 22, 14, 0)),
660 fake("w-old", at(2026, 5, 1, 2, 0)),
661 pinned("pin-ancient", at(2020, 1, 1, 0, 0)),
662 ],
663 // Single row; empty input handled by select_kept directly.
664 vec![fake("only", at(2026, 5, 24, 2, 0))],
665 ];
666 let policies = [
667 policy(Some(2), None, None, None, None, None),
668 policy(None, None, Some(2), None, None, None),
669 policy(Some(1), None, Some(2), Some(1), Some(1), Some(1)),
670 policy(None, None, None, None, None, None), // keeps only pins
671 ];
672 for snaps in &populations {
673 for pol in &policies {
674 let first = select_kept(snaps, pol);
675 let survivors: Vec<Fake> = snaps
676 .iter()
677 .filter(|s| first.keep.iter().any(|k| k == &s.id))
678 .map(|s| Fake {
679 id: s.id.clone(),
680 end: s.end,
681 pinned: s.pinned,
682 })
683 .collect();
684 let second = select_kept(&survivors, pol);
685 assert!(
686 second.delete.is_empty(),
687 "keep(S) must be a fixed point; policy {pol:?} re-deleted {:?}",
688 second.delete
689 );
690 assert_eq!(
691 as_set(&second.keep),
692 as_set(&first.keep),
693 "keep(keep(S)) == keep(S) for policy {pol:?}"
694 );
695 }
696 }
697 }
698}