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