Skip to main content

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