kevy_store/value_weight.rs
1//! `Value`'s two accounting questions: what does it weigh, and is freeing it
2//! heavy enough to hand to the bio thread.
3//!
4//! Split from `value.rs` for the file-length rule. Both are pure per-variant
5//! tables over the enum defined there, so they move as a pair and nothing in
6//! `value.rs` calls them.
7
8use crate::value::{
9 HASH_SLOT_BYTES, HEAP_HEAVY_BYTES, LIST_SLOT_BYTES, RANKTREE_SLOT_BYTES, SET_SLOT_BYTES, Value,
10 collection_overhead,
11};
12
13impl Value {
14 /// Approximate heap bytes the value owns. Excludes the inline `Entry` /
15 /// bucket slot — that's a separate per-entry constant accounted by the
16 /// store. Walks collections, so prefer the cached `Entry::weight` for
17 /// hot-path accounting and only call this when bootstrapping or after a
18 /// load-from-snapshot.
19 // LOC-WAIVER: pure per-variant weight table — one arm per stored encoding, no control flow
20 pub fn weight(&self) -> u64 {
21 match self {
22 Value::Str(s) => s.heap_bytes() as u64,
23 // i64 fits in the enum tag's space; no heap.
24 Value::Int(_) => 0,
25 // One payload buffer plus the boxed inner; the column names
26 // are the table's, shared by all its rows.
27 Value::PackedRow(r) => r.footprint(),
28 // Arc<[u8]> heap = the byte slice itself (refcount overhead
29 // is amortised across shared clones).
30 Value::ArcBulk(a) => a.len() as u64,
31 Value::Hash(h) => crate::hash_weight::flat_hash_weight(h),
32 Value::List(l) => {
33 (l.capacity() as u64).saturating_mul(LIST_SLOT_BYTES)
34 + l.iter().map(|v| v.capacity() as u64).sum::<u64>()
35 }
36 // Segments charge like flat lists; the outer deque-of-Arcs
37 // adds one pointer slot per segment.
38 Value::SegList(l) => {
39 (l.seg_count() as u64).saturating_mul(8)
40 + (l.len() as u64).saturating_mul(LIST_SLOT_BYTES)
41 + l.iter().map(|v| v.capacity() as u64).sum::<u64>()
42 }
43 Value::Set(s) => {
44 collection_overhead(s.capacity(), SET_SLOT_BYTES)
45 + s.iter().map(|m| m.heap_bytes() as u64).sum::<u64>()
46 }
47 Value::SegHash(h) => h.weight_as_hash(),
48 Value::SegSet(s) => s.weight_as_set(),
49 Value::SegZSet(z) => z.weight_as_zset(),
50 // Inline collections live entirely in the Value variant
51 // body — zero heap, zero bucket overhead. Accounting matches
52 // `Value::Int` / inline `Value::Str` (both also return 0).
53 Value::SmallSetInline(_)
54 | Value::SmallHashInline(_)
55 | Value::SmallListInline(_)
56 | Value::SmallZSetInline(_) => 0,
57 // The stub owns no heap — its 24 bytes live inline in the
58 // Entry. The reclaimed value bytes are exactly the point:
59 // a cold key weighs key-heap + ENTRY_OVERHEAD only (B7).
60 Value::Cold(_) => 0,
61 // Each member's bytes live twice when they spill to heap (>22 B):
62 // once as the `by_member` key, once inside the rank tree's
63 // `(Score, SmallBytes)` key — hence the ×2 on `heap_bytes`.
64 // Members ≤23 B are inline in both slots (heap_bytes = 0).
65 Value::ZSet(z) => {
66 collection_overhead(z.by_member.capacity(), HASH_SLOT_BYTES)
67 + z.by_member.iter().map(|(m, _)| 2 * m.heap_bytes() as u64).sum::<u64>()
68 + (z.by_score.len() as u64).saturating_mul(RANKTREE_SLOT_BYTES)
69 }
70 Value::Stream(s) => s.weight(),
71 }
72 }
73
74 /// Whether this value's `Drop` is heavy enough to deserve being
75 /// shipped to the bio thread instead of freed inline. Fast: every
76 /// variant decides off a sub-field cheap to inspect (no recursive
77 /// walk), so it's safe to call on every overwrite-SET on the hot
78 /// path. The threshold is intentionally conservative — small Arcs
79 /// and every short string stay on inline-drop where jemalloc small-
80 /// class is sub-µs and a cross-thread hand-off would lose.
81 #[inline]
82 // LOC-WAIVER: pure per-variant predicate table — one arm per stored encoding, no control flow
83 pub fn is_heap_heavy(&self) -> bool {
84 match self {
85 // Inline 23 B / heap ≤ small-class — fast to free inline.
86 Value::Str(_)
87 | Value::Int(_)
88 | Value::SmallSetInline(_)
89 | Value::SmallHashInline(_)
90 | Value::SmallListInline(_)
91 | Value::SmallZSetInline(_) => false,
92 // 24 inline bytes; dropping a stub frees nothing.
93 Value::Cold(_) => false,
94 // Lazy-drop's primary case: the large-value SET tail culprit.
95 Value::ArcBulk(a) => a.len() >= HEAP_HEAVY_BYTES,
96 // One buffer: the same size test, one deallocation.
97 Value::PackedRow(r) => r.heap_bytes() >= HEAP_HEAVY_BYTES,
98 // Collection drops walk every element + the bucket array;
99 // worst-case microseconds on a multi-KB hash/zset. Send to
100 // bio so a SET that overwrites a collection-typed key (the
101 // Redis polymorphic case) doesn't stall the reactor.
102 //
103 // The check uses `Arc::strong_count == 1` to avoid sending
104 // a still-shared Arc: another holder (a SnapshotView in
105 // flight, a same-shard live read) would force the bio
106 // thread to only do a refcount-decrement, which is wasted
107 // cross-thread traffic. A unique Arc IS the case where
108 // drop is expensive (it really frees the inner payload).
109 Value::Hash(a) => alloc::sync::Arc::strong_count(a) == 1 && !a.is_empty(),
110 Value::SegHash(a) => {
111 alloc::sync::Arc::strong_count(a) == 1 && !a.is_empty() && a.all_unique()
112 }
113 Value::SegSet(a) => {
114 alloc::sync::Arc::strong_count(a) == 1 && !a.is_empty() && a.all_unique()
115 }
116 Value::SegZSet(a) => {
117 alloc::sync::Arc::strong_count(a) == 1 && !a.is_empty() && a.all_unique()
118 }
119 Value::List(a) => alloc::sync::Arc::strong_count(a) == 1 && !a.is_empty(),
120 // Bio-drop only pays off when the drop really frees: outer
121 // AND every segment unique. A view-shared SegList's drop is
122 // refcount decrements — cheap enough inline.
123 Value::SegList(a) => {
124 alloc::sync::Arc::strong_count(a) == 1 && !a.is_empty() && a.all_unique()
125 }
126 Value::Set(a) => alloc::sync::Arc::strong_count(a) == 1 && !a.is_empty(),
127 Value::ZSet(a) => alloc::sync::Arc::strong_count(a) == 1 && !a.by_member.is_empty(),
128 Value::Stream(a) => alloc::sync::Arc::strong_count(a) == 1 && a.length() > 0,
129 }
130 }
131}