Skip to main content

kevy_store/
tier_demote.rs

1//! Tiering demotion/promotion primitives + the eviction fork: the
2//! budgeted spill loop, victim sampling (reusing the
3//! eviction sampler's scoring), in-place swap primitives, and the
4//! compaction trigger. Compiled with the tier backend only (std,
5//! off-wasm); the disabled builds' no-op twins live in `tier.rs`.
6
7#![cfg(all(feature = "std", not(target_arch = "wasm32")))]
8
9use kevy_vlog::{CompactOwner, VlogRef};
10
11use crate::value::{ColdRef, Value};
12use crate::{Entry, SmallBytes, Store, key_heap_bytes_for, tier_codec};
13
14/// RFC §7: 32 records per demotion call, continuation on the shard tick.
15const SPILL_BATCH: usize = 32;
16/// Backoff ceiling: a dry sampler doubles its skip up to this
17/// many ticks (~6.4 s at the default 10 Hz tick) — the idle cost of
18/// "over target with nothing left to spill" converges to one bounded
19/// sample walk every few seconds instead of one per tick.
20pub(crate) const BACKOFF_CEILING_TICKS: u32 = 64;
21/// RFC §7: values at or below inline size never spill.
22const MIN_SPILL_BYTES: u64 = 64;
23/// Demote watermark headroom (same 19/20 shape as eviction).
24const WATERMARK_NUM: u64 = 19;
25const WATERMARK_DEN: u64 = 20;
26/// Compact a sealed file once its live ratio falls below this percent.
27const COMPACT_LIVE_PCT: u32 = 50;
28/// Records rewritten per reactor-tick compaction step. Bounds each tick's
29/// compaction cost to keep it off the query-tail path (a whole-file pass
30/// stalled the reactor tens of ms); the backlog drains over ticks.
31const COMPACT_STEP_RECORDS: usize = 256;
32
33/// Is this value in the v1 spillable CLASS (ArcBulk / heap Hash /
34/// inline hash)? Threshold and Cold-skip are the sampler's job.
35#[inline]
36fn spillable_class(v: &Value) -> bool {
37    matches!(v, Value::ArcBulk(_) | Value::Hash(_) | Value::SmallHashInline(_))
38}
39
40impl Store {
41    /// The demotion twin of [`Store::try_evict_after_write`], called
42    /// beside it from the write-commit sites. No-op unless tiering is
43    /// on AND `used_memory` is past the unified target (the plain
44    /// watermark minus the index/view floor and the stub floor); then
45    /// spills at most one batch (a single write never funds an
46    /// unbounded spill storm — continuation rides
47    /// [`Store::demote_step`] on the tick). Returns keys demoted.
48    #[inline]
49    pub fn try_demote_after_write(&mut self) -> usize {
50        let n = self.demote_if_over(crate::evict::DEMOTE_VISIT_WINDOW);
51        if n > 0
52            && let Some(t) = self.tier.as_mut()
53        {
54            // Progress on the write path means candidates exist again —
55            // wake the tick sampler out of its backoff.
56            t.tick_wait = 0;
57            t.tick_skip = 0;
58        }
59        n
60    }
61
62    /// Tick continuation of [`Store::try_demote_after_write`]: one more
63    /// budgeted batch per shard tick while over the watermark — with
64    /// backoff. A tick whose batch moves nothing while over
65    /// target (every spillable value already cold, or the floor alone
66    /// exceeds the budget so `effective_target == 0`) doubles the
67    /// tick's skip up to [`BACKOFF_CEILING_TICKS`]; any demotion — here
68    /// or on the write path — resets it. During a backoff window this
69    /// is one decrement: the sampler does not run. "Idempotent is not
70    /// convergent": before this, an over-target store with nothing left
71    /// to spill re-walked the sample window every tick forever.
72    #[inline]
73    pub fn demote_step(&mut self) -> usize {
74        let Some(t) = self.tier.as_mut() else { return 0 };
75        if t.tick_wait > 0 {
76            t.tick_wait -= 1;
77            return 0;
78        }
79        let over = self.used_memory > effective_target(self.tier.as_ref().expect("probed above"));
80        let n = self.demote_if_over(crate::evict::DEMOTE_VISIT_WINDOW);
81        let t = self.tier.as_mut().expect("still enabled");
82        if n == 0 && over {
83            t.tick_skip = (t.tick_skip * 2).clamp(1, BACKOFF_CEILING_TICKS);
84            t.tick_wait = t.tick_skip;
85        } else if n > 0 {
86            t.tick_skip = 0;
87        }
88        n
89    }
90
91    /// Bulk-load drain: demote batch after batch until the
92    /// store is back under the watermark or candidates run dry. Replay /
93    /// snapshot-load / reshard call this every K applied frames — those
94    /// paths are single-threaded, so draining more than one write-path
95    /// batch per check is safe (there is no reactor to stall). The
96    /// sampler runs UNBOUNDED here: a fixed visit window keeps a stale
97    /// start position between calls (the access clock does not advance
98    /// mid-drain), so a window that has gone all-cold would end the
99    /// drain while still over the watermark — ending under the
100    /// watermark is this path's hard contract. Returns total keys
101    /// demoted.
102    pub fn demote_to_watermark(&mut self) -> usize {
103        let mut total = 0usize;
104        loop {
105            let n = self.demote_if_over(usize::MAX);
106            if n == 0 {
107                break;
108            }
109            total += n;
110        }
111        // Drain compaction fully: this path is single-threaded (no reactor
112        // to stall), and leaving a backlog would inflate vlog space
113        // amplification (B5) during a bulk ingest.
114        while self.tier_compact_step(usize::MAX) > 0 {}
115        total
116    }
117
118    /// Shared over-target gate for the two entry points above.
119    #[inline]
120    fn demote_if_over(&mut self, visit_bound: usize) -> usize {
121        match &self.tier {
122            None => 0,
123            Some(t) if self.used_memory <= effective_target(t) => 0,
124            Some(_) => self.demote_batch(visit_bound),
125        }
126    }
127
128    /// One budgeted demotion batch: sample → demote, ≤ [`SPILL_BATCH`]
129    /// records, stop at the unified target or when sampling runs dry.
130    /// The target is re-read per iteration — every demotion grows
131    /// `stub_bytes`, which lowers it. Ends with the compaction trigger.
132    fn demote_batch(&mut self, visit_bound: usize) -> usize {
133        let policy = self.tier.as_ref().expect("gated by caller").policy;
134        let mut demoted = 0usize;
135        let mut misses = 0u32;
136        loop {
137            let target = effective_target(self.tier.as_ref().expect("gated by caller"));
138            if self.used_memory <= target || demoted >= SPILL_BATCH {
139                break;
140            }
141            let cap = self.tier.as_ref().expect("gated by caller").max_spill;
142            let victim = crate::evict::sample_pick_with(self, policy, |e| {
143                spillable_class(&e.value)
144                    && e.weight() >= MIN_SPILL_BYTES
145                    && (cap == 0 || e.weight() <= cap)
146            }, visit_bound);
147            match victim {
148                None => break,
149                Some(k) if self.demote_in_place(&k) => {
150                    demoted += 1;
151                    misses = 0;
152                }
153                Some(_) => {
154                    misses += 1;
155                    if misses >= 3 {
156                        break;
157                    }
158                }
159            }
160        }
161        // Compaction is NOT run inline here: a whole-file vlog rewrite on
162        // the reactor thread stalls every concurrent query for tens of ms
163        // (measured 35-82ms p99 tails at 10M rows). It rides the tick in
164        // bounded steps instead — see `tier_compact_tick`.
165        demoted
166    }
167
168    /// Swap `key`'s live value for a [`ColdRef`] stub, appending the
169    /// codec bytes to the vlog. Re-stamps `Entry::weight` to the stub's
170    /// actual footprint, applies the `used_memory` delta, preserves
171    /// `lru_clock`/TTL, touches nothing else — zero events, zero WATCH
172    /// bumps, no hfttl clear (field TTLs stay RAM-resident while cold).
173    /// `false` when the key is absent/expired/non-spillable or the
174    /// append failed (value stays hot).
175    pub(crate) fn demote_in_place(&mut self, key: &[u8]) -> bool {
176        let cap = match self.tier.as_ref() {
177            Some(t) => t.max_spill,
178            None => return false,
179        };
180        let Some((payload, tag)) = ({
181            match self.map.get(key) {
182                Some(e)
183                    if !e.is_expired(self.cached_clock, self.cached_ns)
184                        && (cap == 0 || e.weight() <= cap) =>
185                {
186                    tier_codec::encode(&e.value)
187                }
188                _ => None,
189            }
190        }) else {
191            return false;
192        };
193        let Some(t) = self.tier.as_mut() else { return false };
194        let Ok(vref) = t.vlog.append(key, &payload) else {
195            // Disk refused the spill — keep the value hot; the caller's
196            // loop counts this as a miss. Never a silent value drop.
197            return false;
198        };
199        let key_heap = key_heap_bytes_for(key);
200        let e = self.map.get_mut(key).expect("probed above");
201        let old_w = e.weight();
202        let value_w = old_w.saturating_sub(key_heap);
203        let stub = ColdRef {
204            offset: vref.offset,
205            file_id: vref.file_id,
206            len: vref.len,
207            weight: value_w.min(u64::from(u32::MAX)) as u32,
208            type_tag: tag,
209            touched: 0,
210        };
211        let old_value = core::mem::replace(&mut e.value, Value::Cold(stub));
212        e.set_weight(key_heap);
213        crate::apply_delta(&mut self.used_memory, -(value_w as i64));
214        let t = self.tier.as_mut().expect("still enabled");
215        t.demotions_total += 1;
216        t.cold_keys += 1;
217        t.cold_bytes += u64::from(stub.weight);
218        t.stub_bytes += crate::value::ENTRY_OVERHEAD + key_heap;
219        self.maybe_offload_drop(old_value);
220        true
221    }
222
223    /// Materialize `key`'s cold value back into the map: pread +
224    /// decode, swap the stub out, re-stamp weight from the decoded
225    /// value, credit the record dead. Same nothing-else contract as
226    /// [`Store::demote_in_place`]. `false` when the value is not Cold.
227    pub(crate) fn promote_in_place(&mut self, key: &[u8]) -> bool {
228        let cref = match self.map.get(key).map(|e| &e.value) {
229            Some(Value::Cold(c)) => *c,
230            _ => return false,
231        };
232        let value = self.tier_read_record(key, cref);
233        let key_heap = key_heap_bytes_for(key);
234        let new_w = key_heap + value.weight();
235        let e = self.map.get_mut(key).expect("probed above");
236        e.value = value;
237        let delta = new_w as i64 - e.weight() as i64;
238        e.set_weight(new_w);
239        crate::apply_delta(&mut self.used_memory, delta);
240        if cref.is_seg() {
241            // The segment record is stranded now; the vlog's books
242            // were never involved.
243            self.segrow_note_dead(cref);
244            return true;
245        }
246        let t = self.tier.as_mut().expect("cold value ⇒ tiering on");
247        t.vlog.note_dead(cref.vref());
248        t.renames.remove(&(cref.file_id, cref.offset));
249        t.promotions_total += 1;
250        t.cold_keys = t.cold_keys.saturating_sub(1);
251        t.cold_bytes = t.cold_bytes.saturating_sub(u64::from(cref.weight));
252        t.stub_bytes = t
253            .stub_bytes
254            .saturating_sub(crate::value::ENTRY_OVERHEAD + key_heap);
255        true
256    }
257
258    /// The deterministic demotion seam for the B9 transparency suite
259    /// (`KEVY_TEST_FORCE_DEMOTE` genre): demote `key` NOW, ignoring the
260    /// watermark and the min-spill threshold (so inline hashes force
261    /// too) — the suite drives cold state per-key, never by eviction
262    /// timing. Returns whether a demotion happened.
263    #[doc(hidden)]
264    pub fn debug_force_demote(&mut self, key: &[u8]) -> bool {
265        if self.tier.is_none() {
266            return false;
267        }
268        self.demote_in_place(key)
269    }
270
271    /// Test-only direct compaction trigger (the rename-survival test
272    /// needs a deterministic pass, not a batch side effect).
273    #[cfg(test)]
274    pub(crate) fn tier_force_compact_for_tests(&mut self) {
275        while self.tier_compact_step(usize::MAX) > 0 {}
276    }
277
278    /// One bounded compaction step: at most `budget` records of vlog
279    /// rewrite, so it never blocks the reactor for a whole-file pass.
280    /// Returns records processed (0 = nothing below the live threshold).
281    fn tier_compact_step(&mut self, budget: usize) -> usize {
282        let Some(t) = self.tier.as_mut() else { return 0 };
283        let mut owner = StoreOwner { map: &mut self.map, renames: &mut t.renames };
284        // An IO error mid-compaction leaves untouched files untouched;
285        // surfaced loudly (per-boot spill file — a failure is a bug).
286        t.vlog
287            .compact_step(COMPACT_LIVE_PCT, &mut owner, budget)
288            .expect("tier: vlog compaction failed — per-boot spill file, this is a process bug")
289    }
290
291    /// Reactor-tick compaction: one bounded step while a sealed file is
292    /// below the live threshold. Cheap no-op (an O(files) scan) when
293    /// there is nothing to compact. Returns records processed.
294    pub fn tier_compact_tick(&mut self) -> usize {
295        match self.tier.as_ref() {
296            Some(t) if t.vlog.compaction_pending(COMPACT_LIVE_PCT) => {
297                self.tier_compact_step(COMPACT_STEP_RECORDS)
298            }
299            _ => 0,
300        }
301    }
302}
303
304#[inline]
305pub(crate) fn watermark(budget: u64) -> u64 {
306    budget.saturating_mul(WATERMARK_NUM) / WATERMARK_DEN
307}
308
309/// The unified demote target: `budget·19/20 −
310/// reserved_bytes − stub_bytes`, saturating. Demotion can only reclaim
311/// hot values — the index/view floor and the stubs' own RAM cost are
312/// fixed layers, so pressure on them translates into a lower target
313/// for the hot set. **Saturated to 0** = the floor alone exceeds the
314/// budget; the tier can demote nothing further once every spillable
315/// value is cold (`TierStats::effective_target` makes the state
316/// visible in INFO).
317#[inline]
318pub(crate) fn effective_target(t: &crate::tier::TierState) -> u64 {
319    watermark(t.budget)
320        .saturating_sub(t.reserved_bytes)
321        .saturating_sub(t.stub_bytes)
322}
323
324/// [`CompactOwner`] over the store map + the rename forward-pointers.
325struct StoreOwner<'a> {
326    map: &'a mut kevy_map::KevyMap<SmallBytes, Entry>,
327    renames: &'a mut std::collections::HashMap<(u32, u64), SmallBytes>,
328}
329
330impl StoreOwner<'_> {
331    /// The key currently holding the stub for `old` — the record's own
332    /// key, or the rename forward-pointer's target. `None` = dead.
333    fn resolve(&self, key: &[u8], old: VlogRef) -> Option<SmallBytes> {
334        if stub_matches(self.map.get(key), old) {
335            return Some(SmallBytes::from_slice(key));
336        }
337        let fwd = self.renames.get(&(old.file_id, old.offset))?;
338        stub_matches(self.map.get(fwd.as_slice()), old).then(|| fwd.clone())
339    }
340}
341
342fn stub_matches(e: Option<&Entry>, old: VlogRef) -> bool {
343    matches!(
344        e.map(|e| &e.value),
345        Some(Value::Cold(c)) if c.file_id == old.file_id && c.offset == old.offset
346    )
347}
348
349impl CompactOwner for StoreOwner<'_> {
350    fn is_live(&mut self, key: &[u8], old: VlogRef) -> bool {
351        self.resolve(key, old).is_some()
352    }
353
354    fn moved(&mut self, key: &[u8], old: VlogRef, new: VlogRef) {
355        let holder = self.resolve(key, old).expect("moved() only after is_live");
356        let e = self.map.get_mut(holder.as_slice()).expect("resolved live");
357        let Value::Cold(c) = &mut e.value else { unreachable!("resolved a stub") };
358        c.file_id = new.file_id;
359        c.offset = new.offset;
360        c.len = new.len;
361        self.renames.remove(&(old.file_id, old.offset));
362        // The re-appended record still carries its original embedded
363        // key; if the stub lives elsewhere, keep the forward pointer.
364        if holder.as_slice() != key {
365            self.renames.insert((new.file_id, new.offset), holder);
366        }
367    }
368}