kache_store/eviction.rs
1//! Eviction *selection*, decoupled from the removal *mechanism*
2//! (kunobi-ninja/kache#595).
3//!
4//! `Store` historically had three eviction entry points — size-pressure,
5//! age-based, and content-duplicate — each with its own hardcoded `SELECT`,
6//! all funnelling into the same `remove_entry_guarded`. Mechanism was already
7//! unified; selection was not.
8//!
9//! This module makes selection a pure function of observable per-entry
10//! features. Two consequences matter:
11//!
12//! - **Shadow evaluation.** A candidate policy can rank the same candidate set
13//! and log what it *would* evict, while the live policy decides what actually
14//! goes. That comparison is what #594 needs, and it is impractical while the
15//! ranking is an `ORDER BY` clause inside the removal loop.
16//! - **Signals beyond SQL columns.** Selection written as SQL can only see
17//! columns of `entries`; `compile_time_ms` lives in each entry's `meta.json`
18//! and is therefore invisible to eviction today (#594). With features
19//! assembled in Rust, adding a signal is a struct field.
20//!
21//! Policies never remove anything. The pin/grace check, blob refcount
22//! decrement, and refuse-on-corrupt-meta guard stay in
23//! `Store::remove_entry_guarded` — a policy that could bypass them would
24//! reintroduce the mid-restore eviction race (#326, #182).
25
26use std::cmp::Ordering;
27use std::collections::HashMap;
28
29/// Percentage of `max_size` a size-pressure sweep evicts *down to*.
30pub const EVICT_TARGET_PERCENT: u64 = 90;
31
32/// The low edge of GC's hysteresis band: the size a sweep evicts down to.
33///
34/// Compute through `u128` so arbitrary byte-sized limits get an exact floor
35/// without overflowing near `u64::MAX`.
36pub fn eviction_target(max_size: u64) -> u64 {
37 ((max_size as u128 * EVICT_TARGET_PERCENT as u128) / 100) as u64
38}
39
40/// The high edge of GC's hysteresis band: the size a sweep *fires* at.
41///
42/// Trigger and target were both [`eviction_target`], so "evict to 90% — avoids
43/// boundary thrashing" described a band that did not exist: a store parked on
44/// the 90% line re-triggered on essentially every pass, each one evicting just
45/// enough to dip under before the next `put` pushed it back over. Firing at the
46/// configured cap and stopping at 90% gives the sweep the 10% of headroom the
47/// comment always claimed.
48pub fn over_eviction_trigger(physical_size: u64, max_size: u64) -> bool {
49 physical_size > max_size
50}
51
52/// The observable state of one cache entry, as seen by an eviction policy.
53///
54/// Deliberately small: it is materialized for every entry in the store on each
55/// sweep, so new fields should earn their bytes.
56#[derive(Debug, Clone)]
57pub struct EntryFeatures {
58 pub key: String,
59 pub size: i64,
60 pub hit_count: i64,
61 /// Hours since `last_accessed`. May be negative under clock skew; policies
62 /// are responsible for clamping (the previous SQL clamped at 0.01).
63 pub idle_hours: f64,
64 /// Content address of the entry's artifact set, when known.
65 pub content_hash: Option<String>,
66 pub committed: bool,
67 /// What a miss on this entry would cost to rebuild, in milliseconds
68 /// (kunobi-ninja/kache#594). `0` means unknown — either a pre-#594 entry
69 /// not yet backfilled, or a compile too fast to register. No policy ships
70 /// today that reads this; it is here so a value-aware policy can be
71 /// written and shadow-evaluated against the current one.
72 pub compile_time_ms: i64,
73 /// Physical bytes evicting this entry would actually free right now:
74 /// the sizes of blobs it holds every remaining reference to
75 /// (kunobi-ninja/kache#608). Zero for an entry whose blobs are all
76 /// shared — evicting it destroys rebuild value and reclaims nothing.
77 /// `None` means unknown (entry not yet backfilled into `entry_blobs`);
78 /// policies fall back to the logical `size`.
79 pub reclaimable_bytes: Option<i64>,
80 /// Recent at candidate selection time. GC skips their metadata read and
81 /// transaction setup; removal checks the grace again for other entries.
82 pub recently_accessed: bool,
83 /// Delivered by the remote within [`crate::store::IMPORT_PIN`], judged for
84 /// an automatic sweep; always false for a sweep the user asked for.
85 /// Eviction keeps it (kunobi-ninja/kache#1008).
86 pub recently_imported: bool,
87}
88
89/// Ranks or filters eviction candidates. Pure: no I/O, no store mutation.
90pub trait EvictionPolicy {
91 /// Stable identifier, for logging and shadow-mode comparison.
92 fn name(&self) -> &'static str;
93
94 /// Candidates to evict, worst-first. The caller stops early when a size
95 /// budget is satisfied, so a policy may return more keys than are needed.
96 fn select(&self, candidates: &[EntryFeatures]) -> Vec<String>;
97}
98
99/// Priority score for size-pressure eviction; **lower is evicted first**.
100///
101/// `(hit_count + 1) / (idle_hours * reclaimable_mb)` — prefers evicting stale,
102/// rarely-hit entries whose removal actually frees bytes, degrading to LRU
103/// with a size tiebreaker when ages are similar. The size term is the entry's
104/// *marginal reclaimable* bytes (blobs it holds the last references to), not
105/// its logical size: a 500 MB entry whose blobs are all shared frees nothing,
106/// while a 200 MB entry with unique blobs frees 200 MB, and a size-pressure
107/// sweep exists to free bytes (kunobi-ninja/kache#608). Entries not yet
108/// backfilled into `entry_blobs` (`None`) rank on logical size as before.
109/// The clamps reproduce the `MAX(..., 0.01)` / `MAX(..., 0.001)` of the SQL
110/// this replaced, which exist to keep a just-accessed or free-nothing entry
111/// from dividing by zero — a fully-shared entry thus ranks as if it were
112/// tiny, i.e. last among equally stale candidates.
113///
114/// Note this score has no notion of what an entry costs to *rebuild*; see #594.
115pub fn size_pressure_score(e: &EntryFeatures) -> f64 {
116 let idle = e.idle_hours.max(0.01);
117 let bytes = e.reclaimable_bytes.unwrap_or(e.size);
118 let size_mb = (bytes as f64 / 1_048_576.0).max(0.001);
119 (e.hit_count as f64 + 1.0) / (idle * size_mb)
120}
121
122/// Size-pressure eviction: rank every entry by [`size_pressure_score`].
123pub struct SizePressurePolicy;
124
125impl EvictionPolicy for SizePressurePolicy {
126 fn name(&self) -> &'static str {
127 "size-pressure"
128 }
129
130 fn select(&self, candidates: &[EntryFeatures]) -> Vec<String> {
131 let mut ranked: Vec<&EntryFeatures> = candidates.iter().collect();
132 // Total order: NaN cannot arise from the clamped score, but sort_by
133 // demands a total comparator, so fall back to Equal rather than panic.
134 ranked.sort_by(|a, b| {
135 size_pressure_score(a)
136 .partial_cmp(&size_pressure_score(b))
137 .unwrap_or(Ordering::Equal)
138 });
139 ranked.into_iter().map(|e| e.key.clone()).collect()
140 }
141}
142
143/// Retention value per reclaimable byte; **lower is evicted first**
144/// (kunobi-ninja/kache#594, shadow candidate — never the live policy yet).
145///
146/// `compile_time_ms / reclaimable_mb`: what a re-miss on this entry costs to
147/// rebuild, per byte its eviction would actually free. This is deliberately
148/// the PURE density score from the #594 analysis, with no recency or
149/// hit-count terms bolted on: reuse probability belongs as a multiplier
150/// estimated from the tombstone demand stream, not as an arbitrary divisor —
151/// and the demand stream this shadow feeds is exactly what will estimate it.
152///
153/// Two knowingly rough edges, acceptable because this only ever shadows:
154/// entries whose `compile_time_ms` is still 0 (not yet backfilled, or a
155/// sub-millisecond compile) rank as worthless and become the shadow's first
156/// victims, and the serve-from-cache cost is treated as negligible rather
157/// than subtracted from the rebuild cost.
158pub fn value_density_score(e: &EntryFeatures) -> f64 {
159 let bytes = e.reclaimable_bytes.unwrap_or(e.size);
160 let size_mb = (bytes as f64 / 1_048_576.0).max(0.001);
161 e.compile_time_ms as f64 / size_mb
162}
163
164/// Pure rebuild-cost-density ranking — the #594 shadow candidate.
165pub struct ValueDensityPolicy;
166
167impl EvictionPolicy for ValueDensityPolicy {
168 fn name(&self) -> &'static str {
169 "value-density"
170 }
171
172 fn select(&self, candidates: &[EntryFeatures]) -> Vec<String> {
173 let mut ranked: Vec<&EntryFeatures> = candidates.iter().collect();
174 ranked.sort_by(|a, b| {
175 value_density_score(a)
176 .partial_cmp(&value_density_score(b))
177 .unwrap_or(Ordering::Equal)
178 });
179 ranked.into_iter().map(|e| e.key.clone()).collect()
180 }
181}
182
183/// The set of keys a policy's ranking would evict to free `bytes_needed` —
184/// the shadow half of a #594 comparison. Walks `order` accumulating each
185/// entry's *expected* reclaim (its marginal reclaimable bytes, logical size
186/// when unknown) until the budget is met.
187///
188/// An approximation over the pre-sweep snapshot, not a simulation: the live
189/// walk budgets on bytes each removal ACTUALLY freed, where a shared blob's
190/// bytes materialize only when its last surviving twin goes, while this walk
191/// sees every twin's marginal bytes as the snapshot reported them (zero for
192/// all of them) and never updates as it goes. Unknown reclaim falls back to
193/// logical size, which can overestimate progress and shorten the prefix, and
194/// entries the live sweep skips (recency-pinned, lost removal races) still
195/// consume this budget. Good enough for a per-entry agreement verdict;
196/// a faithful counterfactual sweep would need dynamically updated blob
197/// refcounts and the live eligibility rules (#594 follow-up territory).
198pub fn would_evict_for_budget(
199 candidates: &[EntryFeatures],
200 order: &[String],
201 bytes_needed: u64,
202) -> std::collections::HashSet<String> {
203 let by_key: HashMap<&str, &EntryFeatures> =
204 candidates.iter().map(|e| (e.key.as_str(), e)).collect();
205 let mut victims = std::collections::HashSet::new();
206 let mut freed: u64 = 0;
207 for key in order {
208 if freed >= bytes_needed {
209 break;
210 }
211 let Some(e) = by_key.get(key.as_str()) else {
212 continue;
213 };
214 freed += e.reclaimable_bytes.unwrap_or(e.size).max(0) as u64;
215 victims.insert(key.clone());
216 }
217 victims
218}
219
220/// Age-based eviction: every entry untouched for more than `hours`.
221///
222/// Replaces `WHERE last_accessed < datetime('now', '-N hours')`. One deliberate
223/// difference: `datetime()` compares at whole-second granularity, whereas
224/// `idle_hours` comes from `julianday()` and is sub-second. An entry sitting
225/// within the same second as the cutoff is therefore now selected where the
226/// old query kept it. Immaterial for a retention sweep measured in hours, and
227/// the finer comparison is the more truthful one, but it is a difference and
228/// not a pure refactor.
229pub struct OlderThanPolicy {
230 pub hours: u64,
231}
232
233impl EvictionPolicy for OlderThanPolicy {
234 fn name(&self) -> &'static str {
235 "older-than"
236 }
237
238 fn select(&self, candidates: &[EntryFeatures]) -> Vec<String> {
239 let cutoff = self.hours as f64;
240 candidates
241 .iter()
242 .filter(|e| e.idle_hours > cutoff)
243 .map(|e| e.key.clone())
244 .collect()
245 }
246}
247
248/// Content-duplicate eviction: when several committed entries share one
249/// `content_hash`, keep the most recently accessed and evict the rest —
250/// but only where removal actually frees bytes.
251///
252/// Ties at the group maximum are all kept, reproducing the strict `<` of the
253/// previous `WHERE e.last_accessed < dups.newest_access`.
254///
255/// Blobs are refcounted and shared, so deleting a key whose blobs are all held
256/// by another entry destroys hit history without reclaiming space. Candidates
257/// are eligible only when backfill proves a positive marginal reclaim. Unknown
258/// legacy rows fail closed until a later bounded backfill maps them.
259pub struct DuplicatePolicy;
260
261impl EvictionPolicy for DuplicatePolicy {
262 fn name(&self) -> &'static str {
263 "duplicate"
264 }
265
266 fn select(&self, candidates: &[EntryFeatures]) -> Vec<String> {
267 // Group committed, content-addressed entries by content hash. Idle time
268 // is the inverse of recency, so the group's *minimum* idle is its most
269 // recently accessed member.
270 let mut newest: HashMap<&str, f64> = HashMap::new();
271 let mut counts: HashMap<&str, usize> = HashMap::new();
272 for e in candidates {
273 if !e.committed {
274 continue;
275 }
276 let Some(hash) = e.content_hash.as_deref() else {
277 continue;
278 };
279 *counts.entry(hash).or_insert(0) += 1;
280 newest
281 .entry(hash)
282 .and_modify(|m| *m = m.min(e.idle_hours))
283 .or_insert(e.idle_hours);
284 }
285
286 let mut eligible: Vec<&EntryFeatures> = candidates
287 .iter()
288 .filter(|e| {
289 if !e.committed {
290 return false;
291 }
292 let Some(hash) = e.content_hash.as_deref() else {
293 return false;
294 };
295 // Removing this key must actually free bytes; a fully shared
296 // entry costs its hit history and reclaims nothing.
297 if !matches!(e.reclaimable_bytes, Some(bytes) if bytes > 0) {
298 return false;
299 }
300 // Only groups with a genuine duplicate, and only members
301 // strictly older than the group's newest access.
302 counts.get(hash).copied().unwrap_or(0) > 1
303 && newest
304 .get(hash)
305 .is_some_and(|newest_idle| e.idle_hours > *newest_idle)
306 })
307 .collect();
308
309 // The removal walk may stop once its byte target is met, so return
310 // victims worst-first: stalest duplicate first, then key order for a
311 // stable choice among equally old entries.
312 eligible.sort_by(|a, b| {
313 b.idle_hours
314 .total_cmp(&a.idle_hours)
315 .then_with(|| a.key.cmp(&b.key))
316 });
317 eligible.into_iter().map(|e| e.key.clone()).collect()
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 #[test]
326 fn eviction_target_is_exact_for_arbitrary_limits_without_overflow() {
327 assert_eq!(eviction_target(1_010), 909);
328 assert_eq!(
329 eviction_target(u64::MAX),
330 ((u64::MAX as u128 * EVICT_TARGET_PERCENT as u128) / 100) as u64
331 );
332 }
333
334 #[test]
335 fn eviction_trigger_is_strictly_above_the_configured_cap() {
336 assert!(!over_eviction_trigger(999, 1_000));
337 assert!(!over_eviction_trigger(1_000, 1_000));
338 assert!(over_eviction_trigger(1_001, 1_000));
339 }
340
341 fn feat(key: &str, size: i64, hits: i64, idle: f64) -> EntryFeatures {
342 EntryFeatures {
343 key: key.into(),
344 size,
345 hit_count: hits,
346 idle_hours: idle,
347 content_hash: None,
348 committed: true,
349 compile_time_ms: 0,
350 reclaimable_bytes: None,
351 recently_accessed: false,
352 recently_imported: false,
353 }
354 }
355
356 /// The score must rank a large, stale, unused entry below a small, fresh,
357 /// frequently-hit one — the ordering the size-pressure sweep depends on.
358 #[test]
359 fn size_pressure_ranks_large_stale_unused_first() {
360 let big_stale = feat("big", 600 * 1024 * 1024, 0, 15.0);
361 let small_hot = feat("small", 14 * 1024, 9, 0.1);
362 assert!(size_pressure_score(&big_stale) < size_pressure_score(&small_hot));
363
364 let order = SizePressurePolicy.select(&[small_hot, big_stale]);
365 assert_eq!(order, vec!["big", "small"]);
366 }
367
368 /// A just-accessed entry (idle ~0) and a zero-size entry must not produce a
369 /// division blow-up — the reason the SQL carried MAX() clamps.
370 #[test]
371 fn size_pressure_score_is_finite_at_the_clamps() {
372 assert!(size_pressure_score(&feat("fresh", 1024, 0, 0.0)).is_finite());
373 assert!(size_pressure_score(&feat("empty", 0, 0, 5.0)).is_finite());
374 // Clock skew must not invert the ordering into +inf.
375 assert!(size_pressure_score(&feat("skewed", 1024, 0, -3.0)).is_finite());
376 // A fully-shared entry (reclaims nothing) hits the same clamp.
377 let mut shared = feat("shared", 1024, 0, 5.0);
378 shared.reclaimable_bytes = Some(0);
379 assert!(size_pressure_score(&shared).is_finite());
380 }
381
382 /// The size term is *marginal reclaimable* bytes, not logical size
383 /// (kunobi-ninja/kache#608): an entry with unique blobs must be evicted
384 /// before an equally stale, larger entry whose blobs are all shared —
385 /// evicting the latter frees nothing.
386 #[test]
387 fn size_pressure_prefers_entries_that_actually_free_bytes() {
388 let mut shared_big = feat("shared_big", 500 * 1024 * 1024, 0, 10.0);
389 shared_big.reclaimable_bytes = Some(0);
390 let mut unique_small = feat("unique_small", 200 * 1024 * 1024, 0, 10.0);
391 unique_small.reclaimable_bytes = Some(200 * 1024 * 1024);
392
393 assert!(size_pressure_score(&unique_small) < size_pressure_score(&shared_big));
394 let order = SizePressurePolicy.select(&[shared_big, unique_small]);
395 assert_eq!(order, vec!["unique_small", "shared_big"]);
396 }
397
398 /// An entry not yet backfilled into `entry_blobs` (None) ranks on its
399 /// logical size — the pre-#608 behavior — rather than as free-nothing.
400 #[test]
401 fn size_pressure_falls_back_to_logical_size_when_reclaimable_unknown() {
402 let unknown = feat("unknown", 600 * 1024 * 1024, 0, 15.0);
403 let small_hot = feat("small", 14 * 1024, 9, 0.1);
404 assert!(size_pressure_score(&unknown) < size_pressure_score(&small_hot));
405 }
406
407 /// kunobi-ninja/kache#594: the shadow candidate ranks by rebuild cost per
408 /// reclaimable byte — a big cheap entry goes before a small expensive
409 /// one, the opposite of what size pressure prefers.
410 #[test]
411 fn value_density_evicts_cheap_per_byte_first() {
412 let mut big_cheap = feat("big_cheap", 600 * 1024 * 1024, 0, 15.0);
413 big_cheap.compile_time_ms = 300; // 0.3s for 600 MB
414 let mut small_expensive = feat("small_expensive", 3 * 1024 * 1024, 0, 15.0);
415 small_expensive.compile_time_ms = 6_000; // 6s for 3 MB
416
417 assert!(value_density_score(&big_cheap) < value_density_score(&small_expensive));
418 let order = ValueDensityPolicy.select(&[small_expensive, big_cheap]);
419 assert_eq!(order, vec!["big_cheap", "small_expensive"]);
420 }
421
422 /// Density divides by marginal reclaimable bytes when known: an entry
423 /// whose blobs are all shared frees nothing, so its retention costs
424 /// nothing and it ranks LAST (infinite-ish density from the clamp).
425 #[test]
426 fn value_density_uses_reclaimable_bytes_and_ranks_unknown_cost_first() {
427 let mut shared = feat("shared", 500 * 1024 * 1024, 0, 10.0);
428 shared.reclaimable_bytes = Some(0);
429 shared.compile_time_ms = 1_000;
430 let mut unique = feat("unique", 10 * 1024 * 1024, 0, 10.0);
431 unique.reclaimable_bytes = Some(10 * 1024 * 1024);
432 unique.compile_time_ms = 1_000;
433 assert!(value_density_score(&unique) < value_density_score(&shared));
434
435 // Zero compile time (not yet backfilled) ranks as worthless — a
436 // documented shadow-only rough edge.
437 let unbackfilled = feat("unbackfilled", 1024, 0, 10.0);
438 assert_eq!(value_density_score(&unbackfilled), 0.0);
439 }
440
441 /// Below the 0.001 MB clamp the size term saturates, so sub-clamp
442 /// entries order purely by rebuild cost. Pins the clamp's placement on
443 /// the MB-converted value: mis-scaling the conversion (which is
444 /// order-preserving everywhere else) un-saturates these and flips them.
445 #[test]
446 fn value_density_orders_sub_clamp_entries_by_cost_alone() {
447 let mut a = feat("a", 500, 0, 1.0);
448 a.compile_time_ms = 1;
449 let mut b = feat("b", 1000, 0, 1.0);
450 b.compile_time_ms = 2;
451 // Both clamp to 0.001 MB: scores are 1000 vs 2000, cheaper first.
452 assert!(value_density_score(&a) < value_density_score(&b));
453 let order = ValueDensityPolicy.select(&[b, a]);
454 assert_eq!(order, vec!["a", "b"]);
455 }
456
457 /// The budget walk takes the ranking's prefix whose expected reclaim
458 /// covers `bytes_needed`, and no more.
459 #[test]
460 fn would_evict_for_budget_takes_the_covering_prefix() {
461 let mut a = feat("a", 100, 0, 1.0);
462 a.reclaimable_bytes = Some(100);
463 let mut b = feat("b", 100, 0, 1.0);
464 b.reclaimable_bytes = Some(0); // fully shared: frees nothing
465 let mut c = feat("c", 100, 0, 1.0);
466 c.reclaimable_bytes = Some(100);
467 let d = feat("d", 100, 0, 1.0); // unknown: expected reclaim = logical 100
468
469 let candidates = vec![a, b, c, d];
470 let order: Vec<String> = ["a", "b", "c", "d"].iter().map(|s| s.to_string()).collect();
471
472 let victims = would_evict_for_budget(&candidates, &order, 150);
473 // a frees 100 (< 150), b frees 0, c reaches 200 — d is spared.
474 assert!(victims.contains("a") && victims.contains("b") && victims.contains("c"));
475 assert!(!victims.contains("d"));
476
477 assert!(would_evict_for_budget(&candidates, &order, 0).is_empty());
478 }
479
480 #[test]
481 fn older_than_selects_strictly_older_entries() {
482 let c = vec![
483 feat("old", 100, 0, 48.5),
484 feat("exactly", 100, 0, 24.0),
485 feat("fresh", 100, 0, 1.0),
486 ];
487 let picked = OlderThanPolicy { hours: 24 }.select(&c);
488 assert_eq!(
489 picked,
490 vec!["old"],
491 "boundary entry must be kept (strict >)"
492 );
493 }
494
495 /// Keep the most recently accessed member of each content group, evict the
496 /// rest, and never touch a group with only one member.
497 #[test]
498 fn duplicate_keeps_newest_and_ignores_singletons() {
499 let mut a = feat("dup_new", 100, 0, 1.0);
500 let mut b = feat("dup_old", 100, 0, 9.0);
501 let mut c = feat("dup_older", 100, 0, 20.0);
502 let mut lone = feat("lone", 100, 0, 99.0);
503 a.content_hash = Some("h1".into());
504 b.content_hash = Some("h1".into());
505 c.content_hash = Some("h1".into());
506 lone.content_hash = Some("h2".into());
507 b.reclaimable_bytes = Some(100);
508 c.reclaimable_bytes = Some(100);
509
510 let picked = DuplicatePolicy.select(&[a, b, c, lone]);
511 assert_eq!(picked, vec!["dup_older", "dup_old"]);
512 }
513
514 /// Uncommitted entries and entries without a content hash are invisible to
515 /// duplicate eviction — they may be mid-write.
516 #[test]
517 fn duplicate_ignores_uncommitted_and_hashless() {
518 let mut committed = feat("committed", 100, 0, 1.0);
519 let mut uncommitted = feat("uncommitted", 100, 0, 9.0);
520 committed.content_hash = Some("h".into());
521 uncommitted.content_hash = Some("h".into());
522 uncommitted.committed = false;
523
524 assert!(DuplicatePolicy.select(&[committed, uncommitted]).is_empty());
525 }
526
527 /// Ties at the group's newest access are all kept: the previous SQL used a
528 /// strict `<` against the group max, so two equally-fresh duplicates both
529 /// survive rather than one being arbitrarily dropped.
530 #[test]
531 fn duplicate_keeps_all_entries_tied_at_newest() {
532 let mut a = feat("tie_a", 100, 0, 5.0);
533 let mut b = feat("tie_b", 100, 0, 5.0);
534 a.content_hash = Some("h".into());
535 b.content_hash = Some("h".into());
536 a.reclaimable_bytes = Some(100);
537 b.reclaimable_bytes = Some(100);
538 assert!(DuplicatePolicy.select(&[a, b]).is_empty());
539 }
540
541 /// kunobi-ninja/kache#709: a duplicate whose blob is still referenced
542 /// must be skipped because removing it frees no physical bytes.
543 #[test]
544 fn duplicate_skips_victim_whose_blob_is_still_referenced() {
545 let mut newest = feat("newest", 100, 0, 1.0);
546 let mut oldest = feat("oldest", 100, 0, 9.0);
547 newest.content_hash = Some("h".into());
548 oldest.content_hash = Some("h".into());
549 oldest.reclaimable_bytes = Some(0);
550
551 assert!(
552 DuplicatePolicy.select(&[newest, oldest]).is_empty(),
553 "a zero-marginal-byte duplicate must never be selected"
554 );
555 }
556
557 #[test]
558 fn duplicate_fails_closed_when_marginal_bytes_are_unknown() {
559 let mut newest = feat("newest", 100, 0, 1.0);
560 let mut legacy = feat("legacy", 100, 0, 9.0);
561 newest.content_hash = Some("h".into());
562 legacy.content_hash = Some("h".into());
563 assert_eq!(legacy.reclaimable_bytes, None);
564
565 assert!(DuplicatePolicy.select(&[newest, legacy]).is_empty());
566 }
567
568 #[test]
569 fn duplicate_still_evicts_victim_with_positive_marginal_bytes() {
570 let mut newest = feat("newest", 100, 0, 1.0);
571 let mut oldest = feat("oldest", 100, 0, 9.0);
572 newest.content_hash = Some("h".into());
573 oldest.content_hash = Some("h".into());
574 oldest.reclaimable_bytes = Some(100);
575
576 assert_eq!(DuplicatePolicy.select(&[newest, oldest]), vec!["oldest"]);
577 }
578
579 mod properties {
580 use super::*;
581 use proptest::prelude::*;
582
583 /// Candidates with unique keys. Idle times and sizes come from small
584 /// sets so scores tie often; reclaim covers unknown, zero and full.
585 fn candidates() -> impl Strategy<Value = Vec<EntryFeatures>> {
586 let entry = (
587 proptest::sample::select(&[0_i64, 512, 4096, 3 << 20][..]),
588 0_i64..4,
589 proptest::sample::select(&[-1.0, 0.0, 0.5, 2.0, 30.0][..]),
590 proptest::option::of(proptest::sample::select(&["h0", "h1"][..])),
591 any::<bool>(),
592 0_u8..3,
593 );
594 proptest::collection::vec(entry, 0..12).prop_map(|entries| {
595 entries
596 .into_iter()
597 .enumerate()
598 .map(
599 |(i, (size, hits, idle, hash, committed, reclaim))| EntryFeatures {
600 key: format!("k{i:02}"),
601 size,
602 hit_count: hits,
603 idle_hours: idle,
604 content_hash: hash.map(str::to_string),
605 committed,
606 compile_time_ms: 0,
607 reclaimable_bytes: [None, Some(0), Some(size)][reclaim as usize],
608 recently_accessed: false,
609 recently_imported: false,
610 },
611 )
612 .collect()
613 })
614 }
615
616 proptest! {
617 #![proptest_config(ProptestConfig {
618 cases: 64,
619 max_shrink_iters: 256,
620 ..ProptestConfig::default()
621 })]
622
623 /// Selection is a pure function of the candidate list. Size
624 /// pressure ranks every key once by ascending score; reordering
625 /// the list can only reorder keys whose scores tie, since ties
626 /// keep list order. Duplicate selection does not depend on list
627 /// order at all, and keeps each group's most recent member.
628 #[test]
629 fn eviction_selection_is_deterministic(
630 (list, shuffled) in candidates().prop_flat_map(|c| (Just(c.clone()), Just(c).prop_shuffle())),
631 ) {
632 let score = |key: &String| {
633 size_pressure_score(list.iter().find(|e| &e.key == key).unwrap())
634 };
635 let ranked = SizePressurePolicy.select(&list);
636 prop_assert_eq!(&ranked, &SizePressurePolicy.select(&list));
637 let mut keys = ranked.clone();
638 keys.sort();
639 prop_assert_eq!(keys, list.iter().map(|e| e.key.clone()).collect::<Vec<_>>());
640 let scores: Vec<f64> = ranked.iter().map(score).collect();
641 prop_assert!(scores.is_sorted(), "{scores:?}");
642 let reordered: Vec<f64> = SizePressurePolicy.select(&shuffled).iter().map(score).collect();
643 prop_assert_eq!(scores, reordered);
644
645 let duplicates = DuplicatePolicy.select(&list);
646 prop_assert_eq!(&duplicates, &DuplicatePolicy.select(&shuffled));
647 for key in &duplicates {
648 let e = list.iter().find(|e| &e.key == key).unwrap();
649 let newest = list
650 .iter()
651 .filter(|o| o.committed && o.content_hash.is_some() && o.content_hash == e.content_hash)
652 .map(|o| o.idle_hours)
653 .fold(f64::INFINITY, f64::min);
654 prop_assert!(e.committed && e.idle_hours > newest, "{e:?}");
655 prop_assert!(e.reclaimable_bytes.is_some_and(|b| b > 0), "{e:?}");
656 }
657 }
658 }
659 }
660}