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}