Skip to main content

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;