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::*;
5pub use kevy_bytes::SmallBytes;
6use kevy_map::{KevyMap, KevySet};
7use kevy_ranktree::RankTree;
8use core::cmp::Ordering;
9use alloc::collections::VecDeque;
10use alloc::sync::Arc;
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, Vec<u8>>;
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#[derive(Clone, Copy, PartialEq)]
25pub struct Score(pub f64);
26impl Eq for Score {}
27impl Ord for Score {
28    fn cmp(&self, other: &Self) -> Ordering {
29        self.0.total_cmp(&other.0)
30    }
31}
32impl PartialOrd for Score {
33    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
34        Some(self.cmp(other))
35    }
36}
37
38/// A score-range endpoint for `ZRANGEBYSCORE`/`ZCOUNT` (inclusive or exclusive).
39/// Use `value = ±INFINITY` for `-inf`/`+inf`.
40pub struct ScoreBound {
41    pub value: f64,
42    pub exclusive: bool,
43}
44impl ScoreBound {
45    /// Does `s` satisfy this as a *minimum* bound?
46    pub(crate) fn ge_ok(&self, s: f64) -> bool {
47        if self.exclusive {
48            s > self.value
49        } else {
50            s >= self.value
51        }
52    }
53    /// Does `s` satisfy this as a *maximum* bound?
54    pub(crate) fn le_ok(&self, s: f64) -> bool {
55        if self.exclusive {
56            s < self.value
57        } else {
58            s <= self.value
59        }
60    }
61}
62
63/// Sorted set: a member→score map plus an order-statistic B-tree keyed by
64/// `(score, member)` ([`kevy_ranktree::RankTree`] — every node carries its
65/// subtree count), so rank queries (`ZRANK`, `ZRANGE` by rank, `ZCOUNT`,
66/// score-bound seeks) are O(log N) descents instead of linear walks.
67#[derive(Default, Clone)]
68pub struct ZSetData {
69    pub(crate) by_member: KevyMap<SmallBytes, f64>,
70    /// The `(score, member)` order-statistic index. Member is a
71    /// [`SmallBytes`] (≤22 B inline in the node's key slot), ordered by
72    /// byte-lexicographic `Ord` — the same order the old `Vec<u8>` gave.
73    pub(crate) by_score: RankTree<(Score, SmallBytes)>,
74}
75
76impl ZSetData {
77    pub(crate) fn insert(&mut self, member: &[u8], score: f64) -> bool {
78        let is_new = match self.by_member.insert(SmallBytes::from_slice(member), score) {
79            Some(old) => {
80                self.by_score.remove(&(Score(old), SmallBytes::from_slice(member)));
81                false
82            }
83            None => true,
84        };
85        self.by_score.insert((Score(score), SmallBytes::from_slice(member)));
86        is_new
87    }
88    pub(crate) fn remove(&mut self, member: &[u8]) -> bool {
89        match self.by_member.remove(member) {
90            Some(old) => {
91                self.by_score.remove(&(Score(old), SmallBytes::from_slice(member)));
92                true
93            }
94            None => false,
95        }
96    }
97    pub(crate) fn len(&self) -> usize {
98        self.by_member.len()
99    }
100    /// `(member, score)` pairs in ascending `(score, member)` order.
101    pub fn ordered(&self) -> impl Iterator<Item = (&[u8], f64)> {
102        self.by_score.iter().map(|(s, m)| (m.as_slice(), s.0))
103    }
104    /// Like [`Self::ordered`] but starting at ascending `rank` — one
105    /// O(log N) seek, no skip-walk.
106    pub(crate) fn ordered_from(&self, rank: usize) -> impl Iterator<Item = (&[u8], f64)> {
107        self.by_score.iter_from(rank).map(|(s, m)| (m.as_slice(), s.0))
108    }
109    /// The ascending rank of `member` (whose score is `score`). O(log N).
110    pub(crate) fn rank_of(&self, member: &[u8], score: f64) -> Option<usize> {
111        self.by_score.rank_of(&(Score(score), SmallBytes::from_slice(member)))
112    }
113    /// First rank whose score satisfies `min` as a lower bound. O(log N).
114    pub(crate) fn score_start_rank(&self, min: &ScoreBound) -> usize {
115        self.by_score.partition_point(|(s, _)| !min.ge_ok(s.0))
116    }
117    /// First rank whose score fails `max` as an upper bound (i.e. one past
118    /// the last in-range rank). O(log N).
119    pub(crate) fn score_end_rank(&self, max: &ScoreBound) -> usize {
120        self.by_score.partition_point(|(s, _)| max.le_ok(s.0))
121    }
122}
123
124/// Type tag a [`ColdRef`] carries so `TYPE` / SCAN's `TYPE` filter / the
125/// WRONGTYPE precheck answer with zero IO.
126pub const COLD_TAG_STRING: u8 = 1;
127/// Hash tag — see [`COLD_TAG_STRING`].
128pub const COLD_TAG_HASH: u8 = 2;
129
130/// The in-map stub a demoted (cold) value leaves behind: its vlog
131/// record's address + enough metadata to answer stage-1 questions
132/// (existence, TYPE, weight) with zero IO. Byte math: offset u64 (8) +
133/// file_id/len/weight u32 (12) + type_tag/touched u8 (2) = 22, padded
134/// to 24 by u64 alignment — fits `Value`'s 24 B payload (≤32 B assert).
135#[derive(Clone, Copy, PartialEq, Eq, Debug)]
136pub struct ColdRef {
137    /// Byte offset of the record header inside its vlog file.
138    pub(crate) offset: u64,
139    /// The vlog file id.
140    pub(crate) file_id: u32,
141    /// Record body length (mirrors `kevy_vlog::VlogRef::len`).
142    pub(crate) len: u32,
143    /// The ORIGINAL value weight (heap bytes) at demotion time —
144    /// promotion re-accounting sanity + spill-policy input. The live
145    /// `Entry::weight` is re-stamped to the stub's actual footprint so
146    /// `MEMORY USAGE` (and Σ ≈ used_memory) stay stub-actual.
147    pub(crate) weight: u32,
148    /// [`COLD_TAG_STRING`] / [`COLD_TAG_HASH`].
149    pub(crate) type_tag: u8,
150    /// Promotion-gate probation mark: the first materializing access
151    /// serves bytes via pread WITHOUT installing and sets this; the
152    /// second access promotes. Bulk/shared-lane reads never set it.
153    pub(crate) touched: u8,
154}
155
156impl ColdRef {
157    /// The tag's Redis type name (the `TYPE` command on a cold key —
158    /// answered from RAM, never a pread).
159    pub(crate) fn type_name(self) -> &'static str {
160        match self.type_tag {
161            COLD_TAG_HASH => "hash",
162            _ => "string",
163        }
164    }
165}
166
167/// A stored value. One variant per Redis type.
168///
169/// The collection variants live behind a **shared pointer** (`Arc`) so the
170/// enum is only as big as `Str` (24 B) + tag = 32 B, not the 56 B `ZSetData`
171/// — every `Entry` (incl. the common string case) is then ~48 B instead of
172/// ~80 B, so the bucket array is ~40% denser/smaller (fewer cache misses on
173/// a large keyspace, less RSS). The extra pointer-chase lands only on
174/// collection ops, not the hot string GET path.
175///
176/// `Arc` (same 8 B as the previous `Box`) is what makes O(short-pause)
177/// persistence possible: [`crate::Store::collect_snapshot`] bumps each
178/// collection's refcount instead of serializing it, and a background thread
179/// walks the frozen payloads at leisure. Mutations go through
180/// [`std::sync::Arc::make_mut`] — a single uniqueness check (the steady
181/// state, no snapshot in flight) or a copy-on-write deep clone when a
182/// snapshot still holds the payload.
183///
184/// `Str` holds a [`SmallBytes`] (24 B, same size as `Vec<u8>`) so byte strings
185/// up to 22 bytes live **inline inside the bucket**, killing the second cache
186/// miss the value pointer-chase used to cost on large-keyspace GETs.
187/// `Clone` is the snapshot-collect primitive: `Str` copies its bytes
188/// (inline = 24 B memcpy; heap = one allocation), collections bump a
189/// refcount. See [`crate::Store::collect_snapshot`].
190#[derive(Clone)]
191pub enum Value {
192    Str(SmallBytes),
193    /// Following valkey's OBJ_ENCODING_INT: when a SET
194    /// stores a clean canonical i64 ASCII string (parses round-trip), we
195    /// keep the integer **as i64** rather than as 22 B of inline bytes.
196    /// Wins on INCR (in-place `+= delta`, no parse / no format / no
197    /// SmallBytes wrap) and on memory (8 B vs 24 B). GET formats it via
198    /// a per-`Store` scratch buffer.
199    Int(i64),
200    /// Values larger
201    /// than [`BULK_THRESHOLD`] bytes get stored behind an
202    /// `Arc<Box<[u8]>>` instead of a heap-backed `SmallBytes`. The Arc
203    /// lets the io_uring reactor's reply path borrow the bytes across
204    /// the SQE→CQE window safely (Arc clone keeps them alive even if
205    /// the keyspace mutates) — the prerequisite for the writev
206    /// zero-copy bulk reply path, which skips the per-GET memcpy from
207    /// value storage into the per-conn output buffer.
208    ///
209    /// **Why `Arc<Box<[u8]>>` and not `Arc<[u8]>`**: `Arc<[u8]>` is a
210    /// DST-backed `ArcInner<[u8]> = { strong, weak, [u8; N] }` whose
211    /// data slot sits past the refcount words. `Arc::from(Vec<u8>)`
212    /// allocates a fresh `ArcInner` and `copy_from_slice`s the bytes
213    /// — a hard mandatory 64 KiB memcpy on every big SET. With
214    /// `Arc<Box<[u8]>>`, the `Box<[u8]>` wrapper occupies the Arc's
215    /// data slot (16 B), pointing AT an unchanged heap buffer; so
216    /// `Arc::new(vec.into_boxed_slice())` is **truly zero-copy**
217    /// (the boxed slice's allocation stays put — only the 32-byte
218    /// `ArcInner` is freshly malloced). Per-GET cost: one extra
219    /// pointer dereference (`&**arc` to get `&[u8]`), measured to be
220    /// negligible vs the per-SET memcpy savings. The `Arc<[u8]>`
221    /// mandatory copy was confirmed with perf-record before switching
222    /// to `Arc<Box<[u8]>>`.
223    ///
224    /// Small values stay on `Str(SmallBytes)` because the inline
225    /// cache-line storage beats an Arc indirection for the common case.
226    ArcBulk(Arc<Box<[u8]>>),
227    Hash(Arc<HashData>),
228    List(Arc<ListData>),
229    Set(Arc<SetData>),
230    ZSet(Arc<ZSetData>),
231    Stream(Arc<crate::stream::StreamData>),
232    /// Valkey-orthodox encoding switch: tiny sets (1-N
233    /// short members) live inline in 24 bytes instead of behind
234    /// `Arc<SetData>` — matches valkey's `OBJ_ENCODING_LISTPACK` for
235    /// sets, which is what `redis-benchmark -t sadd` default `-r 0`
236    /// (cardinality stays at 1 forever, single 20-byte literal member)
237    /// measures. On overflow ([`crate::small_set::SmallSetData::try_add`]
238    /// returns `NoRoom`) the set is promoted to `Value::Set(Arc<SetData>)`
239    /// — the Swiss-table path that wins for larger cardinalities.
240    SmallSetInline(crate::small_set::SmallSetData),
241    /// Tiny hashes
242    /// (1-2 short field-value pairs) live inline in 24 bytes; promoted
243    /// to `Value::Hash(Arc<HashData>)` on overflow. Mirrors valkey's
244    /// `OBJ_ENCODING_LISTPACK` for hashes.
245    SmallHashInline(crate::small_hash::SmallHashData),
246    /// Tiny lists inline encoding; promoted to
247    /// `Value::List(Arc<ListData>)` on overflow.
248    SmallListInline(crate::small_list::SmallListData),
249    /// Tiny sorted sets inline encoding; promoted to
250    /// `Value::ZSet(Arc<ZSetData>)` on overflow.
251    SmallZSetInline(crate::small_zset::SmallZSetData),
252    /// A demoted (tiered-to-disk) value's in-map stub. The two-stage
253    /// funnel (`tier` module) resolves this before any typed match sees
254    /// it: stage 1 answers existence/TYPE/TTL from the stub with zero
255    /// IO; stage 2 materializes (serve or promote) only on a type
256    /// match. Cloning a `Cold` clones the STUB, not the record — paths
257    /// that duplicate values (COPY, cross-shard ship) materialize
258    /// first so two stubs never alias one vlog record.
259    Cold(ColdRef),
260}
261
262/// Threshold (bytes) above which a SET stores its value as
263/// [`Value::ArcBulk`] (writev-eligible on GET) instead of [`Value::Str`]
264/// (inline `SmallBytes`). 64 B ≈ one cache line — below that the
265/// inline-SmallBytes storage wins on L1 locality; above it the
266/// writev-borrow win dominates.
267pub const BULK_THRESHOLD: usize = 64;
268
269const _: () = {
270    // Don't let future variants undo box-collection's Entry-48B win.
271    assert!(core::mem::size_of::<Value>() <= 32);
272};
273
274/// Heap-size threshold above which an overwritten `Value` is sent to the
275/// runtime's bio thread for off-reactor drop instead of being freed inline
276/// (lazy-drop).
277///
278/// **Why not lower**: a 256 B threshold regressed c=50 -d 10240 SET
279/// p999 from 0.487 → 1.583 ms (worse by 3.25×). The cause: `std::sync::mpsc::Sender::send`
280/// is a few hundred ns of atomic + Box clone, which EXCEEDS the inline
281/// `Box::<[u8]>::drop` cost when the allocator serves the free from a
282/// hot large-class slab (~ 1-3 µs for 10 KB; the bench's steady state).
283/// Off-loading only wins when the inline drop's tail risk (cold-slab
284/// `munmap`/`madvise` consolidation stall, observed at 50-150 µs and
285/// occasionally millisecond-range) exceeds the per-send channel cost
286/// PLUS the cross-thread cache-line bouncing.
287///
288/// With per-shard batch accumulation flushing at the end of every
289/// reactor iteration, the per-mpsc-send cost is amortised across N
290/// drops. That makes the channel hop profitable at smaller sizes than
291/// a lone-send model could justify (lone-send had to lift the
292/// threshold to 16 KB because per-`mpsc::send` cost was a few hundred
293/// ns — at 256 B the inline drop was cheaper).
294///
295/// **Sweet-spot surprise**: intuition suggested dropping the threshold
296/// to 256 B – 1 KB once batching amortises the send. A sweep across
297/// thresholds {512, 1024, 4096, 16384} × c=50 SET -d {1K, 4K, 10K, 64K}
298/// disproved that floor: at ≤ 1 KB threshold, p999 / max on small
299/// values (-d 1024, -d 4096) was variance-bounded equal or
300/// occasionally WORSE than a 16 KB threshold, while the larger
301/// sizes (10 KB / 64 KB) won either way. Cause: the Vec::push +
302/// occasional `MAX_PENDING_DROPS` force-flush stall costs more for
303/// small Arcs (allocator small-class free is sub-µs even at tail)
304/// than the inline drop it avoids.
305///
306/// Picked **4 KB** as the lowest threshold where the bio-off-reactor
307/// win consistently dominates the batch-buffer overhead on tail
308/// metrics. The biggest batching wins (vs lone-send at 16 KB) land on
309/// `-d 64K` SET p50 (-44 %) and `-d 10K` SET max (-35 %), where each
310/// iter's batch already contains several heavy values per shard.
311pub const HEAP_HEAVY_BYTES: usize = 4 * 1024;
312
313/// Sender half of the runtime's bio-drop channel. Wired from
314/// `kevy-rt`'s `bio.rs` via [`crate::Store::set_bio_drop_sender`]; the
315/// concrete payload is `Vec<Value>` — a **batch** of values
316/// produced by one shard since its last flush.
317/// The bio thread (`kevy-rt::bio::spawn`) iterates the batch and
318/// drops each item. One mpsc message per shard-flush amortises the
319/// channel cost (atomic + cross-thread cacheline traffic) across
320/// however many values landed in the batch.
321#[cfg(feature = "std")]
322pub type BioDropSender = std::sync::mpsc::Sender<Vec<Value>>;
323
324impl Value {
325    /// The Redis type name (`TYPE` command).
326    pub fn type_name(&self) -> &'static str {
327        match self {
328            Value::Str(_) | Value::Int(_) | Value::ArcBulk(_) => "string",
329            Value::Hash(_) | Value::SmallHashInline(_) => "hash",
330            Value::List(_) | Value::SmallListInline(_) => "list",
331            Value::Set(_) | Value::SmallSetInline(_) => "set",
332            Value::ZSet(_) | Value::SmallZSetInline(_) => "zset",
333            Value::Stream(_) => "stream",
334            // Stage-1 funnel: TYPE (and SCAN's TYPE filter) answer from
335            // the tag — a cold key never pays a pread for its type.
336            Value::Cold(c) => c.type_name(),
337        }
338    }
339
340    /// Approximate heap bytes the value owns. Excludes the inline `Entry` /
341    /// bucket slot — that's a separate per-entry constant accounted by the
342    /// store. Walks collections, so prefer the cached `Entry::weight` for
343    /// hot-path accounting and only call this when bootstrapping or after a
344    /// load-from-snapshot.
345    pub fn weight(&self) -> u64 {
346        match self {
347            Value::Str(s) => s.heap_bytes() as u64,
348            // i64 fits in the enum tag's space; no heap.
349            Value::Int(_) => 0,
350            // Arc<[u8]> heap = the byte slice itself (refcount overhead
351            // is amortised across shared clones).
352            Value::ArcBulk(a) => a.len() as u64,
353            Value::Hash(h) => collection_overhead(h.capacity(), HASH_SLOT_BYTES) + h
354                .iter()
355                .map(|(f, v)| f.heap_bytes() as u64 + v.capacity() as u64)
356                .sum::<u64>(),
357            Value::List(l) => (l.capacity() as u64).saturating_mul(LIST_SLOT_BYTES)
358                + l.iter().map(|v| v.capacity() as u64).sum::<u64>(),
359            Value::Set(s) => collection_overhead(s.capacity(), SET_SLOT_BYTES) + s
360                .iter()
361                .map(|m| m.heap_bytes() as u64)
362                .sum::<u64>(),
363            // Inline collections live entirely in the Value variant
364            // body — zero heap, zero bucket overhead. Accounting matches
365            // `Value::Int` / inline `Value::Str` (both also return 0).
366            Value::SmallSetInline(_)
367            | Value::SmallHashInline(_)
368            | Value::SmallListInline(_)
369            | Value::SmallZSetInline(_) => 0,
370            // The stub owns no heap — its 24 bytes live inline in the
371            // Entry. The reclaimed value bytes are exactly the point:
372            // a cold key weighs key-heap + ENTRY_OVERHEAD only (B7).
373            Value::Cold(_) => 0,
374            // Each member's bytes live twice when they spill to heap (>22 B):
375            // once as the `by_member` key, once inside the rank tree's
376            // `(Score, SmallBytes)` key — hence the ×2 on `heap_bytes`.
377            // Members ≤22 B are inline in both slots (heap_bytes = 0).
378            Value::ZSet(z) => collection_overhead(z.by_member.capacity(), HASH_SLOT_BYTES)
379                + z.by_member
380                    .iter()
381                    .map(|(m, _)| 2 * m.heap_bytes() as u64)
382                    .sum::<u64>()
383                + (z.by_score.len() as u64).saturating_mul(RANKTREE_SLOT_BYTES),
384            Value::Stream(s) => s.weight(),
385        }
386    }
387
388    /// Whether this value's `Drop` is heavy enough to deserve being
389    /// shipped to the bio thread instead of freed inline. Fast: every
390    /// variant decides off a sub-field cheap to inspect (no recursive
391    /// walk), so it's safe to call on every overwrite-SET on the hot
392    /// path. The threshold is intentionally conservative — small Arcs
393    /// and every short string stay on inline-drop where jemalloc small-
394    /// class is sub-µs and a cross-thread hand-off would lose.
395    #[inline]
396    pub fn is_heap_heavy(&self) -> bool {
397        match self {
398            // Inline 22 B / heap ≤ small-class — fast to free inline.
399            Value::Str(_)
400            | Value::Int(_)
401            | Value::SmallSetInline(_)
402            | Value::SmallHashInline(_)
403            | Value::SmallListInline(_)
404            | Value::SmallZSetInline(_) => false,
405            // 24 inline bytes; dropping a stub frees nothing.
406            Value::Cold(_) => false,
407            // Lazy-drop's primary case: the large-value SET tail culprit.
408            Value::ArcBulk(a) => a.len() >= HEAP_HEAVY_BYTES,
409            // Collection drops walk every element + the bucket array;
410            // worst-case microseconds on a multi-KB hash/zset. Send to
411            // bio so a SET that overwrites a collection-typed key (the
412            // Redis polymorphic case) doesn't stall the reactor.
413            //
414            // The check uses `Arc::strong_count == 1` to avoid sending
415            // a still-shared Arc: another holder (a SnapshotView in
416            // flight, a same-shard live read) would force the bio
417            // thread to only do a refcount-decrement, which is wasted
418            // cross-thread traffic. A unique Arc IS the case where
419            // drop is expensive (it really frees the inner payload).
420            Value::Hash(a) => alloc::sync::Arc::strong_count(a) == 1 && !a.is_empty(),
421            Value::List(a) => alloc::sync::Arc::strong_count(a) == 1 && !a.is_empty(),
422            Value::Set(a) => alloc::sync::Arc::strong_count(a) == 1 && !a.is_empty(),
423            Value::ZSet(a) => {
424                alloc::sync::Arc::strong_count(a) == 1 && !a.by_member.is_empty()
425            }
426            Value::Stream(a) => {
427                alloc::sync::Arc::strong_count(a) == 1 && a.length() > 0
428            }
429        }
430    }
431}
432
433// `BioDropSender = mpsc::Sender<Box<Value>>` requires `Value: Send`. Static
434// assert: if a future variant inadvertently makes Value `!Send` (e.g. an
435// `Rc<...>` payload) this fails at compile time, BEFORE the runtime tries
436// to hand a value to the bio thread.
437const _: fn() = || {
438    fn assert_send<T: Send>() {}
439    assert_send::<Value>();
440};
441
442/// Per-bucket footprint for `KevyMap`/`KevySet`-backed collections (open-
443/// addressing Swiss table). Approximation, not exact: includes metadata byte
444/// per slot plus the boxed `K`/`V` cell, padded for 7/8 load factor.
445pub(crate) const HASH_SLOT_BYTES: u64 = 32;
446pub(crate) const SET_SLOT_BYTES: u64 = 24;
447/// `VecDeque` ring-buffer slot per stored `Vec<u8>` header (24 B Vec metadata).
448pub(crate) const LIST_SLOT_BYTES: u64 = 24;
449/// `BTreeSet`/`BTreeMap` per-entry overhead (node pointers + B-tree node
450/// padding) — the stream index's accounting constant.
451pub(crate) const BTREE_SLOT_BYTES: u64 = 40;
452/// `kevy_ranktree::RankTree` per-key overhead. Measured from the structure:
453/// the `(Score, SmallBytes)` key slot is 32 B; nodes hold ≤15 keys in a Vec
454/// whose buffer rounds to 16 slots at ~2/3 fill (≈10-11 live keys), so the
455/// key arrays amortise to ≈48 B per key; the per-node fixed cost (56 B
456/// header + Box allocation, ~1 node per 10 keys) and the internal nodes'
457/// child-pointer arrays add ≈8 B more. 64 errs slightly high (allocator
458/// size-class rounding), keeping `used_memory` a conservative upper bound —
459/// same policy as [`ENTRY_OVERHEAD`].
460pub(crate) const RANKTREE_SLOT_BYTES: u64 = 64;
461/// Per-entry overhead in the top-level keyspace map: the inline 24-byte
462/// `SmallBytes` key cell + the 64-byte `Entry` (post weight/clock fields) +
463/// metadata. Approximation that errs slightly high so `used_memory` stays a
464/// conservative upper bound vs the actual allocator footprint.
465pub const ENTRY_OVERHEAD: u64 = 96;
466
467#[inline]
468fn collection_overhead(capacity: usize, per_slot: u64) -> u64 {
469    (capacity as u64).saturating_mul(per_slot)
470}
471
472/// Per-field delta a new hash field charges against the entry weight: heap
473/// bytes for the field name (if not inline) + value capacity + one slot of
474/// bucket overhead. Used when an HSET inserts a brand-new field.
475#[inline]
476pub fn hash_field_weight(field: &SmallBytes, value_cap: usize) -> u64 {
477    field.heap_bytes() as u64 + value_cap as u64 + HASH_SLOT_BYTES
478}
479
480/// Per-member delta a new set member charges. Mirrors [`hash_field_weight`]
481/// for the set variant (no separate value, single bucket slot).
482#[inline]
483pub fn set_member_weight(member: &SmallBytes) -> u64 {
484    member.heap_bytes() as u64 + SET_SLOT_BYTES
485}
486
487/// Per-item delta a new list element charges (Vec header slot + heap cap).
488#[inline]
489pub fn list_item_weight(value_cap: usize) -> u64 {
490    LIST_SLOT_BYTES + value_cap as u64
491}
492
493/// Per-member delta a new zset member charges: hash slot for `by_member` +
494/// rank-tree slot for `by_score` + the member's heap bytes — twice, because
495/// a heap-spilling member (>22 B) is stored in both structures (inline
496/// members cost 0 here, matching [`Value::weight`]'s ZSet arm).
497#[inline]
498pub fn zset_member_weight(member: &SmallBytes) -> u64 {
499    2 * member.heap_bytes() as u64 + HASH_SLOT_BYTES + RANKTREE_SLOT_BYTES
500}