kevy_store/tier.rs
1//! Transparent tiering — the store core.
2//!
3//! Cold values live in a per-shard [`kevy_vlog::Vlog`]; each leaves a
4//! 24-byte [`ColdRef`] stub *in the map* (`Value::Cold`), so every raw
5//! probe (SET fast path, DEL, RENAME, FLUSHALL, SCAN's single-table
6//! sweep, the reaper) works unchanged by construction. The two-stage
7//! funnel keeps the ~196 downstream `Value` matches Cold-free:
8//!
9//! - **Stage 1 (zero IO)**: existence / NX / XX / EXPIRE-family answer
10//! from the `Entry`; `TYPE` from the stub's tag; a WRONGTYPE refusal
11//! never pays a pread ([`Store::tier_resolve`] / [`Store::tier_serve`]
12//! check the tag before touching disk).
13//! - **Stage 2 (materialize)**: write paths promote in place; read
14//! paths run the promotion gate — the FIRST materializing access
15//! serves decoded bytes without installing (probation `touched`
16//! mark), the SECOND promotes. Bulk/`&self` shared-lane reads never
17//! promote and never set the mark.
18//!
19//! Demotion/promotion are dedicated in-place primitives — NOT
20//! `insert_entry`/`remove_entry`, which would clear hash field-TTLs,
21//! capture `new` events and drift the `expires` counter. They emit
22//! zero keyspace notifications, never bump WATCH versions, and
23//! preserve `lru_clock` (LFU history survives a round trip).
24//!
25//! This module is compiled only with `std` off-wasm (the vlog needs a
26//! real filesystem); a sibling `cfg(not(...))` block provides funnel
27//! passthroughs so call sites stay cfg-free.
28
29#[cfg(all(feature = "std", not(target_arch = "wasm32")))]
30mod enabled {
31 use std::io;
32 use std::path::{Path, PathBuf};
33
34 use kevy_vlog::{Vlog, VlogRef};
35
36 use crate::value::{ColdRef, Value};
37 use crate::{EvictionPolicy, SmallBytes, Store};
38
39 /// Per-shard tiering state — present only when tiering is enabled
40 /// (`tier: Option<TierState>`; `None` = today's paths, the A1 gate's
41 /// precondition).
42 pub(crate) struct TierState {
43 pub(crate) vlog: Vlog,
44 pub(crate) budget: u64,
45 /// Demotion victim scoring (RFC §7: tiered-lru default).
46 pub(crate) policy: EvictionPolicy,
47 pub(crate) demotions_total: u64,
48 /// Demote-sampler backoff: ticks left to skip before
49 /// the next over-target sample walk. "Idempotent is not
50 /// convergent" — a store that is over target with nothing left
51 /// to spill (every spillable value already cold, or the floor
52 /// alone exceeds the budget so `effective_target == 0`) used to
53 /// re-walk the sample window every tick forever.
54 pub(crate) tick_wait: u32,
55 /// Current backoff width: doubles on every dry tick batch up
56 /// to [`crate::tier_demote::BACKOFF_CEILING_TICKS`], resets to
57 /// 0 on any demotion (tick or write path — the write path
58 /// always samples immediately, so a fresh spillable value
59 /// never waits out the window).
60 pub(crate) tick_skip: u32,
61 pub(crate) promotions_total: u64,
62 /// Every vlog record read (serve, promote, peek) — the
63 /// WRONGTYPE-without-read proof counter.
64 pub(crate) preads_total: u64,
65 /// Record reads made by NO-PROMOTE peeks only: hydration,
66 /// backfill, digest, scope-move. One per cold ROW — the
67 /// preads==rows (not rows×fields) proof counter.
68 pub(crate) peek_preads_total: u64,
69 /// Batched cold-read submissions: one per
70 /// [`Store::peek_hash_rows`] page with ≥1 cold row, weighted by
71 /// the reader's kernel submission count — the one-batch-per-page
72 /// proof counter.
73 pub(crate) batch_submissions_total: u64,
74 pub(crate) cold_keys: u64,
75 pub(crate) cold_bytes: u64,
76 /// Largest value weight demotion may spill (bytes; 0 =
77 /// unlimited). Bounds the pread-under-shard-lock hold time on
78 /// the embedded RwLock shape (RFC §7: embedded default 256 KiB,
79 /// server unlimited) — an over-cap value simply stays hot.
80 pub(crate) max_spill: u64,
81 /// Index/view memory floor (Σ segment `approx_bytes` on this
82 /// shard), fed per shard tick by [`Store::set_tier_reserved`].
83 /// Subtracted from the demote watermark: the
84 /// premium fixed layer demotion can never reclaim.
85 pub(crate) reserved_bytes: u64,
86 /// RAM the cold stubs themselves cost (Σ per cold key of
87 /// `ENTRY_OVERHEAD + key heap bytes`) — the other unreclaimable
88 /// floor, maintained incrementally at demote / promote /
89 /// DEL-of-cold / RENAME / FLUSHALL.
90 pub(crate) stub_bytes: u64,
91 /// Cold stubs RENAMEd away from their record's embedded key:
92 /// `(file_id, offset) → current key`. Rename moves the stub
93 /// without a pread, so the on-disk key goes stale; compaction's
94 /// `is_live`/`moved` consult this map on a primary-key miss.
95 /// Usually empty; entries die with their stub.
96 pub(crate) renames: std::collections::HashMap<(u32, u64), SmallBytes>,
97 }
98
99 /// Tiering gauges — the `INFO # Tiering` feeders.
100 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
101 pub struct TierStats {
102 /// The RAM budget this shard demotes against (resolved bytes).
103 pub budget: u64,
104 /// The unified demote target: `budget·19/20 − reserved_bytes −
105 /// stub_bytes`, saturating. **0 = the floor alone exceeds the
106 /// budget** — the tier can demote nothing; visible here, never
107 /// silent (RFC §4 row 16).
108 pub effective_target: u64,
109 /// Index/view memory floor fed by [`Store::set_tier_reserved`].
110 pub reserved_bytes: u64,
111 /// RAM the cold stubs cost (Σ `ENTRY_OVERHEAD + key heap`).
112 pub stub_bytes: u64,
113 /// Keys demoted to the cold tier since boot.
114 pub demotions_total: u64,
115 /// Keys promoted back since boot.
116 pub promotions_total: u64,
117 /// Vlog record reads (serve + promote + peek).
118 pub preads_total: u64,
119 /// No-promote peek record reads only — one per cold row.
120 pub peek_preads_total: u64,
121 /// Batched cold-read submissions — one per page batch on
122 /// the sync reader; kernel submit count on the uring reader.
123 pub batch_submissions_total: u64,
124 /// Currently-cold keys.
125 pub cold_keys: u64,
126 /// Σ original weights of currently-cold values.
127 pub cold_bytes: u64,
128 /// Vlog file count.
129 pub vlog_files: u64,
130 /// Vlog total bytes on disk.
131 pub vlog_bytes: u64,
132 /// Vlog live (non-dead) bytes.
133 pub vlog_live_bytes: u64,
134 /// Vlog compaction epoch (retired-file counter).
135 pub vlog_epoch: u64,
136 }
137
138 impl ColdRef {
139 #[inline]
140 pub(crate) fn vref(self) -> VlogRef {
141 VlogRef { file_id: self.file_id, offset: self.offset, len: self.len }
142 }
143 }
144
145 impl Store {
146 /// Turn tiering on: open (wiping) the vlog under `dir` and set
147 /// the RAM `budget` the demotion watermark works against.
148 /// Callers own the dir choice (`<data>/tier/` by convention).
149 pub fn enable_tiering(&mut self, dir: &Path, budget: u64) -> io::Result<()> {
150 let dir: PathBuf = dir.to_path_buf();
151 let vlog = Vlog::open(&dir, kevy_vlog::DEFAULT_ROTATE_BYTES)?;
152 self.cold_backing = true;
153 self.tier = Some(TierState {
154 vlog,
155 budget,
156 policy: EvictionPolicy::AllKeysLru,
157 demotions_total: 0,
158 tick_wait: 0,
159 tick_skip: 0,
160 promotions_total: 0,
161 preads_total: 0,
162 peek_preads_total: 0,
163 batch_submissions_total: 0,
164 cold_keys: 0,
165 cold_bytes: 0,
166 max_spill: 0,
167 reserved_bytes: 0,
168 stub_bytes: 0,
169 renames: std::collections::HashMap::new(),
170 });
171 Ok(())
172 }
173
174 /// Live-update the tiering budget (auto/percent re-resolution on
175 /// the shard tick, `CONFIG SET` — the maxmemory reapply
176 /// precedent). Touches nothing but the number: the vlog, the
177 /// stubs and every counter stay as they are. No-op when tiering
178 /// is off.
179 #[inline]
180 pub fn set_tier_budget(&mut self, bytes: u64) {
181 if let Some(t) = &mut self.tier {
182 t.budget = bytes;
183 }
184 }
185
186 /// Cap the largest spillable value (0 = unlimited). Embedded
187 /// sets 256 KiB by default (RFC §7) to bound cold-read
188 /// lock-hold time; the server leaves it unlimited. No-op when
189 /// tiering is off.
190 #[inline]
191 pub fn set_tier_max_spill(&mut self, bytes: u64) {
192 if let Some(t) = &mut self.tier {
193 t.max_spill = bytes;
194 }
195 }
196
197 /// Feed the index/view memory floor (Σ segment `approx_bytes`
198 /// on this shard) into the unified watermark. Called per shard
199 /// tick by the serving layer. No-op when tiering is off.
200 #[inline]
201 pub fn set_tier_reserved(&mut self, bytes: u64) {
202 if let Some(t) = &mut self.tier {
203 t.reserved_bytes = bytes;
204 }
205 }
206
207 /// Whether the index/view floor (`reserved_bytes + extra`)
208 /// already exhausts the tier's demotable headroom — the
209 /// IDX.CREATE refusal predicate (RFC §4 row 16). `false` when
210 /// tiering is off.
211 pub fn tier_index_floor_blocked(&self, extra: u64) -> bool {
212 match &self.tier {
213 Some(t) => {
214 t.reserved_bytes.saturating_add(extra)
215 >= crate::tier_demote::watermark(t.budget).saturating_sub(t.stub_bytes)
216 }
217 None => false,
218 }
219 }
220
221 /// Whether tiering is on for this shard.
222 #[inline]
223 pub fn tier_enabled(&self) -> bool {
224 self.tier.is_some()
225 }
226
227 /// Tiering gauges — zeros when tiering is off.
228 pub fn tier_stats(&self) -> TierStats {
229 match &self.tier {
230 None => TierStats::default(),
231 Some(t) => {
232 let v = t.vlog.stats();
233 TierStats {
234 budget: t.budget,
235 effective_target: crate::tier_demote::effective_target(t),
236 reserved_bytes: t.reserved_bytes,
237 stub_bytes: t.stub_bytes,
238 demotions_total: t.demotions_total,
239 promotions_total: t.promotions_total,
240 preads_total: t.preads_total,
241 peek_preads_total: t.peek_preads_total,
242 batch_submissions_total: t.batch_submissions_total,
243 cold_keys: t.cold_keys,
244 cold_bytes: t.cold_bytes,
245 vlog_files: v.files as u64,
246 vlog_bytes: v.bytes,
247 vlog_live_bytes: v.live_bytes,
248 vlog_epoch: v.epoch,
249 }
250 }
251 }
252 }
253
254 /// Whether the LRU/LFU access clock must advance: eviction
255 /// (`maxmemory > 0`) or tiering (demotion scoring) needs it.
256 /// Same single-branch cost as the old `maxmemory > 0` test.
257 #[inline]
258 pub(crate) fn clock_on(&self) -> bool {
259 self.maxmemory > 0 || self.tier.is_some()
260 }
261
262 /// The policy access-touches score under: eviction's when
263 /// enabled, else the tier's (tiered-lru default).
264 #[inline]
265 pub(crate) fn touch_policy(&self) -> EvictionPolicy {
266 if self.maxmemory > 0 {
267 return self.eviction_policy;
268 }
269 match &self.tier {
270 Some(t) => t.policy,
271 None => self.eviction_policy,
272 }
273 }
274
275 /// Pin every current vlog file (view pinning): a
276 /// snapshot view / rewrite plan captured from a tiered store
277 /// carries these so its frozen [`ColdRef`]s stay readable on the
278 /// serializer thread across compaction — a retired file is
279 /// unlinked only when the last pin drops. Empty when tiering is
280 /// off.
281 pub fn tier_pins(&self) -> Vec<std::sync::Arc<kevy_vlog::VlogFile>> {
282 match &self.tier {
283 Some(t) => t.vlog.pin_all(),
284 None => Vec::new(),
285 }
286 }
287
288 /// Serialization-side cold materialization: decode `v`'s
289 /// record into a fresh owned hot value WITHOUT installing,
290 /// promoting, or setting the probation mark — persistence is a
291 /// bulk path and never promotes. `None` when `v` is hot.
292 pub fn materialize_cold(&self, key: &[u8], v: &Value) -> Option<Value> {
293 self.tier_peek_value(key, v)
294 }
295
296 /// A cold stub is being discarded (DEL / overwrite / expiry /
297 /// FLUSH of the key): credit its record's bytes as dead so the
298 /// compaction trigger sees them, and release the stub's RAM
299 /// cost from `stub_bytes`. `key_heap` is the heap-byte cost of
300 /// the key the stub lived under (part of the stub cost —
301 /// callers pass `key_heap_bytes_for(key)` / `key.heap_bytes()`
302 /// since some sites have already moved the key into the map).
303 /// No-op for hot values.
304 pub(crate) fn tier_note_dead(&mut self, key_heap: u64, v: &Value) {
305 let Value::Cold(c) = v else { return };
306 if c.is_seg() {
307 self.segrow_note_dead(*c);
308 return;
309 }
310 if let Some(t) = &mut self.tier {
311 t.vlog.note_dead(c.vref());
312 t.cold_keys = t.cold_keys.saturating_sub(1);
313 t.cold_bytes = t.cold_bytes.saturating_sub(u64::from(c.weight));
314 t.stub_bytes = t
315 .stub_bytes
316 .saturating_sub(crate::value::ENTRY_OVERHEAD + key_heap);
317 t.renames.remove(&(c.file_id, c.offset));
318 }
319 }
320
321 /// RENAME moved a cold stub from `src` to `dst` without reading
322 /// it — the record's embedded key is now stale; register the
323 /// forward pointer compaction resolves through, and re-account
324 /// the stub cost for the new key's heap bytes.
325 pub(crate) fn tier_note_renamed(&mut self, v: &Value, src: &[u8], dst: &[u8]) {
326 let Value::Cold(c) = v else { return };
327 if let Some(t) = &mut self.tier {
328 t.stub_bytes = t
329 .stub_bytes
330 .saturating_sub(crate::key_heap_bytes_for(src))
331 .saturating_add(crate::key_heap_bytes_for(dst));
332 t.renames.insert((c.file_id, c.offset), SmallBytes::from_slice(dst));
333 }
334 }
335
336 /// FLUSHALL: every stub died with the map — mark the whole log
337 /// dead (sealed files drop scan-free at the next compaction).
338 pub(crate) fn tier_on_flushall(&mut self) {
339 if let Some(t) = &mut self.tier {
340 t.vlog.mark_all_dead();
341 t.renames.clear();
342 t.cold_keys = 0;
343 t.cold_bytes = 0;
344 t.stub_bytes = 0;
345 }
346 }
347 }
348}
349
350#[cfg(all(feature = "std", not(target_arch = "wasm32")))]
351pub use enabled::TierStats;
352#[cfg(all(feature = "std", not(target_arch = "wasm32")))]
353pub(crate) use enabled::TierState;
354
355/// Funnel passthroughs for builds without the tier backend (no_std /
356/// wasm): `Value::Cold` cannot be constructed there (no `enable_tiering`),
357/// so the funnel degenerates to `live_entry` and no-ops.
358#[cfg(not(all(feature = "std", not(target_arch = "wasm32"))))]
359mod disabled {
360 use crate::value::Value;
361 use crate::{EvictionPolicy, Store};
362
363 impl Store {
364 #[inline]
365 pub(crate) fn clock_on(&self) -> bool {
366 self.maxmemory > 0
367 }
368
369 #[inline]
370 pub(crate) fn touch_policy(&self) -> EvictionPolicy {
371 self.eviction_policy
372 }
373
374 /// No tier backend on this target — `Value::Cold` cannot exist.
375 #[inline]
376 pub fn materialize_cold(&self, _key: &[u8], _v: &Value) -> Option<Value> {
377 None
378 }
379
380 /// No tier backend on this target — always 0.
381 #[inline]
382 pub fn demote_to_watermark(&mut self) -> usize {
383 0
384 }
385
386 #[inline]
387 pub(crate) fn tier_note_dead(&mut self, _key_heap: u64, _v: &Value) {}
388
389 #[inline]
390 pub(crate) fn tier_note_renamed(&mut self, _v: &Value, _src: &[u8], _dst: &[u8]) {}
391
392 #[inline]
393 pub(crate) fn tier_on_flushall(&mut self) {}
394
395 /// No tier backend on this target — no-op.
396 #[inline]
397 pub fn set_tier_budget(&mut self, _bytes: u64) {}
398
399 /// No tier backend on this target — no-op.
400 #[inline]
401 pub fn set_tier_reserved(&mut self, _bytes: u64) {}
402
403 /// No tier backend on this target — always false.
404 #[inline]
405 pub fn tier_index_floor_blocked(&self, _extra: u64) -> bool {
406 false
407 }
408
409 #[inline]
410 pub(crate) fn promote_in_place(&mut self, _key: &[u8]) -> bool {
411 false
412 }
413
414 /// No tier backend on this target — always 0.
415 #[inline]
416 pub fn try_demote_after_write(&mut self) -> usize {
417 0
418 }
419
420 /// No tier backend on this target — always 0.
421 #[inline]
422 pub fn demote_step(&mut self) -> usize {
423 0
424 }
425
426 /// No tier backend on this target — always 0.
427 #[inline]
428 pub fn tier_compact_tick(&mut self) -> usize {
429 0
430 }
431
432 /// No tier backend on this target — always false.
433 #[doc(hidden)]
434 pub fn debug_force_demote(&mut self, _key: &[u8]) -> bool {
435 false
436 }
437
438 /// No tier backend on this target — always false.
439 #[inline]
440 pub fn tier_enabled(&self) -> bool {
441 false
442 }
443 }
444}