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