Skip to main content

gglib_core/domain/
slot_eviction.rs

1//! Byte-budget selection for on-disk KV slot cache eviction.
2//!
3//! Pure decision logic, no IO: given the current set of cached slot files and
4//! a byte budget, decide which files to delete. The disk-scanning and
5//! deletion itself lives in `gglib-proxy` (`slot_eviction.rs`), which stats
6//! the slot directory and hands the results here.
7
8use std::path::PathBuf;
9
10/// Metadata for one on-disk slot file, gathered by the IO layer.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct SlotFileMeta {
13    pub path: PathBuf,
14    pub mtime_unix_secs: u64,
15    pub len_bytes: u64,
16}
17
18/// Given all cached slot files and a byte budget, return the paths to delete.
19///
20/// Deletes oldest-`mtime`-first until the surviving total is `<= budget_bytes`.
21/// Ties on `mtime` break on `path` so the result is deterministic. A single
22/// file larger than the entire budget is still evicted (an empty cache is a
23/// valid outcome; a cache permanently over budget is not).
24#[must_use]
25pub fn select_evictions(mut files: Vec<SlotFileMeta>, budget_bytes: u64) -> Vec<PathBuf> {
26    files.sort_by(|a, b| {
27        a.mtime_unix_secs
28            .cmp(&b.mtime_unix_secs)
29            .then_with(|| a.path.cmp(&b.path))
30    });
31
32    let total: u64 = files.iter().map(|f| f.len_bytes).sum();
33    let mut remaining = total;
34    let mut evicted = Vec::new();
35
36    for file in files {
37        if remaining <= budget_bytes {
38            break;
39        }
40        remaining = remaining.saturating_sub(file.len_bytes);
41        evicted.push(file.path);
42    }
43
44    evicted
45}
46
47/// Divisor applied to (free disk space + cache footprint) for the auto budget.
48///
49/// Recomputed on every sweep so it tracks disk pressure from other
50/// applications, not just this cache's own growth.
51pub const DISK_BUDGET_FRACTION_DIVISOR: u64 = 4;
52
53/// Compute an auto-sized disk budget, in bytes.
54///
55/// `available_bytes` is free space on the filesystem holding the slot
56/// directory; `current_cache_bytes` is the cache's own current footprint
57/// (already-cached files count as "available" for the cache to keep using,
58/// since evicting them doesn't free space for anything else). The budget is
59/// a quarter of that combined figure — safe headroom for the rest of the
60/// disk, expanding automatically as free space changes.
61#[must_use]
62pub const fn compute_auto_disk_budget_bytes(available_bytes: u64, current_cache_bytes: u64) -> u64 {
63    available_bytes.saturating_add(current_cache_bytes) / DISK_BUDGET_FRACTION_DIVISOR
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    fn meta(path: &str, mtime: u64, len: u64) -> SlotFileMeta {
71        SlotFileMeta {
72            path: PathBuf::from(path),
73            mtime_unix_secs: mtime,
74            len_bytes: len,
75        }
76    }
77
78    #[test]
79    fn under_budget_evicts_nothing() {
80        let files = vec![meta("a.bin", 1, 100), meta("b.bin", 2, 100)];
81        assert!(select_evictions(files, 1000).is_empty());
82    }
83
84    #[test]
85    fn exact_budget_boundary_evicts_nothing() {
86        let files = vec![meta("a.bin", 1, 100), meta("b.bin", 2, 100)];
87        assert!(select_evictions(files, 200).is_empty());
88    }
89
90    #[test]
91    fn evicts_oldest_first_until_under_budget() {
92        let files = vec![
93            meta("oldest.bin", 1, 100),
94            meta("middle.bin", 2, 100),
95            meta("newest.bin", 3, 100),
96        ];
97        // total 300, budget 150 -> evict oldest (200 left), then middle (100 left)
98        let evicted = select_evictions(files, 150);
99        assert_eq!(
100            evicted,
101            vec![PathBuf::from("oldest.bin"), PathBuf::from("middle.bin")]
102        );
103    }
104
105    #[test]
106    fn zero_budget_evicts_everything() {
107        let files = vec![meta("a.bin", 1, 100), meta("b.bin", 2, 100)];
108        let evicted = select_evictions(files, 0);
109        assert_eq!(evicted.len(), 2);
110    }
111
112    #[test]
113    fn single_file_larger_than_budget_is_evicted() {
114        let files = vec![meta("huge.bin", 1, 10_000)];
115        assert_eq!(
116            select_evictions(files, 100),
117            vec![PathBuf::from("huge.bin")]
118        );
119    }
120
121    #[test]
122    fn mtime_ties_break_on_path() {
123        let files = vec![
124            meta("z.bin", 5, 100),
125            meta("a.bin", 5, 100),
126            meta("m.bin", 5, 100),
127        ];
128        // all same mtime, budget forces evicting two -> lexicographically first two
129        let evicted = select_evictions(files, 100);
130        assert_eq!(
131            evicted,
132            vec![PathBuf::from("a.bin"), PathBuf::from("m.bin")]
133        );
134    }
135
136    #[test]
137    fn auto_disk_budget_is_a_quarter_of_available_plus_cache() {
138        // 40 GiB free + 8 GiB already cached -> 12 GiB budget
139        let available = 40 * 1024 * 1024 * 1024;
140        let cache = 8 * 1024 * 1024 * 1024;
141        assert_eq!(
142            compute_auto_disk_budget_bytes(available, cache),
143            12 * 1024 * 1024 * 1024
144        );
145    }
146
147    #[test]
148    fn auto_disk_budget_saturates_on_overflow() {
149        // available + cache overflows u64 and saturates to u64::MAX before dividing.
150        assert_eq!(
151            compute_auto_disk_budget_bytes(u64::MAX, u64::MAX),
152            u64::MAX / DISK_BUDGET_FRACTION_DIVISOR
153        );
154    }
155}