Skip to main content

kevy_store/
small_set.rs

1//! `SmallSetData` — valkey-style inline-listpack encoding for tiny sets.
2//!
3//! Mirrors valkey's `OBJ_ENCODING_LISTPACK` for sets of size
4//! 1–N (`t_set.c::setTypeMaybeConvert`). valkey starts a fresh set as a
5//! 1-entry listpack inside one cache line; once cardinality grows past
6//! `set-max-listpack-entries` (128 default) OR a single member exceeds the
7//! per-entry size cap, it converts to `OBJ_ENCODING_HT`. kevy's analogue:
8//! [`SmallSetData`] for tiny sets, upgrade to [`crate::value::SetData`]
9//! (Swiss-table `KevySet<SmallBytes>`) on overflow.
10//!
11//! ## Layout
12//!
13//! Exactly 24 bytes, mirroring [`kevy_bytes::SmallBytes`] so the
14//! `Value::SmallSetInline(SmallSetData)` variant body matches the size
15//! of `Value::Str(SmallBytes)` and the
16//! `assert!(size_of::<Value>() <= 32)` in `value.rs:162` still holds:
17//!
18//! ```text
19//! offset: 0    1                                 23
20//!         +----+----+----+----+----+ ...     +-----+
21//!         | n  | u  |       buf[22]              |
22//!         +----+----+----+----+----+ ...     +-----+
23//! ```
24//!
25//! - `n` (u8): member count (`0..=22`, capped well below to leave room).
26//! - `u` (u8): bytes used in `buf` (sum of `1 + len_i` over all members).
27//! - `buf` ([u8; 22]): packed `[len_i: u8][member_i: u8; len_i]` entries.
28//!
29//! Per-entry length prefix is one byte → a single member is at most 21
30//! bytes (need 1 byte for the length itself). For 20-byte
31//! `element:__rand_int__` (the redis-benchmark default SADD member shape),
32//! one entry consumes 21 bytes — fits with 1 spare. For shorter members,
33//! 4-5 fit comfortably.
34//!
35//! ## Upgrade trigger
36//!
37//! Insert returns [`AddResult::NoRoom`] when either (a) the new member's
38//! `1 + len` would overflow the 22-byte budget, or (b) the per-member
39//! length exceeds the 21-byte per-entry cap. The caller upgrades to
40//! `Value::Set(Arc<SetData>)` and re-inserts the new member there.
41//!
42//! Linear-scan `contains` over ≤ N members is faster than the
43//! hash-then-SIMD-probe path on a 16-slot Swiss table when N is small
44//! AND the data is one cache line — the same structural reason valkey's
45//! listpack beats `OBJ_ENCODING_HT` for N≤128.
46//!
47//! ## Future extension
48//!
49//! Hash / List / ZSet families want the same encoding switch. The
50//! pattern factored here:
51//! 1. Inline 24-byte packed-entries variant on `Value`.
52//! 2. Per-op `try_*` helper returns `AddResult { Added, AlreadyPresent,
53//!    NoRoom }` so the caller knows when to upgrade.
54//! 3. `account_delta` accepts the per-member weight delta uniformly; the
55//!    inline variant returns its own `inline_weight()` (zero heap) so the
56//!    Store's `used_memory` accounting stays consistent across both
57//!    encodings.
58//!
59//! The `Hash` analogue will need `[len_field][field][len_val][val]`
60//! tuples; List can use the same `[len][bytes]` shape; ZSet needs
61//! `[score:8][len][member]`. All three fit the 24-byte budget for the
62//! 1–3 member cases that dominate `redis-benchmark` default shapes.
63
64use kevy_bytes::SmallBytes;
65
66/// Inline packed set storage. 24 bytes total — see module docs for layout.
67#[derive(Clone)]
68pub struct SmallSetData {
69    /// Number of inline members (0..=22 cap, real ceiling is byte-budget).
70    count: u8,
71    /// Bytes used in `buf` so far (sum of `1 + member_len` per entry).
72    used: u8,
73    /// Packed `[len_i: u8][member_i; len_i]` entries, contiguous from
74    /// offset 0 up to `used`.
75    buf: [u8; SMALL_SET_BUF_CAP],
76}
77
78/// Byte budget for the inline packed entries area. Chosen so that
79/// `count(1) + used(1) + buf(22) = 24` bytes total, matching the
80/// `SmallBytes` body size and preserving `size_of::<Value>() <= 32`.
81pub(crate) const SMALL_SET_BUF_CAP: usize = 22;
82
83/// Per-member length cap: one byte is spent on the length prefix, so the
84/// member payload can be at most `SMALL_SET_BUF_CAP - 1` bytes. Members
85/// larger than this trigger an [`AddResult::NoRoom`] regardless of how
86/// empty the inline buffer is — the caller must upgrade to
87/// `Value::Set` to store them.
88pub(crate) const SMALL_SET_MEMBER_MAX: usize = SMALL_SET_BUF_CAP - 1;
89
90/// Per-set member count cap. The byte budget tends to hit first (a single
91/// 20-byte member takes 21 of 22 bytes), but a hard `count` cap keeps the
92/// linear scan deterministic and bounds the `u8` count field.
93pub(crate) const SMALL_SET_COUNT_MAX: usize = 8;
94
95/// Outcome of [`SmallSetData::try_add`].
96pub(crate) enum AddResult {
97    /// Member was new; count + used updated.
98    Added,
99    /// Member already present; no change.
100    AlreadyPresent,
101    /// Member doesn't fit (either too long or buffer full / count cap).
102    /// Caller must upgrade to `Value::Set` and re-insert.
103    NoRoom,
104}
105
106impl SmallSetData {
107    /// Build an empty inline set.
108    pub(crate) fn new() -> Self {
109        Self { count: 0, used: 0, buf: [0; SMALL_SET_BUF_CAP] }
110    }
111
112    /// Build an inline set holding one member, if it fits. Returns `None`
113    /// when the member exceeds [`SMALL_SET_MEMBER_MAX`] — the caller
114    /// should create a `Value::Set(Arc::default())` and insert the
115    /// member there instead.
116    pub(crate) fn with_one(member: &[u8]) -> Option<Self> {
117        if member.len() > SMALL_SET_MEMBER_MAX {
118            return None;
119        }
120        let mut s = Self::new();
121        s.buf[0] = member.len() as u8;
122        s.buf[1..1 + member.len()].copy_from_slice(member);
123        s.count = 1;
124        s.used = 1 + member.len() as u8;
125        Some(s)
126    }
127
128    /// Number of inline members.
129    pub fn len(&self) -> usize {
130        self.count as usize
131    }
132
133    /// Whether the inline set has no members.
134    pub fn is_empty(&self) -> bool {
135        self.count == 0
136    }
137
138    /// Linear scan for `member`. ≤22 bytes of packed entries fit in one
139    /// cache line; loop is unrolled by the optimiser at small counts.
140    pub fn contains(&self, member: &[u8]) -> bool {
141        self.iter_slices().any(|m| m == member)
142    }
143
144    /// Iterator over the packed entries as `&[u8]` slices. Owns nothing.
145    pub fn iter_slices(&self) -> SmallSetIter<'_> {
146        SmallSetIter { buf: &self.buf[..self.used as usize], cursor: 0 }
147    }
148
149    /// Alias for [`Self::iter_slices`] — matches the `iter` shape used by
150    /// other collection types in this crate.
151    pub fn iter(&self) -> SmallSetIter<'_> {
152        self.iter_slices()
153    }
154
155    /// Try to append `member`. See [`AddResult`].
156    pub(crate) fn try_add(&mut self, member: &[u8]) -> AddResult {
157        if self.contains(member) {
158            return AddResult::AlreadyPresent;
159        }
160        if member.len() > SMALL_SET_MEMBER_MAX {
161            return AddResult::NoRoom;
162        }
163        if self.count as usize >= SMALL_SET_COUNT_MAX {
164            return AddResult::NoRoom;
165        }
166        let need = 1 + member.len();
167        let new_used = self.used as usize + need;
168        if new_used > SMALL_SET_BUF_CAP {
169            return AddResult::NoRoom;
170        }
171        let off = self.used as usize;
172        self.buf[off] = member.len() as u8;
173        self.buf[off + 1..off + need].copy_from_slice(member);
174        self.used = new_used as u8;
175        self.count += 1;
176        AddResult::Added
177    }
178
179    /// Try to remove `member`. Returns whether it was present. On hit,
180    /// the trailing packed entries are shifted left by `1 + len` to
181    /// close the gap (deterministic O(used) memmove, fits in cache line).
182    pub(crate) fn try_remove(&mut self, member: &[u8]) -> bool {
183        let mut cursor = 0usize;
184        let used = self.used as usize;
185        while cursor < used {
186            let len = self.buf[cursor] as usize;
187            let start = cursor + 1;
188            let end = start + len;
189            if &self.buf[start..end] == member {
190                // Shift [end..used) → [cursor..)
191                self.buf.copy_within(end..used, cursor);
192                let shifted = used - end;
193                let new_used = cursor + shifted;
194                // Zero the freed tail (avoid leaking old member bytes
195                // into snapshots / accidental Debug prints).
196                self.buf[new_used..used].fill(0);
197                self.used = new_used as u8;
198                self.count -= 1;
199                return true;
200            }
201            cursor = end;
202        }
203        false
204    }
205}
206
207/// Iterator over [`SmallSetData`] members as `&[u8]` slices.
208pub struct SmallSetIter<'a> {
209    buf: &'a [u8],
210    cursor: usize,
211}
212
213impl<'a> Iterator for SmallSetIter<'a> {
214    type Item = &'a [u8];
215
216    fn next(&mut self) -> Option<&'a [u8]> {
217        if self.cursor >= self.buf.len() {
218            return None;
219        }
220        let len = self.buf[self.cursor] as usize;
221        let start = self.cursor + 1;
222        let end = start + len;
223        self.cursor = end;
224        Some(&self.buf[start..end])
225    }
226}
227
228/// Materialise the inline set as a heap-backed [`crate::value::SetData`].
229/// Used when an upgrade is forced by an oversized member or full buffer.
230pub(crate) fn promote(inline: &SmallSetData) -> crate::value::SetData {
231    let mut s = crate::value::SetData::with_capacity(inline.len().max(1));
232    for m in inline.iter_slices() {
233        s.insert(SmallBytes::from_slice(m));
234    }
235    s
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn size_is_24_bytes() {
244        // Mirrors SmallBytes' 24 B body so size_of::<Value>() <= 32 holds.
245        assert_eq!(core::mem::size_of::<SmallSetData>(), 24);
246    }
247
248    #[test]
249    fn empty_and_with_one() {
250        let s = SmallSetData::new();
251        assert_eq!(s.len(), 0);
252        assert!(!s.contains(b"foo"));
253
254        let s = SmallSetData::with_one(b"hi").unwrap();
255        assert_eq!(s.len(), 1);
256        assert!(s.contains(b"hi"));
257        assert!(!s.contains(b"hj"));
258    }
259
260    #[test]
261    fn member_too_long_for_with_one() {
262        let big = vec![b'x'; SMALL_SET_MEMBER_MAX + 1];
263        assert!(SmallSetData::with_one(&big).is_none());
264    }
265
266    #[test]
267    fn add_dedup_and_iter() {
268        let mut s = SmallSetData::new();
269        assert!(matches!(s.try_add(b"a"), AddResult::Added));
270        assert!(matches!(s.try_add(b"b"), AddResult::Added));
271        assert!(matches!(s.try_add(b"a"), AddResult::AlreadyPresent));
272        assert_eq!(s.len(), 2);
273        let v: Vec<&[u8]> = s.iter_slices().collect();
274        assert_eq!(v, vec![b"a".as_slice(), b"b".as_slice()]);
275    }
276
277    #[test]
278    fn full_buffer_returns_no_room() {
279        let mut s = SmallSetData::new();
280        // single 20-byte member uses 21 of 22 bytes; second won't fit.
281        let m1 = b"element:__rand_int__";
282        assert_eq!(m1.len(), 20);
283        assert!(matches!(s.try_add(m1), AddResult::Added));
284        assert!(matches!(s.try_add(b"x"), AddResult::NoRoom));
285    }
286
287    #[test]
288    fn member_too_long_returns_no_room() {
289        let mut s = SmallSetData::new();
290        let big = vec![b'x'; SMALL_SET_MEMBER_MAX + 1];
291        assert!(matches!(s.try_add(&big), AddResult::NoRoom));
292        assert_eq!(s.len(), 0);
293    }
294
295    #[test]
296    fn remove_middle_shifts_tail() {
297        let mut s = SmallSetData::new();
298        assert!(matches!(s.try_add(b"aa"), AddResult::Added));
299        assert!(matches!(s.try_add(b"bbb"), AddResult::Added));
300        assert!(matches!(s.try_add(b"cc"), AddResult::Added));
301        assert!(s.try_remove(b"bbb"));
302        assert_eq!(s.len(), 2);
303        assert!(s.contains(b"aa"));
304        assert!(!s.contains(b"bbb"));
305        assert!(s.contains(b"cc"));
306        let v: Vec<&[u8]> = s.iter_slices().collect();
307        assert_eq!(v, vec![b"aa".as_slice(), b"cc".as_slice()]);
308    }
309
310    #[test]
311    fn remove_absent_returns_false() {
312        let mut s = SmallSetData::new();
313        s.try_add(b"a");
314        assert!(!s.try_remove(b"zz"));
315        assert_eq!(s.len(), 1);
316    }
317
318    #[test]
319    fn count_cap_returns_no_room() {
320        let mut s = SmallSetData::new();
321        // 8 × 1-byte members fit in 16 bytes; 9th should hit the count cap
322        // before the byte cap.
323        for c in b"abcdefgh" {
324            assert!(matches!(s.try_add(&[*c]), AddResult::Added));
325        }
326        assert_eq!(s.len(), SMALL_SET_COUNT_MAX);
327        assert!(matches!(s.try_add(b"i"), AddResult::NoRoom));
328    }
329
330    #[test]
331    fn promote_preserves_members() {
332        let mut s = SmallSetData::new();
333        s.try_add(b"a");
334        s.try_add(b"bb");
335        s.try_add(b"ccc");
336        let promoted = promote(&s);
337        assert_eq!(promoted.len(), 3);
338        assert!(promoted.contains(b"a".as_slice()));
339        assert!(promoted.contains(b"bb".as_slice()));
340        assert!(promoted.contains(b"ccc".as_slice()));
341    }
342}