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}