Skip to main content

kevy_store/
small_hash.rs

1//! `SmallHashData` — inline-listpack encoding for tiny hashes.
2//!
3//! Companion to [`crate::small_set::SmallSetData`] (v1.25 A.7 pilot).
4//! Mirrors valkey's `OBJ_ENCODING_LISTPACK` for hashes (`t_hash.c::
5//! hashTypeTryConversion`). For the `redis-benchmark -t hset` default
6//! shape (single field `field:__rand_int__` ~24 B + literal value
7//! `__rand_int__`), the structural prerequisite is "one (field, value)
8//! tuple in one cache line" — same lever as small_set.
9//!
10//! ## Layout — 24 bytes packed
11//!
12//! ```text
13//! offset: 0    1                                 23
14//!         +----+----+----+----+----+ ...     +-----+
15//!         | n  | u  |       buf[22]              |
16//!         +----+----+----+----+----+ ...     +-----+
17//! ```
18//!
19//! - `n` (u8): tuple count (`0..=COUNT_MAX`).
20//! - `u` (u8): bytes used in `buf` (sum of `2 + flen_i + vlen_i`).
21//! - `buf` ([u8; 22]): packed `[flen][field][vlen][value]` tuples.
22//!
23//! Two length prefix bytes per tuple → field + value share the 20-byte
24//! payload budget after the two prefix bytes. A 6-byte `field` +
25//! 12-byte value fits with 2 bytes spare. The redis-benchmark default
26//! `field` literal is `field:__rand_int__` (= 20 bytes); already
27//! overflows the inline buffer with the prefix overhead. The win is
28//! the **single very short field-value pair** workload — small struct-
29//! field hashes like `{"name": "alice"}`, which valkey's listpack also
30//! catches.
31//!
32//! ## Upgrade trigger
33//!
34//! `try_set` returns [`AddResult::NoRoom`] when (a) field len > cap, (b)
35//! value len > cap, (c) replacing-an-existing-field would overflow, or
36//! (d) appending a new pair would overflow the 22-byte budget. Caller
37//! upgrades to `Value::Hash(Arc<HashData>)` and re-inserts.
38
39use kevy_bytes::SmallBytes;
40
41/// Inline packed hash storage. 24 bytes total.
42#[derive(Clone)]
43pub struct SmallHashData {
44    count: u8,
45    used: u8,
46    buf: [u8; SMALL_HASH_BUF_CAP],
47}
48
49pub(crate) const SMALL_HASH_BUF_CAP: usize = 22;
50
51/// Per-field name cap. With one prefix byte the field can be at most
52/// `BUF_CAP - 3` bytes (leave 1 byte for value length and 1 byte for
53/// at least a 0-length value — caller may still NoRoom on value).
54pub(crate) const SMALL_HASH_FIELD_MAX: usize = SMALL_HASH_BUF_CAP - 3;
55
56/// Per-value cap mirroring the field cap.
57pub(crate) const SMALL_HASH_VALUE_MAX: usize = SMALL_HASH_BUF_CAP - 3;
58
59/// Hard count cap (defensive; byte budget usually bites first).
60pub(crate) const SMALL_HASH_COUNT_MAX: usize = 8;
61
62/// Outcome of [`SmallHashData::try_set`].
63pub(crate) enum AddResult {
64    /// Field was new; count + used updated.
65    Added,
66    /// Field existed; value was updated in place (or, if the new value
67    /// fits with no shift, written over the old one).
68    Updated,
69    /// Pair doesn't fit (oversized field/value or buffer full).
70    NoRoom,
71}
72
73impl SmallHashData {
74    pub(crate) fn new() -> Self {
75        Self { count: 0, used: 0, buf: [0; SMALL_HASH_BUF_CAP] }
76    }
77
78    /// Construct holding one `(field, value)` pair if it fits.
79    pub(crate) fn with_one(field: &[u8], value: &[u8]) -> Option<Self> {
80        if field.len() > SMALL_HASH_FIELD_MAX || value.len() > SMALL_HASH_VALUE_MAX {
81            return None;
82        }
83        let need = 2 + field.len() + value.len();
84        if need > SMALL_HASH_BUF_CAP {
85            return None;
86        }
87        let mut s = Self::new();
88        s.write_pair_at(0, field, value);
89        s.count = 1;
90        s.used = need as u8;
91        Some(s)
92    }
93
94    pub fn len(&self) -> usize {
95        self.count as usize
96    }
97
98    pub fn is_empty(&self) -> bool {
99        self.count == 0
100    }
101
102    /// Look up `field`; returns the value bytes if present.
103    pub fn get(&self, field: &[u8]) -> Option<&[u8]> {
104        for (f, v) in self.iter() {
105            if f == field {
106                return Some(v);
107            }
108        }
109        None
110    }
111
112    /// Does this small hash contain `field`?
113    pub fn contains_key(&self, field: &[u8]) -> bool {
114        self.get(field).is_some()
115    }
116
117    pub fn iter(&self) -> SmallHashIter<'_> {
118        SmallHashIter { buf: &self.buf[..self.used as usize], cursor: 0 }
119    }
120
121    /// Try to set `field -> value`. See [`AddResult`].
122    pub(crate) fn try_set(&mut self, field: &[u8], value: &[u8]) -> AddResult {
123        if field.len() > SMALL_HASH_FIELD_MAX || value.len() > SMALL_HASH_VALUE_MAX {
124            return AddResult::NoRoom;
125        }
126        // Existing field? Replace value (may need shift).
127        if let Some((off, flen, old_vlen)) = self.locate(field) {
128            let new_vlen = value.len();
129            let used = self.used as usize;
130            let val_off = off + 1 + flen + 1;
131            let old_end = val_off + old_vlen;
132            let delta: isize = new_vlen as isize - old_vlen as isize;
133            let new_used_i = used as isize + delta;
134            if new_used_i > SMALL_HASH_BUF_CAP as isize {
135                return AddResult::NoRoom;
136            }
137            let new_used = new_used_i as usize;
138            // Shift the tail to make room (or close the gap).
139            if delta != 0 {
140                self.buf.copy_within(old_end..used, (val_off + new_vlen) as usize);
141                if delta < 0 {
142                    // Zero freed tail.
143                    self.buf[new_used..used].fill(0);
144                }
145            }
146            self.buf[val_off - 1] = new_vlen as u8;
147            self.buf[val_off..val_off + new_vlen].copy_from_slice(value);
148            self.used = new_used as u8;
149            return AddResult::Updated;
150        }
151        // New field — append.
152        if self.count as usize >= SMALL_HASH_COUNT_MAX {
153            return AddResult::NoRoom;
154        }
155        let need = 2 + field.len() + value.len();
156        let new_used = self.used as usize + need;
157        if new_used > SMALL_HASH_BUF_CAP {
158            return AddResult::NoRoom;
159        }
160        let off = self.used as usize;
161        self.write_pair_at(off, field, value);
162        self.used = new_used as u8;
163        self.count += 1;
164        AddResult::Added
165    }
166
167    /// Try to remove `field`. Returns whether it was present.
168    pub(crate) fn try_remove(&mut self, field: &[u8]) -> bool {
169        let Some((off, flen, vlen)) = self.locate(field) else {
170            return false;
171        };
172        let used = self.used as usize;
173        let entry_end = off + 2 + flen + vlen;
174        self.buf.copy_within(entry_end..used, off);
175        let shifted = used - entry_end;
176        let new_used = off + shifted;
177        self.buf[new_used..used].fill(0);
178        self.used = new_used as u8;
179        self.count -= 1;
180        true
181    }
182
183    fn write_pair_at(&mut self, off: usize, field: &[u8], value: &[u8]) {
184        self.buf[off] = field.len() as u8;
185        let fstart = off + 1;
186        let fend = fstart + field.len();
187        self.buf[fstart..fend].copy_from_slice(field);
188        self.buf[fend] = value.len() as u8;
189        let vstart = fend + 1;
190        let vend = vstart + value.len();
191        self.buf[vstart..vend].copy_from_slice(value);
192    }
193
194    /// Returns (entry_offset, field_len, value_len) if `field` is present.
195    fn locate(&self, field: &[u8]) -> Option<(usize, usize, usize)> {
196        let mut cursor = 0usize;
197        let used = self.used as usize;
198        while cursor < used {
199            let flen = self.buf[cursor] as usize;
200            let fstart = cursor + 1;
201            let fend = fstart + flen;
202            let vlen = self.buf[fend] as usize;
203            if &self.buf[fstart..fend] == field {
204                return Some((cursor, flen, vlen));
205            }
206            cursor = fend + 1 + vlen;
207        }
208        None
209    }
210}
211
212/// Iterator over [`SmallHashData`] yielding `(&[u8] field, &[u8] value)`.
213pub struct SmallHashIter<'a> {
214    buf: &'a [u8],
215    cursor: usize,
216}
217
218impl<'a> Iterator for SmallHashIter<'a> {
219    type Item = (&'a [u8], &'a [u8]);
220    fn next(&mut self) -> Option<Self::Item> {
221        if self.cursor >= self.buf.len() {
222            return None;
223        }
224        let flen = self.buf[self.cursor] as usize;
225        let fstart = self.cursor + 1;
226        let fend = fstart + flen;
227        let vlen = self.buf[fend] as usize;
228        let vstart = fend + 1;
229        let vend = vstart + vlen;
230        self.cursor = vend;
231        Some((&self.buf[fstart..fend], &self.buf[vstart..vend]))
232    }
233}
234
235/// Materialise the inline hash as a heap-backed [`crate::value::HashData`].
236pub(crate) fn promote(inline: &SmallHashData) -> crate::value::HashData {
237    let mut h = crate::value::HashData::with_capacity(inline.len().max(1));
238    for (f, v) in inline.iter() {
239        h.insert(SmallBytes::from_slice(f), v.to_vec());
240    }
241    h
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn size_is_24_bytes() {
250        assert_eq!(std::mem::size_of::<SmallHashData>(), 24);
251    }
252
253    #[test]
254    fn with_one_and_get() {
255        let s = SmallHashData::with_one(b"name", b"alice").unwrap();
256        assert_eq!(s.len(), 1);
257        assert_eq!(s.get(b"name"), Some(b"alice".as_slice()));
258        assert!(s.contains_key(b"name"));
259        assert!(!s.contains_key(b"age"));
260    }
261
262    #[test]
263    fn with_one_too_big() {
264        let big = vec![b'x'; SMALL_HASH_FIELD_MAX + 1];
265        assert!(SmallHashData::with_one(&big, b"v").is_none());
266    }
267
268    #[test]
269    fn set_add_update_remove() {
270        let mut s = SmallHashData::new();
271        assert!(matches!(s.try_set(b"a", b"1"), AddResult::Added));
272        assert!(matches!(s.try_set(b"b", b"22"), AddResult::Added));
273        assert!(matches!(s.try_set(b"a", b"X"), AddResult::Updated));
274        assert_eq!(s.get(b"a"), Some(b"X".as_slice()));
275        assert_eq!(s.get(b"b"), Some(b"22".as_slice()));
276        assert!(s.try_remove(b"a"));
277        assert!(!s.contains_key(b"a"));
278        assert_eq!(s.get(b"b"), Some(b"22".as_slice()));
279    }
280
281    #[test]
282    fn set_no_room_overflow() {
283        let mut s = SmallHashData::new();
284        // 8-byte field + 10-byte value = 2+8+10 = 20 bytes used.
285        assert!(matches!(
286            s.try_set(b"fieldnam", b"valuevalue"),
287            AddResult::Added
288        ));
289        // Next pair won't fit.
290        assert!(matches!(s.try_set(b"more", b"data"), AddResult::NoRoom));
291    }
292
293    #[test]
294    fn update_value_grows_within_budget() {
295        let mut s = SmallHashData::new();
296        s.try_set(b"k", b"v");
297        // grow from 1 byte value to 8 bytes — fits the budget.
298        assert!(matches!(s.try_set(b"k", b"longerv!"), AddResult::Updated));
299        assert_eq!(s.get(b"k"), Some(b"longerv!".as_slice()));
300    }
301
302    #[test]
303    fn update_value_no_room() {
304        let mut s = SmallHashData::new();
305        // Fill near full.
306        s.try_set(b"a", b"x");
307        s.try_set(b"bbbbbbbb", b"yyyyyyyyyy"); // 8+10 = 18, +2 prefix = 20 + already 3 = 23 used? Let me reset.
308        // Reset more deterministically:
309        let mut s = SmallHashData::new();
310        s.try_set(b"abc", b"defghijk"); // 2+3+8=13
311        s.try_set(b"x", b"yzwuv"); // 2+1+5=8, total 21
312        // Try to grow `x`'s value by 2 → would push used to 23 > 22.
313        assert!(matches!(
314            s.try_set(b"x", b"yzwuvAB"),
315            AddResult::NoRoom
316        ));
317    }
318
319    #[test]
320    fn iter_in_insertion_order() {
321        let mut s = SmallHashData::new();
322        s.try_set(b"a", b"1");
323        s.try_set(b"b", b"2");
324        let v: Vec<(&[u8], &[u8])> = s.iter().collect();
325        assert_eq!(v, vec![(b"a".as_slice(), b"1".as_slice()), (b"b".as_slice(), b"2".as_slice())]);
326    }
327
328    #[test]
329    fn promote_preserves_pairs() {
330        let mut s = SmallHashData::new();
331        s.try_set(b"a", b"1");
332        s.try_set(b"bb", b"22");
333        let h = promote(&s);
334        assert_eq!(h.len(), 2);
335        assert_eq!(h.get(b"a".as_slice()).map(Vec::as_slice), Some(b"1".as_slice()));
336        assert_eq!(h.get(b"bb".as_slice()).map(Vec::as_slice), Some(b"22".as_slice()));
337    }
338}