kevy_store/value.rs
1//! Value types — one backing structure per Redis type.
2
3#[cfg(not(feature = "std"))]
4use crate::nostd_prelude::*;
5use alloc::collections::VecDeque;
6use alloc::sync::Arc;
7use core::cmp::Ordering;
8pub use kevy_bytes::SmallBytes;
9use kevy_map::{KevyMap, KevySet};
10use kevy_ranktree::RankTree;
11
12/// Backing structure for a Hash value — [`KevyMap`] keyed by [`SmallBytes`]
13/// (22 B inline / heap-else). Field names ≤22B (the vast majority — `name`,
14/// `email`, etc.) live entirely inside the bucket, saving the 24 B Vec
15/// metadata + heap allocation per field on a 22-byte budget.
16pub type HashData = KevyMap<SmallBytes, SmallBytes>;
17/// Backing structure for a List value (a ring-buffer deque — O(1) both ends).
18pub type ListData = VecDeque<Vec<u8>>;
19/// Backing structure for a Set value — [`KevySet`] of [`SmallBytes`].
20pub type SetData = KevySet<SmallBytes>;
21
22/// A total-ordered f64 score (Redis scores are never NaN). `total_cmp` gives a
23/// total order so scores can key an ordered container.
24///
25/// `PartialEq` is written rather than derived, and it must stay that way.
26/// Derived, it is `f64`'s `==`, which disagrees with the `total_cmp` below
27/// on `-0.0`: `==` calls it equal to `0.0`, `total_cmp` orders it before.
28/// Rust requires of an `Ord` key that `a == b` exactly when `a.cmp(b)` is
29/// `Equal`, and a `Score` that breaks that is a key whose container and
30/// whose callers disagree about which entries are the same one.
31///
32/// It was latent while nothing compared two `Score`s — `ZSetData::insert`
33/// reached the tree only through `Ord`, and its unconditional
34/// remove-then-insert never had to ask. `ZADD z -0 m` is accepted and
35/// `ZADD z2 -0 a; ZADD z2 0 b` really does order `a` first, so the first
36/// caller to write `old == score` would have skipped a live update and left
37/// `by_member` and `by_score` holding different scores for one member.
38/// NaN cannot arrive: the parser refuses it.
39#[derive(Clone, Copy)]
40pub struct Score(pub f64);
41impl PartialEq for Score {
42 fn eq(&self, other: &Self) -> bool {
43 self.0.total_cmp(&other.0) == Ordering::Equal
44 }
45}
46impl Eq for Score {}
47impl Ord for Score {
48 fn cmp(&self, other: &Self) -> Ordering {
49 self.0.total_cmp(&other.0)
50 }
51}
52impl PartialOrd for Score {
53 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
54 Some(self.cmp(other))
55 }
56}
57
58/// A score-range endpoint for `ZRANGEBYSCORE`/`ZCOUNT` (inclusive or exclusive).
59/// Use `value = ±INFINITY` for `-inf`/`+inf`.
60pub struct ScoreBound {
61 /// The score itself. `f64::INFINITY` / `NEG_INFINITY` carry `+inf` and
62 /// `-inf`, which is why this is not an `Option`.
63 pub value: f64,
64 /// `true` for Redis's `(` prefix — the endpoint is excluded from the
65 /// range. An exclusive infinity is accepted and means the same as an
66 /// inclusive one, since nothing equals infinity.
67 pub exclusive: bool,
68}
69impl ScoreBound {
70 /// Does `s` satisfy this as a *minimum* bound?
71 pub(crate) fn ge_ok(&self, s: f64) -> bool {
72 if self.exclusive { s > self.value } else { s >= self.value }
73 }
74 /// Does `s` satisfy this as a *maximum* bound?
75 pub(crate) fn le_ok(&self, s: f64) -> bool {
76 if self.exclusive { s < self.value } else { s <= self.value }
77 }
78}
79
80/// Sorted set: a member→score map plus an order-statistic B-tree keyed by
81/// `(score, member)` ([`kevy_ranktree::RankTree`] — every node carries its
82/// subtree count), so rank queries (`ZRANK`, `ZRANGE` by rank, `ZCOUNT`,
83/// score-bound seeks) are O(log N) descents instead of linear walks.
84#[derive(Default, Clone)]
85pub struct ZSetData {
86 pub(crate) by_member: KevyMap<SmallBytes, f64>,
87 /// The `(score, member)` order-statistic index. Member is a
88 /// [`SmallBytes`] (≤22 B inline in the node's key slot), ordered by
89 /// byte-lexicographic `Ord` — the same order the old `Vec<u8>` gave.
90 pub(crate) by_score: RankTree<(Score, SmallBytes)>,
91}
92
93impl ZSetData {
94 pub(crate) fn insert(&mut self, member: &[u8], score: f64) -> bool {
95 let is_new = match self.by_member.insert(SmallBytes::from_slice(member), score) {
96 Some(old) => {
97 // An unchanged score has nothing to reorder. Without this,
98 // the remove below takes (old, member) out of the index and
99 // the insert at the bottom puts (score, member) back — the
100 // same key, so the tree ends exactly where it began, having
101 // paid a descent, a removal, an insertion and two extra
102 // SmallBytes for it. Measured at 30.6% of the operation
103 // on an 8,000-member set — see the ZADD decomposition
104 // under bench/, which prices it against ZSCORE.
105 //
106 // Through `Score`, never `f64`. The two disagree on -0.0,
107 // the tree keys on `Score`, and `==` here would skip a real
108 // reordering and leave this map and that tree holding
109 // different scores for one member.
110 if Score(old) == Score(score) {
111 return false;
112 }
113 self.by_score.remove(&(Score(old), SmallBytes::from_slice(member)));
114 false
115 }
116 None => true,
117 };
118 self.by_score.insert((Score(score), SmallBytes::from_slice(member)));
119 is_new
120 }
121 pub(crate) fn remove(&mut self, member: &[u8]) -> bool {
122 match self.by_member.remove(member) {
123 Some(old) => {
124 self.by_score.remove(&(Score(old), SmallBytes::from_slice(member)));
125 true
126 }
127 None => false,
128 }
129 }
130 pub(crate) fn len(&self) -> usize {
131 self.by_member.len()
132 }
133 /// `(member, score)` pairs in ascending `(score, member)` order.
134 pub fn ordered(&self) -> impl Iterator<Item = (&[u8], f64)> {
135 self.by_score.iter().map(|(s, m)| (m.as_slice(), s.0))
136 }
137 /// Like [`Self::ordered`] but starting at ascending `rank` — one
138 /// O(log N) seek, no skip-walk.
139 pub(crate) fn ordered_from(&self, rank: usize) -> impl Iterator<Item = (&[u8], f64)> {
140 self.by_score.iter_from(rank).map(|(s, m)| (m.as_slice(), s.0))
141 }
142 /// The ascending rank of `member` (whose score is `score`). O(log N).
143 pub(crate) fn rank_of(&self, member: &[u8], score: f64) -> Option<usize> {
144 self.by_score.rank_of(&(Score(score), SmallBytes::from_slice(member)))
145 }
146 /// First rank whose score satisfies `min` as a lower bound. O(log N).
147 pub(crate) fn score_start_rank(&self, min: &ScoreBound) -> usize {
148 self.by_score.partition_point(|(s, _)| !min.ge_ok(s.0))
149 }
150 /// First rank whose score fails `max` as an upper bound (i.e. one past
151 /// the last in-range rank). O(log N).
152 pub(crate) fn score_end_rank(&self, max: &ScoreBound) -> usize {
153 self.by_score.partition_point(|(s, _)| max.le_ok(s.0))
154 }
155}
156
157pub use crate::value_cold::{COLD_TAG_HASH, COLD_TAG_STRING, ColdRef};
158
159/// A stored value. One variant per Redis type.
160///
161/// The collection variants live behind a **shared pointer** (`Arc`) so the
162/// enum is only as big as `Str` (24 B) + tag = 32 B, not the 56 B `ZSetData`
163/// — every `Entry` (incl. the common string case) is then ~48 B instead of
164/// ~80 B, so the bucket array is ~40% denser/smaller (fewer cache misses on
165/// a large keyspace, less RSS). The extra pointer-chase lands only on
166/// collection ops, not the hot string GET path.
167///
168/// `Arc` (same 8 B as the previous `Box`) is what makes O(short-pause)
169/// persistence possible: [`crate::Store::collect_snapshot`] bumps each
170/// collection's refcount instead of serializing it, and a background thread
171/// walks the frozen payloads at leisure. Mutations go through
172/// [`std::sync::Arc::make_mut`] — a single uniqueness check (the steady
173/// state, no snapshot in flight) or a copy-on-write deep clone when a
174/// snapshot still holds the payload.
175///
176/// `Str` holds a [`SmallBytes`] (24 B, same size as `Vec<u8>`) so byte strings
177/// up to 22 bytes live **inline inside the bucket**, killing the second cache
178/// miss the value pointer-chase used to cost on large-keyspace GETs.
179/// `Clone` is the snapshot-collect primitive: `Str` copies its bytes
180/// (inline = 24 B memcpy; heap = one allocation), collections bump a
181/// refcount. See [`crate::Store::collect_snapshot`].
182#[derive(Clone)]
183pub enum Value {
184 /// A byte string, inline up to 22 bytes — see the type doc above for
185 /// why that boundary is where it is.
186 Str(SmallBytes),
187 /// Following valkey's OBJ_ENCODING_INT: when a SET
188 /// stores a clean canonical i64 ASCII string (parses round-trip), we
189 /// keep the integer **as i64** rather than as 22 B of inline bytes.
190 /// Wins on INCR (in-place `+= delta`, no parse / no format / no
191 /// SmallBytes wrap) and on memory (8 B vs 24 B). GET formats it via
192 /// a per-`Store` scratch buffer.
193 Int(i64),
194 /// Values larger
195 /// than [`BULK_THRESHOLD`] bytes get stored behind an
196 /// `Arc<Box<[u8]>>` instead of a heap-backed `SmallBytes`. The Arc
197 /// lets the io_uring reactor's reply path borrow the bytes across
198 /// the SQE→CQE window safely (Arc clone keeps them alive even if
199 /// the keyspace mutates) — the prerequisite for the writev
200 /// zero-copy bulk reply path, which skips the per-GET memcpy from
201 /// value storage into the per-conn output buffer.
202 ///
203 /// **Why `Arc<Box<[u8]>>` and not `Arc<[u8]>`**: `Arc<[u8]>` is a
204 /// DST-backed `ArcInner<[u8]> = { strong, weak, [u8; N] }` whose
205 /// data slot sits past the refcount words. `Arc::from(Vec<u8>)`
206 /// allocates a fresh `ArcInner` and `copy_from_slice`s the bytes
207 /// — a hard mandatory 64 KiB memcpy on every big SET. With
208 /// `Arc<Box<[u8]>>`, the `Box<[u8]>` wrapper occupies the Arc's
209 /// data slot (16 B), pointing AT an unchanged heap buffer; so
210 /// `Arc::new(vec.into_boxed_slice())` is **truly zero-copy**
211 /// (the boxed slice's allocation stays put — only the 32-byte
212 /// `ArcInner` is freshly malloced). Per-GET cost: one extra
213 /// pointer dereference (`&**arc` to get `&[u8]`), measured to be
214 /// negligible vs the per-SET memcpy savings. The `Arc<[u8]>`
215 /// mandatory copy was confirmed with perf-record before switching
216 /// to `Arc<Box<[u8]>>`.
217 ///
218 /// Small values stay on `Str(SmallBytes)` because the inline
219 /// cache-line storage beats an Arc indirection for the common case.
220 ArcBulk(Arc<Box<[u8]>>),
221 /// A hash below [`HS_PROMOTE`] elements: one map behind one `Arc`, so
222 /// a snapshot pins it whole and the first write during that window
223 /// deep-clones it. Past that size it becomes `SegHash`.
224 Hash(Arc<HashData>),
225 /// A hash past `seg_map::HS_PROMOTE` fields: an extendible-hash
226 /// directory of `Arc`-shared buckets — a COW write under a live
227 /// snapshot view clones one bucket, not the whole value.
228 SegHash(Arc<crate::seg_map::SegMap<SmallBytes>>),
229 /// A list below [`SEG_PROMOTE`] elements: one deque behind one `Arc`,
230 /// with the same whole-value copy-on-write. Past that size it becomes
231 /// `SegList`.
232 List(Arc<ListData>),
233 /// A list past [`crate::list_seg::SEG_PROMOTE`] elements: a deque of
234 /// `Arc`-shared segments so a COW write under a live snapshot view
235 /// clones one segment, not the whole (possibly multi-GB) value. See
236 /// `list_seg.rs` for the promotion contract.
237 SegList(Arc<crate::list_seg::SegListData>),
238 /// A set below [`HS_PROMOTE`] elements, on the same terms as `Hash`.
239 Set(Arc<SetData>),
240 /// A set past `seg_map::HS_PROMOTE` members — the set door of the
241 /// same bucket-sharded COW as [`Value::SegHash`].
242 SegSet(Arc<crate::seg_map::SegMap<()>>),
243 /// A sorted set: members with scores, plus the order-statistic tree
244 /// that makes rank queries a lookup rather than a scan.
245 ZSet(Arc<ZSetData>),
246 /// A zset past `zset_seg::Z_PROMOTE` members — sharded member map
247 /// + ordered segments; COW writes clone one bucket + one segment.
248 SegZSet(Arc<crate::zset_seg::SegZSetData>),
249 /// A stream: entries, consumer groups and their pending lists. Never
250 /// segmented — a stream trims from the front instead of growing
251 /// without bound.
252 Stream(Arc<crate::stream::StreamData>),
253 /// Valkey-orthodox encoding switch: tiny sets (1-N
254 /// short members) live inline in 24 bytes instead of behind
255 /// `Arc<SetData>` — matches valkey's `OBJ_ENCODING_LISTPACK` for
256 /// sets, which is what `redis-benchmark -t sadd` default `-r 0`
257 /// (cardinality stays at 1 forever, single 20-byte literal member)
258 /// measures. On overflow ([`crate::small_set::SmallSetData::try_add`]
259 /// returns `NoRoom`) the set is promoted to `Value::Set(Arc<SetData>)`
260 /// — the Swiss-table path that wins for larger cardinalities.
261 SmallSetInline(crate::small_set::SmallSetData),
262 /// Tiny hashes
263 /// (1-2 short field-value pairs) live inline in 24 bytes; promoted
264 /// to `Value::Hash(Arc<HashData>)` on overflow. Mirrors valkey's
265 /// `OBJ_ENCODING_LISTPACK` for hashes.
266 SmallHashInline(crate::small_hash::SmallHashData),
267 /// A declared table's row: the columns in declared order, in one payload
268 /// buffer, with no field names and no per-row table.
269 ///
270 /// Reachable only for a key under a declared prefix — an undeclared hash
271 /// keeps [`Value::Hash`].
272 PackedRow(crate::packed_row::PackedRow),
273 /// Tiny lists inline encoding; promoted to
274 /// `Value::List(Arc<ListData>)` on overflow.
275 SmallListInline(crate::small_list::SmallListData),
276 /// Tiny sorted sets inline encoding; promoted to
277 /// `Value::ZSet(Arc<ZSetData>)` on overflow.
278 SmallZSetInline(crate::small_zset::SmallZSetData),
279 /// A demoted (tiered-to-disk) value's in-map stub. The two-stage
280 /// funnel (`tier` module) resolves this before any typed match sees
281 /// it: stage 1 answers existence/TYPE/TTL from the stub with zero
282 /// IO; stage 2 materializes (serve or promote) only on a type
283 /// match. Cloning a `Cold` clones the STUB, not the record — paths
284 /// that duplicate values (COPY, cross-shard ship) materialize
285 /// first so two stubs never alias one vlog record.
286 Cold(ColdRef),
287}
288
289/// Threshold (bytes) above which a SET stores its value as
290/// [`Value::ArcBulk`] (writev-eligible on GET) instead of [`Value::Str`]
291/// (inline `SmallBytes`). 64 B ≈ one cache line — below that the
292/// inline-SmallBytes storage wins on L1 locality; above it the
293/// writev-borrow win dominates.
294pub const BULK_THRESHOLD: usize = 64;
295
296const _: () = {
297 // Don't let future variants undo box-collection's Entry-48B win.
298 assert!(core::mem::size_of::<Value>() <= 32);
299};
300
301/// Heap-size threshold above which an overwritten `Value` is sent to the
302/// runtime's bio thread for off-reactor drop instead of being freed inline
303/// (lazy-drop).
304///
305/// **Why not lower**: a 256 B threshold regressed c=50 -d 10240 SET
306/// p999 from 0.487 → 1.583 ms (worse by 3.25×). The cause: `std::sync::mpsc::Sender::send`
307/// is a few hundred ns of atomic + Box clone, which EXCEEDS the inline
308/// `Box::<[u8]>::drop` cost when the allocator serves the free from a
309/// hot large-class slab (~ 1-3 µs for 10 KB; the bench's steady state).
310/// Off-loading only wins when the inline drop's tail risk (cold-slab
311/// `munmap`/`madvise` consolidation stall, observed at 50-150 µs and
312/// occasionally millisecond-range) exceeds the per-send channel cost
313/// PLUS the cross-thread cache-line bouncing.
314///
315/// With per-shard batch accumulation flushing at the end of every
316/// reactor iteration, the per-mpsc-send cost is amortised across N
317/// drops. That makes the channel hop profitable at smaller sizes than
318/// a lone-send model could justify (lone-send had to lift the
319/// threshold to 16 KB because per-`mpsc::send` cost was a few hundred
320/// ns — at 256 B the inline drop was cheaper).
321///
322/// **Sweet-spot surprise**: intuition suggested dropping the threshold
323/// to 256 B – 1 KB once batching amortises the send. A sweep across
324/// thresholds {512, 1024, 4096, 16384} × c=50 SET -d {1K, 4K, 10K, 64K}
325/// disproved that floor: at ≤ 1 KB threshold, p999 / max on small
326/// values (-d 1024, -d 4096) was variance-bounded equal or
327/// occasionally WORSE than a 16 KB threshold, while the larger
328/// sizes (10 KB / 64 KB) won either way. Cause: the Vec::push +
329/// occasional `MAX_PENDING_DROPS` force-flush stall costs more for
330/// small Arcs (allocator small-class free is sub-µs even at tail)
331/// than the inline drop it avoids.
332///
333/// Picked **4 KB** as the lowest threshold where the bio-off-reactor
334/// win consistently dominates the batch-buffer overhead on tail
335/// metrics. The biggest batching wins (vs lone-send at 16 KB) land on
336/// `-d 64K` SET p50 (-44 %) and `-d 10K` SET max (-35 %), where each
337/// iter's batch already contains several heavy values per shard.
338pub const HEAP_HEAVY_BYTES: usize = 4 * 1024;
339
340/// Sender half of the runtime's bio-drop channel. Wired from
341/// `kevy-rt`'s `bio.rs` via [`crate::Store::set_bio_drop_sender`]; the
342/// concrete payload is `Vec<Value>` — a **batch** of values
343/// produced by one shard since its last flush.
344/// The bio thread (`kevy-rt::bio::spawn`) iterates the batch and
345/// drops each item. One mpsc message per shard-flush amortises the
346/// channel cost (atomic + cross-thread cacheline traffic) across
347/// however many values landed in the batch.
348#[cfg(feature = "std")]
349pub type BioDropSender = std::sync::mpsc::Sender<Vec<Value>>;
350
351impl Value {
352 /// The Redis type name (`TYPE` command).
353 pub fn type_name(&self) -> &'static str {
354 match self {
355 Value::Str(_) | Value::Int(_) | Value::ArcBulk(_) => "string",
356 Value::Hash(_)
357 | Value::SegHash(_)
358 | Value::SmallHashInline(_)
359 | Value::PackedRow(_) => "hash",
360 Value::List(_) | Value::SegList(_) | Value::SmallListInline(_) => "list",
361 Value::Set(_) | Value::SegSet(_) | Value::SmallSetInline(_) => "set",
362 Value::ZSet(_) | Value::SegZSet(_) | Value::SmallZSetInline(_) => "zset",
363 Value::Stream(_) => "stream",
364 // Stage-1 funnel: TYPE (and SCAN's TYPE filter) answer from
365 // the tag — a cold key never pays a pread for its type.
366 Value::Cold(c) => c.type_name(),
367 }
368 }
369}
370
371// `BioDropSender = mpsc::Sender<Box<Value>>` requires `Value: Send`. Static
372// assert: if a future variant inadvertently makes Value `!Send` (e.g. an
373// `Rc<...>` payload) this fails at compile time, BEFORE the runtime tries
374// to hand a value to the bio thread.
375const _: fn() = || {
376 fn assert_send<T: Send>() {}
377 assert_send::<Value>();
378};
379
380/// Per-bucket footprint for `KevyMap`/`KevySet`-backed collections (open-
381/// addressing Swiss table). Approximation, not exact: includes metadata byte
382/// per slot plus the boxed `K`/`V` cell, padded for 7/8 load factor.
383pub(crate) const HASH_SLOT_BYTES: u64 = 32;
384pub(crate) const SET_SLOT_BYTES: u64 = 24;
385/// `VecDeque` ring-buffer slot per stored `Vec<u8>` header (24 B Vec metadata).
386pub(crate) const LIST_SLOT_BYTES: u64 = 24;
387/// `BTreeSet`/`BTreeMap` per-entry overhead (node pointers + B-tree node
388/// padding) — the stream index's accounting constant.
389pub(crate) const BTREE_SLOT_BYTES: u64 = 40;
390/// `kevy_ranktree::RankTree` per-key overhead. Measured from the structure:
391/// the `(Score, SmallBytes)` key slot is 32 B; nodes hold ≤15 keys in a Vec
392/// whose buffer rounds to 16 slots at ~2/3 fill (≈10-11 live keys), so the
393/// key arrays amortise to ≈48 B per key; the per-node fixed cost (56 B
394/// header + Box allocation, ~1 node per 10 keys) and the internal nodes'
395/// child-pointer arrays add ≈8 B more. 64 errs slightly high (allocator
396/// size-class rounding), keeping `used_memory` a conservative upper bound —
397/// same policy as [`ENTRY_OVERHEAD`].
398pub(crate) const RANKTREE_SLOT_BYTES: u64 = 64;
399/// Per-entry overhead in the top-level keyspace map: the inline 24-byte
400/// `SmallBytes` key cell + the 64-byte `Entry` (post weight/clock fields) +
401/// metadata. Approximation that errs slightly high so `used_memory` stays a
402/// conservative upper bound vs the actual allocator footprint.
403pub const ENTRY_OVERHEAD: u64 = 96;
404
405#[inline]
406pub(crate) fn collection_overhead(capacity: usize, per_slot: u64) -> u64 {
407 (capacity as u64).saturating_mul(per_slot)
408}
409
410/// Per-field delta a new hash field charges against the entry weight: heap
411/// bytes for the field name (if not inline) + heap bytes for the value (0 when
412/// the value is ≤22 B and lives inline in the slot) + one slot of bucket
413/// overhead. Used when an HSET inserts a brand-new field. Both field and value
414/// inline in the fixed-size slot when short, so only the off-slot footprint is
415/// charged — symmetric with [`set_member_weight`].
416#[inline]
417pub fn hash_field_weight(field: &SmallBytes, value_heap: usize) -> u64 {
418 field.heap_bytes() as u64 + value_heap as u64 + HASH_SLOT_BYTES
419}
420
421/// Per-member delta a new set member charges. Mirrors [`hash_field_weight`]
422/// for the set variant (no separate value, single bucket slot).
423#[inline]
424pub fn set_member_weight(member: &SmallBytes) -> u64 {
425 member.heap_bytes() as u64 + SET_SLOT_BYTES
426}
427
428/// Per-item delta a new list element charges (Vec header slot + heap cap).
429#[inline]
430pub fn list_item_weight(value_cap: usize) -> u64 {
431 LIST_SLOT_BYTES + value_cap as u64
432}
433
434/// Per-member delta a new zset member charges: hash slot for `by_member` +
435/// rank-tree slot for `by_score` + the member's heap bytes — twice, because
436/// a heap-spilling member (>22 B) is stored in both structures (inline
437/// members cost 0 here, matching [`Value::weight`]'s ZSet arm).
438#[inline]
439pub fn zset_member_weight(member: &SmallBytes) -> u64 {
440 2 * member.heap_bytes() as u64 + HASH_SLOT_BYTES + RANKTREE_SLOT_BYTES
441}