Skip to main content

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(&times[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, &times, n as usize, hour_key));
167    }
168    if let Some(n) = policy.keep_daily {
169        keep_idx.extend(keep_per_period(&order, &times, n as usize, day_key));
170    }
171    if let Some(n) = policy.keep_weekly {
172        keep_idx.extend(keep_per_period(&order, &times, n as usize, week_key));
173    }
174    if let Some(n) = policy.keep_monthly {
175        keep_idx.extend(keep_per_period(&order, &times, n as usize, month_key));
176    }
177    if let Some(n) = policy.keep_annual {
178        keep_idx.extend(keep_per_period(&order, &times, 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#[cfg(test)]
197mod tests {
198    use super::*;
199    use chrono::TimeZone;
200
201    /// Minimal fake honoring `SnapshotLike` — no kube CRs in retention tests.
202    struct Fake {
203        id: String,
204        end: DateTime<Utc>,
205        pinned: bool,
206    }
207    impl SnapshotLike for Fake {
208        fn end_time(&self) -> DateTime<Utc> {
209            self.end
210        }
211        fn id(&self) -> &str {
212            &self.id
213        }
214        fn pinned(&self) -> bool {
215            self.pinned
216        }
217    }
218
219    fn at(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> DateTime<Utc> {
220        Utc.with_ymd_and_hms(y, mo, d, h, mi, 0).single().unwrap()
221    }
222    fn fake(id: &str, t: DateTime<Utc>) -> Fake {
223        Fake {
224            id: id.into(),
225            end: t,
226            pinned: false,
227        }
228    }
229    fn pinned(id: &str, t: DateTime<Utc>) -> Fake {
230        Fake {
231            id: id.into(),
232            end: t,
233            pinned: true,
234        }
235    }
236
237    fn policy(
238        latest: Option<u32>,
239        hourly: Option<u32>,
240        daily: Option<u32>,
241        weekly: Option<u32>,
242        monthly: Option<u32>,
243        annual: Option<u32>,
244    ) -> Retention {
245        Retention {
246            keep_latest: latest,
247            keep_hourly: hourly,
248            keep_daily: daily,
249            keep_weekly: weekly,
250            keep_monthly: monthly,
251            keep_annual: annual,
252        }
253    }
254
255    fn as_set(v: &[String]) -> BTreeSet<&str> {
256        v.iter().map(String::as_str).collect()
257    }
258
259    #[test]
260    fn empty_input_yields_empty_sets() {
261        let got = select_kept::<Fake>(&[], &policy(Some(5), None, None, None, None, None));
262        assert!(got.keep.is_empty());
263        assert!(got.delete.is_empty());
264    }
265
266    #[test]
267    fn empty_policy_keeps_nothing() {
268        // All-None policy → no buckets selected → everything deleted.
269        let backups = vec![
270            fake("a", at(2026, 5, 24, 2, 0)),
271            fake("b", at(2026, 5, 23, 2, 0)),
272        ];
273        let got = select_kept(&backups, &Retention::default());
274        assert!(got.keep.is_empty(), "empty policy keeps nothing");
275        assert_eq!(as_set(&got.delete), ["a", "b"].into_iter().collect());
276    }
277
278    #[test]
279    fn keep_latest_keeps_n_newest() {
280        let backups = vec![
281            fake("d1", at(2026, 5, 24, 2, 0)),
282            fake("d2", at(2026, 5, 23, 2, 0)),
283            fake("d3", at(2026, 5, 22, 2, 0)),
284            fake("d4", at(2026, 5, 21, 2, 0)),
285        ];
286        let got = select_kept(&backups, &policy(Some(2), None, None, None, None, None));
287        assert_eq!(as_set(&got.keep), ["d1", "d2"].into_iter().collect());
288        assert_eq!(as_set(&got.delete), ["d3", "d4"].into_iter().collect());
289    }
290
291    #[test]
292    fn keep_daily_keeps_one_newest_per_day() {
293        // Three backups on day 24 (keep the 02:00 one), one each on 23 and 22.
294        let backups = vec![
295            fake("a", at(2026, 5, 24, 0, 5)),
296            fake("b", at(2026, 5, 24, 1, 30)),
297            fake("c", at(2026, 5, 24, 2, 0)), // newest on the 24th
298            fake("d", at(2026, 5, 23, 2, 0)),
299            fake("e", at(2026, 5, 22, 2, 0)),
300        ];
301        let got = select_kept(&backups, &policy(None, None, Some(14), None, None, None));
302        // One per distinct day, newest within the day.
303        assert_eq!(as_set(&got.keep), ["c", "d", "e"].into_iter().collect());
304        assert_eq!(as_set(&got.delete), ["a", "b"].into_iter().collect());
305    }
306
307    #[test]
308    fn keep_daily_count_caps_number_of_days() {
309        let backups = vec![
310            fake("d24", at(2026, 5, 24, 2, 0)),
311            fake("d23", at(2026, 5, 23, 2, 0)),
312            fake("d22", at(2026, 5, 22, 2, 0)),
313            fake("d21", at(2026, 5, 21, 2, 0)),
314        ];
315        let got = select_kept(&backups, &policy(None, None, Some(2), None, None, None));
316        // Only the 2 newest days kept.
317        assert_eq!(as_set(&got.keep), ["d24", "d23"].into_iter().collect());
318        assert_eq!(as_set(&got.delete), ["d22", "d21"].into_iter().collect());
319    }
320
321    #[test]
322    fn keep_latest_unions_with_keep_daily() {
323        // Two backups same day: keepDaily keeps the newest (c), keepLatest:2 also
324        // pulls in the second-newest overall (b) even though it shares c's day.
325        let backups = vec![
326            fake("c", at(2026, 5, 24, 6, 0)),
327            fake("b", at(2026, 5, 24, 5, 0)),
328            fake("a", at(2026, 5, 23, 5, 0)),
329        ];
330        let got = select_kept(&backups, &policy(Some(2), None, Some(7), None, None, None));
331        // c kept by both; b kept by keepLatest; a kept by keepDaily (day 23).
332        assert_eq!(as_set(&got.keep), ["a", "b", "c"].into_iter().collect());
333        assert!(got.delete.is_empty());
334    }
335
336    #[test]
337    fn annual_snapshot_survives_flood_of_newer_dailies() {
338        // The §4.4 "why not flat-count" case. One old end-of-2024 snapshot plus a
339        // pile of 2026 dailies. keepDaily:3 + keepAnnual:2 must retain the 2024
340        // snapshot as the newest-of-its-year even though it's far down the list.
341        let mut backups = vec![fake("y2024", at(2024, 12, 31, 23, 0))];
342        for d in 1..=10u32 {
343            backups.push(fake(&format!("y2026-{d:02}"), at(2026, 5, d, 2, 0)));
344        }
345        // Newest 2026 daily is day 10; year 2026's representative is day 10,
346        // year 2024's representative is y2024.
347        let got = select_kept(&backups, &policy(None, None, Some(3), None, None, Some(2)));
348        let keep = as_set(&got.keep);
349        assert!(
350            keep.contains("y2024"),
351            "annual snapshot must not be dropped by daily flood; kept={keep:?}"
352        );
353        // keepDaily:3 keeps the 3 newest days of 2026.
354        assert!(keep.contains("y2026-10"));
355        assert!(keep.contains("y2026-09"));
356        assert!(keep.contains("y2026-08"));
357        // Older 2026 dailies not covered by any bucket are deleted.
358        assert!(got.delete.contains(&"y2026-01".to_string()));
359    }
360
361    #[test]
362    fn monthly_and_weekly_pick_newest_in_period() {
363        let backups = vec![
364            fake("may-late", at(2026, 5, 28, 2, 0)),
365            fake("may-early", at(2026, 5, 2, 2, 0)),
366            fake("apr", at(2026, 4, 15, 2, 0)),
367            fake("mar", at(2026, 3, 15, 2, 0)),
368        ];
369        let got = select_kept(&backups, &policy(None, None, None, None, Some(2), None));
370        // keepMonthly:2 → newest of May (may-late) and newest of April (apr).
371        assert_eq!(as_set(&got.keep), ["may-late", "apr"].into_iter().collect());
372    }
373
374    #[test]
375    fn pinned_snapshot_survives_a_prune_that_would_delete_it() {
376        // ADR-0005 §13(c): a pinned snapshot is exempt from GFS retention. With
377        // keepLatest:1, the two older snapshots would normally be deleted — but the
378        // pinned one must survive while the unpinned one is pruned.
379        let backups = vec![
380            fake("newest", at(2026, 5, 24, 2, 0)),
381            pinned("pinned-old", at(2026, 5, 20, 2, 0)),
382            fake("unpinned-old", at(2026, 5, 19, 2, 0)),
383        ];
384        let got = select_kept(&backups, &policy(Some(1), None, None, None, None, None));
385        let keep = as_set(&got.keep);
386        let del = as_set(&got.delete);
387        assert!(keep.contains("newest"), "keepLatest:1 keeps the newest");
388        assert!(
389            keep.contains("pinned-old"),
390            "a pinned snapshot must survive a prune that would otherwise delete it"
391        );
392        assert!(
393            del.contains("unpinned-old"),
394            "the unpinned older snapshot is pruned"
395        );
396        assert!(!del.contains("pinned-old"), "pinned is never in delete");
397    }
398
399    #[test]
400    fn every_backup_kept_by_any_bucket_survives() {
401        // Mixed policy; assert the kept set is exactly the union and no kept id
402        // appears in delete.
403        let backups = vec![
404            fake("now", at(2026, 5, 24, 12, 0)),
405            fake("earlier-today", at(2026, 5, 24, 1, 0)),
406            fake("yesterday", at(2026, 5, 23, 1, 0)),
407            fake("last-week", at(2026, 5, 16, 1, 0)),
408        ];
409        let got = select_kept(
410            &backups,
411            &policy(Some(1), None, Some(2), Some(2), None, None),
412        );
413        let keep = as_set(&got.keep);
414        let del = as_set(&got.delete);
415        for id in keep.iter() {
416            assert!(!del.contains(id), "id {id} in both keep and delete");
417        }
418        // Every input is accounted for exactly once.
419        assert_eq!(keep.len() + del.len(), 4);
420    }
421
422    #[test]
423    fn e2e_gfs_history_partitions_exactly_as_the_retention_e2e_expects() {
424        // The EXACT history + spec the `gfs_time_buckets_prune_backdated_history`
425        // e2e (crates/e2e/tests/retention.rs) seeds, pinned hermetically so the
426        // e2e's in-test expectation can never drift from the kernel. Note
427        // keepAnnual: 2 — buckets are the N most recent periods CONTAINING
428        // snapshots, so holding a prior-year snapshot needs the 2026 bucket
429        // (the newest snapshot's year) plus one more.
430        let backups = vec![
431            fake("e2e-gfs-1", at(2025, 4, 10, 10, 0)),
432            fake("e2e-gfs-2", at(2026, 3, 2, 10, 0)),
433            fake("e2e-gfs-3", at(2026, 4, 6, 10, 0)),
434            fake("e2e-gfs-4", at(2026, 5, 25, 10, 0)),
435            fake("e2e-gfs-5", at(2026, 6, 1, 10, 0)),
436            fake("e2e-gfs-6", at(2026, 6, 8, 10, 0)),
437            fake("e2e-gfs-7", at(2026, 6, 8, 11, 0)),
438        ];
439        let policy: Retention = serde_json::from_value(serde_json::json!({
440            "keepLatest": 1, "keepDaily": 2, "keepWeekly": 2,
441            "keepMonthly": 2, "keepAnnual": 2
442        }))
443        .unwrap();
444        let got = select_kept(&backups, &policy);
445        assert_eq!(
446            as_set(&got.keep),
447            ["e2e-gfs-1", "e2e-gfs-4", "e2e-gfs-5", "e2e-gfs-7"]
448                .into_iter()
449                .collect(),
450            "keep: latest+daily+weekly (gfs-7/5), monthly #2 (gfs-4), annual #2 (gfs-1)"
451        );
452        assert_eq!(
453            as_set(&got.delete),
454            ["e2e-gfs-2", "e2e-gfs-3", "e2e-gfs-6"]
455                .into_iter()
456                .collect(),
457            "delete: months outside keepMonthly:2 and the same-day older duplicate"
458        );
459    }
460
461    /// Convergence keystone for adoption invariant 8 (controller crate): the
462    /// selection is stable on its own kept set — re-running it over exactly the
463    /// survivors deletes nothing and keeps the same set. This is what makes the
464    /// adoption gate's pre-prune evaluation equal the next retention pass's
465    /// decision: pruning the non-kept rows never re-selects (or de-selects) a
466    /// survivor. Holds because the selection is time-invariant (buckets derive
467    /// purely from end times), a non-kept row is never a newest-in-period
468    /// representative, and ties break deterministically by id.
469    #[test]
470    fn select_kept_is_stable_on_its_own_kept_set() {
471        let populations: Vec<Vec<Fake>> = vec![
472            // Dense multi-day spread with a same-instant id tie and a pinned
473            // straggler far outside every bucket.
474            vec![
475                fake("tie-a", at(2026, 5, 24, 2, 0)),
476                fake("tie-b", at(2026, 5, 24, 2, 0)),
477                fake("d23", at(2026, 5, 23, 2, 0)),
478                fake("d22-am", at(2026, 5, 22, 2, 0)),
479                fake("d22-pm", at(2026, 5, 22, 14, 0)),
480                fake("w-old", at(2026, 5, 1, 2, 0)),
481                pinned("pin-ancient", at(2020, 1, 1, 0, 0)),
482            ],
483            // Single row; empty input handled by select_kept directly.
484            vec![fake("only", at(2026, 5, 24, 2, 0))],
485        ];
486        let policies = [
487            policy(Some(2), None, None, None, None, None),
488            policy(None, None, Some(2), None, None, None),
489            policy(Some(1), None, Some(2), Some(1), Some(1), Some(1)),
490            policy(None, None, None, None, None, None), // keeps only pins
491        ];
492        for snaps in &populations {
493            for pol in &policies {
494                let first = select_kept(snaps, pol);
495                let survivors: Vec<Fake> = snaps
496                    .iter()
497                    .filter(|s| first.keep.iter().any(|k| k == &s.id))
498                    .map(|s| Fake {
499                        id: s.id.clone(),
500                        end: s.end,
501                        pinned: s.pinned,
502                    })
503                    .collect();
504                let second = select_kept(&survivors, pol);
505                assert!(
506                    second.delete.is_empty(),
507                    "keep(S) must be a fixed point; policy {pol:?} re-deleted {:?}",
508                    second.delete
509                );
510                assert_eq!(
511                    as_set(&second.keep),
512                    as_set(&first.keep),
513                    "keep(keep(S)) == keep(S) for policy {pol:?}"
514                );
515            }
516        }
517    }
518}