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}