Skip to main content

kevy_store/
lib.rs

1//! kevy-store — the keyspace.
2//!
3//! A single-threaded, multi-type keyspace with lazy expiration. Each Redis data
4//! type is backed by a modern `std` structure — behaviour-compatible, but **not**
5//! Redis's legacy encodings:
6//!
7//! | Type | Backing structure |
8//! |------|-------------------|
9//! | String | `Vec<u8>` |
10//! | Hash / Set | `HashMap` / `HashSet` (hashbrown Swiss table) |
11//! | List | `VecDeque` (ring buffer, O(1) ends) |
12//! | Sorted set | `HashMap` + `BTreeSet<(score, member)>` (a B-tree, not a skiplist) |
13//!
14//! Wrong-type access returns [`StoreError::WrongType`]. The API is `&mut self`
15//! and lock-free, so a thread-per-core runtime ([kevy-rt]) can own one shard per
16//! core with no locking. Part of the [kevy] key–value server.
17//!
18//! `maxmemory` enforcement + 8 eviction policies live in [`evict`]; toggle via
19//! [`Store::set_max_memory`]. With `maxmemory == 0` (the default) the hot-path
20//! cost collapses to a single predicted-not-taken branch, matching the
21//! "unlimited" mode in Redis byte-for-byte.
22//!
23//! [kevy]: https://crates.io/crates/kevy
24//! [kevy-rt]: https://crates.io/crates/kevy-rt
25//!
26//! # Example
27//!
28//! ```
29//! use kevy_store::Store;
30//!
31//! let mut s = Store::new();
32//! s.set(b"greeting", b"hello".to_vec(), None, false, false);
33//! assert_eq!(s.get(b"greeting").unwrap(), Some(&b"hello"[..]));
34//!
35//! s.hset(b"user:1", &[(b"name".to_vec(), b"alice".to_vec())]).unwrap();
36//! assert_eq!(s.hget(b"user:1", b"name").unwrap(), Some(&b"alice"[..]));
37//!
38//! // A string command on a hash key is a type error, as in Redis.
39//! assert_eq!(s.get(b"user:1"), Err(kevy_store::StoreError::WrongType));
40//! ```
41#![forbid(unsafe_code)]
42
43mod accounting;
44pub mod evict;
45pub mod expire;
46pub use expire::ExpireStats;
47mod hash;
48mod keyspace;
49mod list;
50mod set;
51mod string;
52mod util;
53mod value;
54mod zset;
55pub use util::glob_match;
56pub use value::*;
57
58use kevy_map::KevyMap;
59use std::num::NonZeroU64;
60use std::sync::OnceLock;
61use std::time::{Duration, Instant};
62
63/// Process-start anchor: every `Entry::expire_at_ns` is a nanosecond
64/// offset from this `Instant`, encoded as `Option<NonZeroU64>` so the
65/// niche optimisation lets the field cost 8 bytes (vs 16 for a bare
66/// `Option<Instant>`). 584-year range from process start — Y2538-proof.
67fn epoch() -> Instant {
68    static EPOCH: OnceLock<Instant> = OnceLock::new();
69    *EPOCH.get_or_init(Instant::now)
70}
71
72/// Encode an absolute `Instant` as ns-since-process-start. Returns `None`
73/// when `t == epoch()` exactly (sentinel collision); in practice an entry
74/// inserted at exactly t=0 from process start with TTL=0 is the only path
75/// there, and TTL=0 isn't a valid expiry the API ever takes.
76#[inline]
77fn pack_deadline(t: Instant) -> Option<NonZeroU64> {
78    let ns = t.saturating_duration_since(epoch()).as_nanos() as u64;
79    NonZeroU64::new(ns)
80}
81
82/// Decode a packed deadline back into an `Instant` for the rare paths
83/// (`pttl`, snapshot dump) that need real-clock math.
84#[inline]
85fn unpack_deadline(ns: NonZeroU64) -> Instant {
86    epoch() + Duration::from_nanos(ns.get())
87}
88
89/// Per-entry weight ceiling — the field is `u32` so accounting saturates
90/// at 4 GiB per entry. Real-world Redis values are well below this; the
91/// ceiling only matters when a single hash / list / zset exceeds 4 GiB,
92/// in which case `MEMORY USAGE` and the maxmemory accounting under-
93/// report that one entry by the overflow amount. Acceptable v1.0 tradeoff
94/// — keeps `Entry` at 48 bytes (vs 56 if we kept `u64`).
95const WEIGHT_MAX: u32 = u32::MAX;
96
97/// Per-key entry — packed to 48 bytes (vs 64 in the original
98/// `Value + Option<Instant> + u64 weight + u32 clock + 4 pad` layout):
99///
100/// - `value`: 32 bytes (boxed-collection enum).
101/// - `expire_at_ns`: `Option<NonZeroU64>` = ns since process start.
102///   Niche optimisation makes this 8 bytes, not the 16 a bare
103///   `Option<Instant>` would cost.
104/// - `weight`: `u32`. Cached `key.heap_bytes() + value.weight()` for
105///   O(1) eviction & `MEMORY USAGE`. Saturates at 4 GiB per entry.
106/// - `lru_clock`: `u32`. LRU = monotonic op counter; LFU = packed
107///   `[16-bit decay-tick | 8-bit log-counter]`. Only updated when
108///   `Store::maxmemory > 0`.
109///
110/// Storage saving over the original layout: 16 bytes per entry = 25 %.
111/// For a 1 M-key shard that's ~16 MB of RSS back.
112pub(crate) struct Entry {
113    pub(crate) value: Value,
114    pub(crate) expire_at_ns: Option<NonZeroU64>,
115    pub(crate) weight: u32,
116    pub(crate) lru_clock: u32,
117}
118
119impl Entry {
120    /// Build a fresh entry with weight + lru_clock uninitialised (the
121    /// caller — usually [`Store::insert_entry`] — will compute and stamp them).
122    #[inline]
123    pub(crate) fn new(value: Value, expire_at: Option<Instant>) -> Self {
124        Self {
125            value,
126            expire_at_ns: expire_at.and_then(pack_deadline),
127            weight: 0,
128            lru_clock: 0,
129        }
130    }
131
132    /// Cached entry weight as a `u64` for arithmetic uniformity with the
133    /// `Store::used_memory: u64` accumulator. Zero-cost cast.
134    #[inline]
135    pub(crate) fn weight(&self) -> u64 {
136        self.weight as u64
137    }
138
139    /// LRU / LFU clock value (eviction-only).
140    #[inline]
141    pub(crate) fn lru_clock(&self) -> u32 {
142        self.lru_clock
143    }
144
145    /// Overwrite the cached weight, saturating at the 4 GiB ceiling.
146    #[inline]
147    pub(crate) fn set_weight(&mut self, w: u64) {
148        self.weight = w.min(WEIGHT_MAX as u64) as u32;
149    }
150
151    /// Overwrite the LRU/LFU clock field.
152    #[inline]
153    pub(crate) fn set_lru_clock(&mut self, c: u32) {
154        self.lru_clock = c;
155    }
156
157    /// Apply a signed delta to the cached weight (saturating both directions).
158    #[inline]
159    pub(crate) fn add_to_weight(&mut self, delta: i64) {
160        if delta == 0 {
161            return;
162        }
163        let cur = self.weight as u64;
164        let new = if delta >= 0 {
165            cur.saturating_add(delta as u64)
166        } else {
167            cur.saturating_sub((-delta) as u64)
168        };
169        self.weight = new.min(WEIGHT_MAX as u64) as u32;
170    }
171
172    /// Is the entry past its deadline as of `now`? `None` deadline =
173    /// never. Combines the two-step compare into one branch on the
174    /// niche-optimised `Option`.
175    #[inline]
176    pub(crate) fn is_expired_at(&self, now: Instant) -> bool {
177        match self.expire_at_ns {
178            None => false,
179            Some(ns) => unpack_deadline(ns) <= now,
180        }
181    }
182}
183
184// Pin the Entry layout: 32 (Value) + 8 (expire_at_ns, niche-opt) + 8 (packed)
185// = 48 bytes. Any padding regression (e.g. someone re-adding a 4-byte field
186// without packing) is caught at compile time.
187const _: () = {
188    assert!(std::mem::size_of::<Entry>() == 48);
189};
190
191/// Operation errors surfaced to the command layer.
192#[derive(Debug, PartialEq, Eq)]
193pub enum StoreError {
194    /// Key holds a different type than the command expects.
195    WrongType,
196    /// Value is not a base-10 integer (INCR family).
197    NotInteger,
198    /// Result would overflow `i64`.
199    Overflow,
200    /// Index outside the collection (LSET).
201    OutOfRange,
202    /// Key does not exist where the command requires one (LSET).
203    NoSuchKey,
204    /// Value is not a valid float (INCRBYFLOAT).
205    NotFloat,
206    /// `maxmemory` would be exceeded and the active eviction policy is
207    /// [`EvictionPolicy::NoEviction`]. Surfaces as Redis's classic OOM error
208    /// at the RESP layer.
209    OutOfMemory,
210}
211
212/// Maxmemory eviction policy. Mirror of `kevy_config::EvictionPolicy` —
213/// duplicated here so `kevy-store` stays a leaf crate (no `kevy-config` dep).
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
215pub enum EvictionPolicy {
216    /// Refuse writes once `maxmemory` is hit. Default.
217    #[default]
218    NoEviction,
219    /// Approximated LRU across all keys.
220    AllKeysLru,
221    /// Approximated LFU across all keys.
222    AllKeysLfu,
223    /// Random key across all keys.
224    AllKeysRandom,
225    /// Approximated LRU across keys with a TTL.
226    VolatileLru,
227    /// Approximated LFU across keys with a TTL.
228    VolatileLfu,
229    /// Random key from those with a TTL.
230    VolatileRandom,
231    /// Key with the shortest remaining TTL.
232    VolatileTtl,
233}
234
235impl EvictionPolicy {
236    /// Whether the policy ranks candidates by LRU clock (read-touches matter).
237    #[inline]
238    pub fn uses_lru(self) -> bool {
239        matches!(self, Self::AllKeysLru | Self::VolatileLru)
240    }
241
242    /// Whether the policy ranks candidates by LFU counter (read-touches and
243    /// log-counter increments matter).
244    #[inline]
245    pub fn uses_lfu(self) -> bool {
246        matches!(self, Self::AllKeysLfu | Self::VolatileLfu)
247    }
248
249    /// Whether the policy restricts eviction to keys that carry a TTL.
250    #[inline]
251    pub fn is_volatile(self) -> bool {
252        matches!(
253            self,
254            Self::VolatileLru | Self::VolatileLfu | Self::VolatileRandom | Self::VolatileTtl
255        )
256    }
257}
258
259/// A single-database keyspace.
260///
261/// The keyspace map is a [`KevyMap`] — a pure-Rust open-addressing Swiss
262/// table tuned for kevy's per-shard, single-trust-domain keyspace. The
263/// hasher is [`kevy_hash::KevyHash`] (one-call inlinable; no DoS hardening
264/// since the shard is single-threaded with no cross-trust keys). Owning the
265/// table also exposes bucket addresses for software prefetch on the batch
266/// driver.
267#[derive(Default)]
268pub struct Store {
269    pub(crate) map: KevyMap<SmallBytes, Entry>,
270    /// Live byte estimate (dynamic per-entry weights + [`ENTRY_OVERHEAD`] per
271    /// key). Compared against [`Self::maxmemory`] to drive eviction.
272    pub(crate) used_memory: u64,
273    /// Soft byte ceiling. `0` = unlimited; the entire accounting + eviction
274    /// machinery short-circuits to a single not-taken branch in that case.
275    pub(crate) maxmemory: u64,
276    /// Active eviction policy. Only consulted when `used_memory > maxmemory`.
277    pub(crate) eviction_policy: EvictionPolicy,
278    /// Total keys evicted by [`Self::try_evict_after_write`] — surfaced via
279    /// `INFO memory` / `MEMORY STATS`.
280    pub(crate) evictions_total: u64,
281    /// Monotonic access counter; the upper 32 bits are unused, the lower 32
282    /// stamp `Entry::lru_clock` on each access while eviction is enabled.
283    pub(crate) clock_counter: u64,
284    /// `used_memory` peak across the shard's lifetime; surfaced as
285    /// `used_memory_peak` in `INFO memory`.
286    pub(crate) used_memory_peak: u64,
287    /// Keys expired since startup (lazy reap path AND
288    /// [`Self::tick_expire`]). Surfaced via `INFO keyspace` / `MEMORY STATS`
289    /// once those fields land.
290    pub(crate) expired_keys_total: u64,
291}
292
293impl Store {
294    pub fn new() -> Self {
295        Store::default()
296    }
297
298    /// Install (or clear, with `maxmemory == 0`) the eviction limit and
299    /// policy. Cheap; safe to call repeatedly (e.g. on `CONFIG SET`).
300    #[inline]
301    pub fn set_max_memory(&mut self, maxmemory: u64, policy: EvictionPolicy) {
302        self.maxmemory = maxmemory;
303        self.eviction_policy = policy;
304    }
305
306    /// Live byte estimate (see field doc).
307    #[inline]
308    pub fn used_memory(&self) -> u64 {
309        self.used_memory
310    }
311
312    /// `used_memory` high-water mark since startup.
313    #[inline]
314    pub fn used_memory_peak(&self) -> u64 {
315        self.used_memory_peak
316    }
317
318    /// Configured `maxmemory` (0 = unlimited).
319    #[inline]
320    pub fn maxmemory(&self) -> u64 {
321        self.maxmemory
322    }
323
324    /// Configured eviction policy.
325    #[inline]
326    pub fn eviction_policy(&self) -> EvictionPolicy {
327        self.eviction_policy
328    }
329
330    /// Total keys evicted since startup.
331    #[inline]
332    pub fn evictions_total(&self) -> u64 {
333        self.evictions_total
334    }
335
336    /// Cached weight of `key` (dynamic part + [`ENTRY_OVERHEAD`]). Returns
337    /// `None` when the key is absent or expired (no implicit reap).
338    pub fn estimate_key_bytes(&self, key: &[u8]) -> Option<u64> {
339        self.map.get(key).map(|e| e.weight() + ENTRY_OVERHEAD)
340    }
341
342    /// O(1) precondition check the dispatch layer calls before every write
343    /// command. Returns `Err(OutOfMemory)` only when `maxmemory > 0`, the
344    /// budget is already over, AND the policy is `NoEviction` (Redis
345    /// behaviour). All other policies let the write proceed and recover via
346    /// [`Self::try_evict_after_write`].
347    #[inline]
348    pub fn precheck_for_write(&self) -> Result<(), StoreError> {
349        if self.maxmemory == 0 || self.used_memory <= self.maxmemory {
350            return Ok(());
351        }
352        if self.eviction_policy == EvictionPolicy::NoEviction {
353            return Err(StoreError::OutOfMemory);
354        }
355        Ok(())
356    }
357
358    /// Run after every write command. No-op when disabled or under budget;
359    /// otherwise samples per [`Self::eviction_policy`] and removes keys until
360    /// back under `maxmemory` or no eligible candidate remains. Returns the
361    /// number of keys evicted (0 on the common fast path).
362    #[inline]
363    pub fn try_evict_after_write(&mut self) -> usize {
364        if self.maxmemory == 0 || self.used_memory <= self.maxmemory {
365            return 0;
366        }
367        evict::evict_until_under_limit(self)
368    }
369
370}
371
372/// Apply a signed delta to a `u64` (saturating both directions). Used by
373/// `Store::account_delta` / `reweigh_entry` so the in-place mutators don't
374/// have to repeat the same overflow-guarded match.
375#[inline]
376pub(crate) fn apply_delta(v: &mut u64, delta: i64) {
377    if delta >= 0 {
378        *v = v.saturating_add(delta as u64);
379    } else {
380        *v = v.saturating_sub((-delta) as u64);
381    }
382}
383
384/// Heap bytes a `SmallBytes`-encoded key would own. Mirrors
385/// `SmallBytes::heap_bytes` but takes `&[u8]` so the helper is reachable from
386/// places that don't yet have the typed `SmallBytes` (e.g. `reweigh_entry`).
387/// The 22-byte inline boundary is shared with the `kevy-bytes` crate.
388#[inline]
389pub(crate) fn key_heap_bytes_for(key: &[u8]) -> u64 {
390    if key.len() <= 22 { 0 } else { key.len() as u64 }
391}
392
393#[cfg(test)]
394mod tests;