Skip to main content

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}