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