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}