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(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}