decdn_node/runtime/eviction.rs
1//! Cache-eviction driver loop (#1173, ADR 040 §Whole-blob reclaim; range
2//! eviction is upstream-gated).
3//!
4//! A single async task, owned by the node wiring layer (per
5//! `appendix-poc-production-seams.md`), that enforces the cache ceiling the
6//! write path deliberately does not. It periodically measures on-disk footprint
7//! and, once over the high-water mark, releases the blobs the injected
8//! `EvictionPolicy` ranks for eviction (via
9//! [`CacheEngine::release_for_eviction`], the soft-evict path that is *not* the
10//! durable operator-evict) down to the target mark, bounded by a per-sweep
11//! budget.
12//!
13//! ## The ceiling is disk-aware
14//!
15//! The ceiling is not the static `cache.cache_size_mb`. That is an upper bound;
16//! each tick the driver probes free disk on the `cache_dir` volume and clamps
17//! the ceiling down to keep `cache.disk_headroom_mb` free, defended against any
18//! process (#1930). See `effective_cap`. So a large `cache_size_mb` lets a node
19//! size its cache to whatever disk the machine offers, and the high-water and
20//! target marks track that dynamic ceiling.
21//!
22//! ## The driver's actuator is indirect — hence pending-reclaim accounting
23//!
24//! `release_for_eviction` does **not** free disk. It drops the blob's
25//! GC-protecting tag; the bytes are reclaimed later by the independent
26//! iroh-blobs GC sweep (`cache.gc_interval_sec`, default 300s). The driver ticks
27//! far more often than that (`eviction_tick_secs`, default 1s), so a naive loop
28//! that re-measured raw disk each tick would see its own releases have no effect
29//! and keep releasing — roughly `gc_interval_sec / eviction_tick_secs` sweeps'
30//! worth (~300 × 16 ≈ 4800 blobs at defaults) for a pressure event needing a
31//! handful. That would drain the cache on a single high-water crossing.
32//!
33//! So the driver tracks `pending_reclaim`: bytes it has released but not yet
34//! seen reclaimed. Decisions are made against the *effective* footprint
35//! (`raw − pending_reclaim`) — what disk will look like once GC lands — while
36//! the `decdn_cache_bytes` gauge still reports honest raw usage. When a
37//! measurement comes in below the previous one, GC has run, and the observed
38//! drop is credited against `pending_reclaim`. The accounting is self-correcting:
39//! it needs no coupling to the GC schedule and recovers if a release is never
40//! reclaimed.
41//!
42//! With GC disabled (`gc_interval_sec == 0`) nothing is ever reclaimed, so the
43//! driver releases roughly one target's worth once and then idles rather than
44//! stripping every tag in the cache. The runtime warns at startup in that case —
45//! the driver cannot reclaim space and the ceiling is unenforceable.
46//!
47//! ## Hysteresis
48//!
49//! A latch: the driver starts evicting when the effective footprint crosses
50//! `eviction_high_water_pct` and does not stop until it reaches
51//! `eviction_target_pct`, then idles until the next high-water crossing. The
52//! ≥5-point gap between the two (enforced at config resolution) is what prevents
53//! thrash on writes hovering near the trigger.
54
55use std::collections::HashMap;
56use std::sync::Arc;
57use std::time::Duration;
58
59use decdn_cache::{CacheEngine, CacheMetrics};
60use tokio::sync::oneshot;
61
62/// One mebibyte, the unit `cache.cache_size_mb` is expressed in.
63const BYTES_PER_MB: u64 = 1_048_576;
64
65/// Saturating `u64 -> i64` for gauge writes (`iroh_metrics::Gauge` is `i64`).
66fn as_gauge(v: u64) -> i64 {
67 i64::try_from(v).unwrap_or(i64::MAX)
68}
69
70/// Resolved driver parameters. Every field is range-checked at config
71/// resolution (`resolve_cache_into`): `high_water_pct ∈ [60, 95]`,
72/// `target_pct ∈ [40, 90]` **and** `target_pct <= high_water_pct - 5` (the
73/// structural hysteresis gap), `per_sweep_budget ∈ [1, 256]`,
74/// `tick ∈ [1s, 60s]`. The type itself carries no invariant — it is a wiring
75/// DTO with a single production construction site.
76#[derive(Debug, Clone, Copy)]
77pub struct EvictionParams {
78 /// Configured cache size in MiB; the upper bound on the ceiling (the disk
79 /// clamp can only lower it, never raise it).
80 pub cache_size_mb: u64,
81 /// Free disk in MiB the driver keeps unused on the `cache_dir` volume by
82 /// *any* process (#1930). Each tick the effective ceiling is capped so the
83 /// cache cannot grow into the last `disk_headroom_mb` of free space; below
84 /// it, the driver evicts to claw disk back. `0` opts out of the disk clamp
85 /// (only `cache_size_mb` binds).
86 pub disk_headroom_mb: u64,
87 /// Crossing this percentage of the effective ceiling starts a sweep.
88 pub high_water_pct: u64,
89 /// A sweep runs until usage falls to this percentage. The gap to
90 /// `high_water_pct` is the hysteresis that stops it flapping.
91 pub target_pct: u64,
92 /// Maximum blobs released in one sweep, so a sweep cannot monopolize the
93 /// store.
94 pub per_sweep_budget: u64,
95 /// How often the driver re-reads usage.
96 pub tick: Duration,
97}
98
99/// Per-tick ceiling inputs, precomputed once in [`run`] from [`EvictionParams`].
100/// The effective ceiling itself is recomputed every tick from these plus the
101/// live footprint and free disk (#1930), so a shared volume's ceiling tracks
102/// real free space rather than a static config number.
103#[derive(Debug, Clone, Copy)]
104struct Ceiling {
105 /// `cache_size_mb × MiB` — the absolute upper bound on the ceiling.
106 config_limit_bytes: u64,
107 /// `disk_headroom_mb × MiB` — free space kept on the volume.
108 headroom_bytes: u64,
109 /// Percent of the effective ceiling above which a sweep starts.
110 high_water_pct: u64,
111 /// Percent of the effective ceiling a sweep evicts down to.
112 target_pct: u64,
113}
114
115/// Cross-tick driver state. Kept out of `run`'s body so `tick` can be unit-tested.
116#[derive(Debug, Default)]
117struct DriverState {
118 /// Hysteresis latch: `true` between a high-water crossing and reaching target.
119 evicting: bool,
120 /// Bytes released but not yet observed reclaimed by the iroh-blobs GC.
121 pending_reclaim: u64,
122 /// Previous raw measurement, used to detect a GC reclaim (a decrease).
123 last_raw: Option<u64>,
124 /// Whether the disk-headroom clamp — not `cache_size_mb` — is the binding
125 /// ceiling. Tracked so the driver logs the transition once, not every tick.
126 disk_clamped: bool,
127 /// Whether the free-disk probe is currently failing. Tracked so a persistent
128 /// statvfs error logs once on the way in and once on recovery, not per tick.
129 disk_probe_failing: bool,
130}
131
132/// `base * pct / 100` in u128 space, saturated back to u64.
133fn pct_of(base: u64, pct: u64) -> u64 {
134 u64::try_from(u128::from(base).saturating_mul(u128::from(pct)) / 100).unwrap_or(u64::MAX)
135}
136
137/// The effective cache ceiling for one tick: the smaller of the configured
138/// `cache_size_mb` budget and what free disk allows, recomputed every tick so a
139/// shared volume's ceiling tracks real free space (#1930).
140///
141/// Free-space tracking keeps `disk_headroom_mb` of the volume unused by *any*
142/// process. The cache may grow into whatever is free beyond that headroom, on
143/// top of the bytes it already holds:
144/// `disk_ceiling = footprint + max(0, free - headroom)`. At or below headroom
145/// the ceiling collapses to the current footprint, so the driver evicts to claw
146/// disk back toward the margin. A `free` of `u64::MAX` — the statvfs-failed
147/// sentinel the driver substitutes on a probe error — saturates the disk term
148/// so the configured budget binds, and a transient probe failure never shrinks
149/// the cache to its footprint.
150const fn effective_cap(
151 config_limit_bytes: u64,
152 footprint: u64,
153 free_bytes: u64,
154 headroom_bytes: u64,
155) -> u64 {
156 let disk_ceiling = footprint.saturating_add(free_bytes.saturating_sub(headroom_bytes));
157 if config_limit_bytes < disk_ceiling {
158 config_limit_bytes
159 } else {
160 disk_ceiling
161 }
162}
163
164/// Fold a fresh raw measurement into the driver's pending-reclaim bookkeeping
165/// and return the **effective** footprint to decide against.
166///
167/// A decrease since the previous tick means the iroh-blobs GC sweep landed, so
168/// the observed drop is credited against what we are still waiting on. An
169/// increase is unrelated new writes and leaves `pending_reclaim` alone. The
170/// effective footprint is `raw - pending_reclaim`: what disk will look like once
171/// the releases already issued are reclaimed.
172///
173/// This is the whole fix for the driver's original non-convergence: without it,
174/// releases (which only drop GC protection) never moved the measurement, so the
175/// latch stayed engaged and the driver re-released every tick for the whole GC
176/// interval — ~`gc_interval_sec / eviction_tick_secs` sweeps' worth.
177const fn reconcile(state: &mut DriverState, raw: u64) -> u64 {
178 if let Some(prev) = state.last_raw
179 && raw < prev
180 {
181 state.pending_reclaim = state.pending_reclaim.saturating_sub(prev - raw);
182 }
183 state.last_raw = Some(raw);
184 raw.saturating_sub(state.pending_reclaim)
185}
186
187/// One budget-bounded eviction pass. Releases up to `budget` candidates in the
188/// order `policy` ranks them, stopping early once the projected effective
189/// footprint reaches `target_bytes`. Returns the number of bytes actually
190/// released (to be added to `pending_reclaim`).
191///
192/// Emits `evictions_starved` when there is nothing left to evict while still
193/// over target — either because the candidate set is empty (everything pinned,
194/// operator-evicted, or probe-held, or the open-time recency seed failed so
195/// blobs from before the restart are not candidates; see
196/// `decdn_cache_recency_seed_failures_total`) or because a whole pass released
197/// nothing.
198#[allow(clippy::too_many_arguments)]
199async fn sweep(
200 cache: &CacheEngine,
201 metrics: &CacheMetrics,
202 effective: u64,
203 target_bytes: u64,
204 budget: u64,
205 cache_bytes: u64,
206 sizes: &HashMap<decdn_cache::Hash, u64>,
207 policy: &Arc<dyn decdn_cache::EvictionPolicy>,
208 warming: &Arc<crate::warming_allowance::WarmingAllowance>,
209) -> u64 {
210 let candidates = cache.eviction_candidates();
211 if candidates.is_empty() {
212 // Over target but no eligible victim — the operational-alarm signal.
213 metrics.evictions_starved.inc();
214 return 0;
215 }
216
217 // The policy owns the whole sweep decision (ADR 040): it ranks candidates,
218 // applies the target/budget stop conditions, and returns what to evict and
219 // what to promote. The driver is a dumb executor.
220 let segments = cache.segments_snapshot();
221 let plan = policy.plan(&decdn_cache::EvictionContext {
222 candidates: &candidates,
223 sizes,
224 segments: &segments,
225 total_bytes: effective,
226 target_bytes,
227 budget,
228 cache_bytes,
229 });
230
231 // Promotion is a pure in-memory segment move — no store I/O.
232 for (hash, seg) in plan.promote {
233 cache.set_segment(hash, seg);
234 }
235
236 let mut freed: u64 = 0;
237 let mut removed: u64 = 0;
238 for hash in plan.evict {
239 match cache.release_for_eviction(hash).await {
240 // `Ok(0)` means nothing was released — the hash was pinned (the
241 // documented defence-in-depth refusal) or carried no protecting tag.
242 // Crediting it would charge the budget and, worse, add its size to
243 // `freed`, letting the sweep break early believing it had freed
244 // bytes it never touched.
245 Ok(0) => {}
246 Ok(_) => {
247 freed = freed.saturating_add(sizes.get(&hash).copied().unwrap_or(0));
248 removed = removed.saturating_add(1);
249 metrics.evictions.inc();
250 // ADR 041: drop the warming tag for the evicted hash, so a later
251 // reuse of this slot can never credit a stale source's allowance.
252 warming.forget(hash);
253 }
254 Err(err) => {
255 tracing::warn!(%hash, error = %err.display_chain(), "eviction driver: release_for_eviction failed; skipping hash");
256 }
257 }
258 }
259
260 if removed == 0 {
261 // Had candidates but released none (all pinned/untagged/erroring). Same
262 // operator signal as an empty candidate set — otherwise the driver looks
263 // busy while making no progress.
264 metrics.evictions_starved.inc();
265 }
266 metrics.evictions_bytes.inc_by(freed);
267 freed
268}
269
270/// Recompute the effective ceiling for one tick, publish the ceiling gauge, and
271/// log the disk-clamp transition once (#1930).
272///
273/// `footprint` is the live raw on-disk footprint, paired with `free_bytes`
274/// (both real on-disk state) inside [`effective_cap`]. Returns
275/// `(cap, high_water_bytes, target_bytes)` for the latch and sweep. The clamp
276/// becoming — or ceasing to be — the binding ceiling is the operator-visible
277/// event, so it is logged on the transition, not every tick.
278fn resolve_ceiling(
279 ceiling: Ceiling,
280 footprint: u64,
281 free_bytes: u64,
282 state: &mut DriverState,
283 metrics: &CacheMetrics,
284) -> (u64, u64, u64) {
285 let cap = effective_cap(
286 ceiling.config_limit_bytes,
287 footprint,
288 free_bytes,
289 ceiling.headroom_bytes,
290 );
291 metrics.size_limit_bytes.set(as_gauge(cap));
292
293 let clamped = cap < ceiling.config_limit_bytes;
294 if clamped && !state.disk_clamped {
295 tracing::warn!(
296 effective_cap_bytes = cap,
297 config_limit_bytes = ceiling.config_limit_bytes,
298 free_bytes,
299 headroom_bytes = ceiling.headroom_bytes,
300 "eviction driver: free disk is the binding cache ceiling; cache.cache_size_mb \
301 is clamped down to keep cache.disk_headroom_mb free on the volume"
302 );
303 } else if !clamped && state.disk_clamped {
304 tracing::info!(
305 config_limit_bytes = ceiling.config_limit_bytes,
306 "eviction driver: free disk recovered; cache.cache_size_mb is the binding ceiling again"
307 );
308 }
309 state.disk_clamped = clamped;
310
311 (
312 cap,
313 pct_of(cap, ceiling.high_water_pct),
314 pct_of(cap, ceiling.target_pct),
315 )
316}
317
318/// One driver tick: measure, recompute the effective ceiling from the live
319/// footprint and free disk (#1930), refresh cache-health gauges, reconcile
320/// pending-reclaim against observed GC progress, apply the hysteresis latch, and
321/// (when latched over target) run one budget-bounded [`sweep`].
322///
323/// `free_bytes` is the volume's available bytes probed for this tick, or
324/// `u64::MAX` when the probe failed — the sentinel that disables the disk clamp
325/// so `config_limit_bytes` binds (see [`effective_cap`]).
326#[allow(clippy::too_many_arguments)]
327async fn tick(
328 cache: &CacheEngine,
329 metrics: &CacheMetrics,
330 ceiling: Ceiling,
331 free_bytes: u64,
332 budget: u64,
333 state: &mut DriverState,
334 policy: &Arc<dyn decdn_cache::EvictionPolicy>,
335 warming: &Arc<crate::warming_allowance::WarmingAllowance>,
336) {
337 // Exactly ONE store walk per tick: this snapshot is both the footprint
338 // source and the sweep's per-hash size lookup, so `sweep` takes it by
339 // reference rather than re-walking. (An earlier shape called `total_bytes()`
340 // here and `size_snapshot()` again in the sweep — two walks under sustained
341 // pressure, and the two readings could disagree.)
342 //
343 // A snapshot failure skips the whole tick rather than evicting blind:
344 // without sizes there is no way to know when target is reached, and
345 // degrading to an empty map silently drained the full budget every tick.
346 let sizes = match cache.size_snapshot().await {
347 Ok(s) => s,
348 Err(err) => {
349 metrics.size_measure_failures.inc();
350 tracing::warn!(
351 error = %err.display_chain(),
352 "eviction driver: cache size measurement failed; skipping tick \
353 (decdn_cache_bytes is now stale — alert on decdn_cache_size_measure_failures_total)"
354 );
355 return;
356 }
357 };
358
359 let raw = sizes.values().fold(0u64, |acc, sz| acc.saturating_add(*sz));
360 let effective = reconcile(state, raw);
361
362 // Gauges report honest raw usage; `pending_reclaim` is driver bookkeeping.
363 metrics.bytes.set(as_gauge(raw));
364 metrics.pinned_count.set(as_gauge(
365 u64::try_from(cache.pinned_snapshot().len()).unwrap_or(u64::MAX),
366 ));
367
368 // Effective ceiling for this tick: the configured budget, clamped down to
369 // real free disk (#1930). Publishes the ceiling gauge and logs the clamp
370 // transition.
371 let (cap, high_water_bytes, target_bytes) =
372 resolve_ceiling(ceiling, raw, free_bytes, state, metrics);
373
374 // Hysteresis latch: only start evicting on a high-water crossing; once
375 // latched, keep evicting until at/below target, then release.
376 if state.evicting {
377 if effective <= target_bytes {
378 state.evicting = false;
379 return;
380 }
381 } else if effective <= high_water_bytes {
382 return;
383 } else {
384 state.evicting = true;
385 }
386
387 let freed = sweep(
388 cache,
389 metrics,
390 effective,
391 target_bytes,
392 budget,
393 cap,
394 &sizes,
395 policy,
396 warming,
397 )
398 .await;
399 state.pending_reclaim = state.pending_reclaim.saturating_add(freed);
400}
401
402/// Probe free disk for one tick, or return the `u64::MAX` clamp-disabled
403/// sentinel on a `statvfs` failure (#1930). One cheap syscall, dwarfed by the
404/// store walk `tick` already does. On failure the driver falls back to the
405/// config-only ceiling rather than evicting blind. The failure and recovery
406/// transitions are each logged once, not every tick.
407fn probe_free_bytes(cache_dir: &std::path::Path, state: &mut DriverState) -> u64 {
408 match decdn_common::disk::statvfs_target(cache_dir) {
409 Ok(space) => {
410 if state.disk_probe_failing {
411 tracing::info!(
412 cache_dir = %cache_dir.display(),
413 "eviction driver: free-disk probe recovered; disk-headroom clamp re-enabled"
414 );
415 state.disk_probe_failing = false;
416 }
417 space.avail
418 }
419 Err(err) => {
420 if !state.disk_probe_failing {
421 tracing::warn!(
422 error = %err,
423 cache_dir = %cache_dir.display(),
424 "eviction driver: free-disk probe failed; disk-headroom clamp disabled \
425 until it recovers (cache.cache_size_mb still enforced)"
426 );
427 state.disk_probe_failing = true;
428 }
429 u64::MAX
430 }
431 }
432}
433
434/// Run the eviction driver until `shutdown` fires. Intended to be
435/// `tasks.spawn(run(...))`-ed into the runtime `JoinSet`; returns when the
436/// oneshot is dropped or sent.
437pub async fn run(
438 cache: CacheEngine,
439 metrics: Arc<CacheMetrics>,
440 params: EvictionParams,
441 cache_dir: std::path::PathBuf,
442 policy: Arc<dyn decdn_cache::EvictionPolicy>,
443 warming: Arc<crate::warming_allowance::WarmingAllowance>,
444 mut shutdown: oneshot::Receiver<()>,
445) {
446 let ceiling = Ceiling {
447 config_limit_bytes: params.cache_size_mb.saturating_mul(BYTES_PER_MB),
448 headroom_bytes: params.disk_headroom_mb.saturating_mul(BYTES_PER_MB),
449 high_water_pct: params.high_water_pct,
450 target_pct: params.target_pct,
451 };
452
453 let mut ticker = tokio::time::interval(params.tick);
454 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
455 ticker.tick().await; // burn the immediate first tick
456
457 let mut state = DriverState::default();
458
459 loop {
460 tokio::select! {
461 biased;
462 _ = &mut shutdown => {
463 tracing::debug!("eviction driver shutdown signal received");
464 return;
465 }
466 _ = ticker.tick() => {}
467 }
468
469 // `disk_headroom_mb = 0` opts out of the disk clamp (only `cache_size_mb`
470 // binds), so skip the syscall entirely and force the clamp-disabled
471 // sentinel — no probe, no probe-failure logging.
472 let free_bytes = if ceiling.headroom_bytes == 0 {
473 u64::MAX
474 } else {
475 probe_free_bytes(&cache_dir, &mut state)
476 };
477
478 tick(
479 &cache,
480 &metrics,
481 ceiling,
482 free_bytes,
483 params.per_sweep_budget,
484 &mut state,
485 &policy,
486 &warming,
487 )
488 .await;
489 }
490}
491
492#[cfg(test)]
493mod tests;