Skip to main content

kevy_store/
small_zset.rs

1//! `SmallZSetData` — inline-listpack encoding for tiny sorted sets.
2//!
3//! Companion to [`crate::small_set::SmallSetData`]. Mirrors valkey's
4//! `OBJ_ENCODING_LISTPACK` for zsets (`t_zset.c::zsetTypeMaybeConvert`).
5//! Each tuple is `[score: f64 (8 B)][len: u8][member]` — 9 bytes of
6//! overhead before any member bytes.
7//!
8//! The 22-byte budget thus fits at most 1 (9 + len ≤ 22 → len ≤ 13)
9//! member if it's short, or up to 2 members for very short ones.
10//! For the `redis-benchmark -t zadd` default literal-member shape
11//! (`element:__rand_int__`, 20 bytes), only the **single-element**
12//! ZADD pattern fits inline (9 + 1 + 12 = 22 — exact fit). The first
13//! ZADD on a fresh key wins, second triggers promotion to KevyMap +
14//! BTreeSet.
15//!
16//! Members are NOT sorted in the inline buffer; we only have at most
17//! a few entries, and `iter()`/`zrange()` simply scans them. When
18//! promotion happens, the BTreeSet path takes over and applies the
19//! correct ordering.
20//!
21//! ## Layout — 24 bytes packed
22//!
23//! ```text
24//! offset: 0    1                                 23
25//!         +----+----+----+----+ ... +----+ ... +-----+
26//!         | n  | u  |  buf[22]                       |
27//!         +----+----+----+----+----+ ...     +-----+
28//! ```
29//!
30//! - `n` (u8): tuple count.
31//! - `u` (u8): bytes used (sum of `9 + len_i`).
32//! - `buf` ([u8; 22]): packed `[score:8][len:1][member:len]` entries.
33
34/// Inline packed sorted-set storage. 24 bytes total.
35#[derive(Clone)]
36pub struct SmallZSetData {
37    count: u8,
38    used: u8,
39    buf: [u8; SMALL_ZSET_BUF_CAP],
40}
41
42pub(crate) const SMALL_ZSET_BUF_CAP: usize = 22;
43/// Per-member cap: buf_cap minus the 9-byte (score + length prefix)
44/// fixed overhead.
45pub(crate) const SMALL_ZSET_MEMBER_MAX: usize = SMALL_ZSET_BUF_CAP - 9;
46pub(crate) const SMALL_ZSET_COUNT_MAX: usize = 2;
47
48/// Outcome of [`SmallZSetData::try_set`].
49pub(crate) enum AddResult {
50    /// Member was new — count + used updated.
51    Added,
52    /// Member existed — score was updated in place.
53    Updated,
54    /// Doesn't fit (oversized member, count cap, or budget overflow).
55    NoRoom,
56}
57
58impl SmallZSetData {
59    pub(crate) fn new() -> Self {
60        Self { count: 0, used: 0, buf: [0; SMALL_ZSET_BUF_CAP] }
61    }
62
63    pub(crate) fn with_one(member: &[u8], score: f64) -> Option<Self> {
64        if member.len() > SMALL_ZSET_MEMBER_MAX {
65            return None;
66        }
67        let mut s = Self::new();
68        s.write_pair_at(0, member, score);
69        s.count = 1;
70        s.used = (9 + member.len()) as u8;
71        Some(s)
72    }
73
74    pub fn len(&self) -> usize {
75        self.count as usize
76    }
77
78    pub fn is_empty(&self) -> bool {
79        self.count == 0
80    }
81
82    /// Look up `member`'s score.
83    pub fn score(&self, member: &[u8]) -> Option<f64> {
84        for (m, s) in self.iter() {
85            if m == member {
86                return Some(s);
87            }
88        }
89        None
90    }
91
92    /// Does the inline zset contain `member`?
93    pub fn contains(&self, member: &[u8]) -> bool {
94        self.score(member).is_some()
95    }
96
97    /// Iterator yielding `(&[u8] member, f64 score)` in insertion order.
98    pub fn iter(&self) -> SmallZSetIter<'_> {
99        SmallZSetIter { buf: &self.buf[..self.used as usize], cursor: 0 }
100    }
101
102    /// Try to set `member -> score`. See [`AddResult`].
103    pub(crate) fn try_set(&mut self, member: &[u8], score: f64) -> AddResult {
104        if member.len() > SMALL_ZSET_MEMBER_MAX {
105            return AddResult::NoRoom;
106        }
107        if let Some(off) = self.locate(member) {
108            // In-place score update: rewrite the 8-byte score prefix.
109            self.buf[off..off + 8].copy_from_slice(&score.to_bits().to_le_bytes());
110            return AddResult::Updated;
111        }
112        if self.count as usize >= SMALL_ZSET_COUNT_MAX {
113            return AddResult::NoRoom;
114        }
115        let need = 9 + member.len();
116        let new_used = self.used as usize + need;
117        if new_used > SMALL_ZSET_BUF_CAP {
118            return AddResult::NoRoom;
119        }
120        let off = self.used as usize;
121        self.write_pair_at(off, member, score);
122        self.used = new_used as u8;
123        self.count += 1;
124        AddResult::Added
125    }
126
127    /// Try to remove `member`. Returns whether it was present.
128    pub(crate) fn try_remove(&mut self, member: &[u8]) -> bool {
129        let Some(off) = self.locate(member) else {
130            return false;
131        };
132        let used = self.used as usize;
133        let len = self.buf[off + 8] as usize;
134        let entry_end = off + 9 + len;
135        self.buf.copy_within(entry_end..used, off);
136        let shifted = used - entry_end;
137        let new_used = off + shifted;
138        self.buf[new_used..used].fill(0);
139        self.used = new_used as u8;
140        self.count -= 1;
141        true
142    }
143
144    fn write_pair_at(&mut self, off: usize, member: &[u8], score: f64) {
145        self.buf[off..off + 8].copy_from_slice(&score.to_bits().to_le_bytes());
146        self.buf[off + 8] = member.len() as u8;
147        let mstart = off + 9;
148        let mend = mstart + member.len();
149        self.buf[mstart..mend].copy_from_slice(member);
150    }
151
152    /// Returns entry offset (start of score) if present.
153    fn locate(&self, member: &[u8]) -> Option<usize> {
154        let mut cursor = 0usize;
155        let used = self.used as usize;
156        while cursor < used {
157            let len = self.buf[cursor + 8] as usize;
158            let mstart = cursor + 9;
159            let mend = mstart + len;
160            if &self.buf[mstart..mend] == member {
161                return Some(cursor);
162            }
163            cursor = mend;
164        }
165        None
166    }
167}
168
169/// Iterator over [`SmallZSetData`] yielding `(member, score)`.
170pub struct SmallZSetIter<'a> {
171    buf: &'a [u8],
172    cursor: usize,
173}
174
175impl<'a> Iterator for SmallZSetIter<'a> {
176    type Item = (&'a [u8], f64);
177    fn next(&mut self) -> Option<Self::Item> {
178        if self.cursor >= self.buf.len() {
179            return None;
180        }
181        let mut score_bytes = [0u8; 8];
182        score_bytes.copy_from_slice(&self.buf[self.cursor..self.cursor + 8]);
183        let score = f64::from_bits(u64::from_le_bytes(score_bytes));
184        let len = self.buf[self.cursor + 8] as usize;
185        let mstart = self.cursor + 9;
186        let mend = mstart + len;
187        self.cursor = mend;
188        Some((&self.buf[mstart..mend], score))
189    }
190}
191
192/// Materialise the inline zset as a heap-backed [`crate::value::ZSetData`].
193pub(crate) fn promote(inline: &SmallZSetData) -> crate::value::ZSetData {
194    let mut z = crate::value::ZSetData::default();
195    for (m, sc) in inline.iter() {
196        z.insert(m, sc);
197    }
198    z
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn size_is_24_bytes() {
207        assert_eq!(std::mem::size_of::<SmallZSetData>(), 24);
208    }
209
210    #[test]
211    fn add_and_score() {
212        let mut z = SmallZSetData::new();
213        assert!(matches!(z.try_set(b"a", 1.0), AddResult::Added));
214        assert!(matches!(z.try_set(b"b", 2.5), AddResult::Added));
215        assert_eq!(z.score(b"a"), Some(1.0));
216        assert_eq!(z.score(b"b"), Some(2.5));
217        assert!(z.contains(b"a"));
218        assert!(!z.contains(b"c"));
219    }
220
221    #[test]
222    fn update_in_place() {
223        let mut z = SmallZSetData::new();
224        z.try_set(b"a", 1.0);
225        assert!(matches!(z.try_set(b"a", 9.5), AddResult::Updated));
226        assert_eq!(z.score(b"a"), Some(9.5));
227        assert_eq!(z.len(), 1);
228    }
229
230    #[test]
231    fn count_cap() {
232        let mut z = SmallZSetData::new();
233        z.try_set(b"a", 1.0);
234        z.try_set(b"b", 2.0);
235        // Count cap = 2, third add should NoRoom.
236        assert!(matches!(z.try_set(b"c", 3.0), AddResult::NoRoom));
237    }
238
239    #[test]
240    fn member_too_long() {
241        let mut z = SmallZSetData::new();
242        let big = vec![b'x'; SMALL_ZSET_MEMBER_MAX + 1];
243        assert!(matches!(z.try_set(&big, 1.0), AddResult::NoRoom));
244    }
245
246    #[test]
247    fn budget_overflow() {
248        let mut z = SmallZSetData::new();
249        // 1 member at 13 bytes uses 9+13=22 of 22.
250        let m1 = vec![b'a'; SMALL_ZSET_MEMBER_MAX];
251        assert!(matches!(z.try_set(&m1, 1.0), AddResult::Added));
252        // Second member: even 1-byte member needs 10 bytes → NoRoom.
253        assert!(matches!(z.try_set(b"b", 2.0), AddResult::NoRoom));
254    }
255
256    #[test]
257    fn remove_works() {
258        let mut z = SmallZSetData::new();
259        z.try_set(b"a", 1.0);
260        z.try_set(b"b", 2.0);
261        assert!(z.try_remove(b"a"));
262        assert!(!z.contains(b"a"));
263        assert_eq!(z.score(b"b"), Some(2.0));
264        assert_eq!(z.len(), 1);
265    }
266
267    #[test]
268    fn iter_order() {
269        let mut z = SmallZSetData::new();
270        z.try_set(b"b", 2.0);
271        z.try_set(b"a", 1.0);
272        let v: Vec<(&[u8], f64)> = z.iter().collect();
273        assert_eq!(v[0], (b"b".as_slice(), 2.0));
274        assert_eq!(v[1], (b"a".as_slice(), 1.0));
275    }
276
277    #[test]
278    fn promote_preserves_pairs() {
279        let mut z = SmallZSetData::new();
280        z.try_set(b"a", 1.0);
281        z.try_set(b"b", 2.0);
282        let zd = promote(&z);
283        assert_eq!(zd.len(), 2);
284    }
285}