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//! use std::borrow::Cow;
32//! let mut s = Store::new();
33//! s.set(b"greeting", b"hello".to_vec(), None, false, false);
34//! assert_eq!(s.get(b"greeting").unwrap(), Some(Cow::Borrowed(&b"hello"[..])));
35//!
36//! s.hset(b"user:1", &[(b"name".to_vec(), b"alice".to_vec())]).unwrap();
37//! assert_eq!(s.hget(b"user:1", b"name").unwrap(), Some(&b"alice"[..]));
38//!
39//! // A string command on a hash key is a type error, as in Redis.
40//! assert_eq!(s.get(b"user:1"), Err(kevy_store::StoreError::WrongType));
41//! ```
42#![forbid(unsafe_code)]
43
44mod accounting;
45mod bitmap;
46mod clock;
47mod entry;
48pub mod evict;
49pub mod expire;
50pub use expire::ExpireStats;
51pub(crate) use entry::Entry;
52mod hash;
53mod keyspace;
54mod list;
55mod list_ops;
56mod set;
57mod small_set;
58pub use small_set::{SmallSetData, SmallSetIter};
59mod small_hash;
60pub use small_hash::{SmallHashData, SmallHashIter};
61mod small_list;
62pub use small_list::{SmallListData, SmallListIter};
63mod small_zset;
64pub use small_zset::{SmallZSetData, SmallZSetIter};
65mod snapshot;
66pub use snapshot::SnapshotView;
67mod stream;
68mod string;
69mod string_rmw;
70mod util;
71mod value;
72mod zset;
73pub use stream::{
74    AutoclaimResult, ConsumerGroup, ConsumerState, EntryBatch, GroupCreateMode,
75    LoadedGroup, LoadedPelEntry, LoadedStreamEntry, PelEntry, PendingExtended,
76    PendingExtendedRow, PendingSummary, ReadGroupId, StreamData, StreamId, StreamIdError,
77    XAddIdSpec, XClaimOpts, now_unix_ms, parse_explicit_id, parse_range_end,
78    parse_range_start, parse_xadd_id,
79};
80pub use string::GetReply;
81pub use util::glob_match;
82pub use value::*;
83
84pub(crate) use clock::{deadline_at, now_ns, pack_deadline, remaining_ms};
85use kevy_map::KevyMap;
86
87/// Feed kevy's monotonic clock on `wasm32-unknown-unknown`, which has no
88/// `Instant`. The embedding host advances time (ns since an arbitrary fixed
89/// epoch, e.g. `Date.now() * 1e6`) before TTL-sensitive ops and once per
90/// reaper tick. No-op concept on native targets, where the OS clock is the
91/// source — hence wasm-only.
92#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
93pub use clock::set_clock_ns;
94/// Feed kevy's wall clock (Unix-epoch millis, e.g. `Date.now()`) on
95/// `wasm32-unknown-unknown`, where `SystemTime::now()` traps. Used by `XADD`
96/// auto-IDs and `EXPIREAT`/`PEXPIREAT`.
97#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
98pub use clock::set_wall_clock_ms;
99
100
101/// Outcome of [`Store::rename`] — three-way result so the dispatch
102/// layer can pick the right RESP frame (`+OK` / `-ERR no such key` /
103/// `:0` for `RENAMENX`-with-existing-dst).
104#[derive(Debug, PartialEq, Eq)]
105pub enum RenameOutcome {
106    /// Source removed, destination created (overwriting any prior dst).
107    Renamed,
108    /// Source key doesn't exist.
109    NoSuchSrc,
110    /// `RENAMENX` only — destination already exists, no rename done.
111    DstExists,
112}
113
114/// Operation errors surfaced to the command layer.
115#[derive(Debug, PartialEq, Eq)]
116pub enum StoreError {
117    /// Key holds a different type than the command expects.
118    WrongType,
119    /// Value is not a base-10 integer (INCR family).
120    NotInteger,
121    /// Result would overflow `i64`.
122    Overflow,
123    /// Index outside the collection (LSET).
124    OutOfRange,
125    /// Key does not exist where the command requires one (LSET).
126    NoSuchKey,
127    /// Value is not a valid float (INCRBYFLOAT).
128    NotFloat,
129    /// `maxmemory` would be exceeded and the active eviction policy is
130    /// [`EvictionPolicy::NoEviction`]. Surfaces as Redis's classic OOM error
131    /// at the RESP layer.
132    OutOfMemory,
133}
134
135/// Maxmemory eviction policy. Mirror of `kevy_config::EvictionPolicy` —
136/// duplicated here so `kevy-store` stays a leaf crate (no `kevy-config` dep).
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
138pub enum EvictionPolicy {
139    /// Refuse writes once `maxmemory` is hit. Default.
140    #[default]
141    NoEviction,
142    /// Approximated LRU across all keys.
143    AllKeysLru,
144    /// Approximated LFU across all keys.
145    AllKeysLfu,
146    /// Random key across all keys.
147    AllKeysRandom,
148    /// Approximated LRU across keys with a TTL.
149    VolatileLru,
150    /// Approximated LFU across keys with a TTL.
151    VolatileLfu,
152    /// Random key from those with a TTL.
153    VolatileRandom,
154    /// Key with the shortest remaining TTL.
155    VolatileTtl,
156}
157
158impl EvictionPolicy {
159    /// Whether the policy ranks candidates by LRU clock (read-touches matter).
160    #[inline]
161    pub fn uses_lru(self) -> bool {
162        matches!(self, Self::AllKeysLru | Self::VolatileLru)
163    }
164
165    /// Whether the policy ranks candidates by LFU counter (read-touches and
166    /// log-counter increments matter).
167    #[inline]
168    pub fn uses_lfu(self) -> bool {
169        matches!(self, Self::AllKeysLfu | Self::VolatileLfu)
170    }
171
172    /// Whether the policy restricts eviction to keys that carry a TTL.
173    #[inline]
174    pub fn is_volatile(self) -> bool {
175        matches!(
176            self,
177            Self::VolatileLru | Self::VolatileLfu | Self::VolatileRandom | Self::VolatileTtl
178        )
179    }
180}
181
182/// A single-database keyspace.
183///
184/// The keyspace map is a [`KevyMap`] — a pure-Rust open-addressing Swiss
185/// table tuned for kevy's per-shard, single-trust-domain keyspace. The
186/// hasher is [`kevy_hash::KevyHash`] (one-call inlinable; no DoS hardening
187/// since the shard is single-threaded with no cross-trust keys). Owning the
188/// table also exposes bucket addresses for software prefetch on the batch
189/// driver.
190#[derive(Default)]
191pub struct Store {
192    pub(crate) map: KevyMap<SmallBytes, Entry>,
193    /// Coarse cached monotonic clock (ns since [`epoch`]), refreshed by the
194    /// reactor loop / reaper tick via [`Self::refresh_clock`]. Lazy expiry on
195    /// the read path (`live_entry`) compares deadlines against this instead of
196    /// calling `Instant::now()` per access — the Redis cached-`mstime` model.
197    /// `0` (the `Default`) reads as "epoch" → keys look live until the first
198    /// refresh, the safe direction (expires at most one refresh-interval late,
199    /// never early — writes stamp deadlines from a *fresh* clock).
200    pub(crate) cached_ns: u64,
201    /// Whether lazy expiry trusts `Self::cached_ns` (set by a reactor/reaper
202    /// that calls [`Self::refresh_clock`]) instead of reading a fresh clock per
203    /// access. Enabled by the server reactor and the embedded background
204    /// reaper; left `false` (the `Default`) for manual-reaper / bare-`Store`
205    /// use, where nothing refreshes the cache so each access reads fresh —
206    /// preserving "lazy expiry works without an explicit tick".
207    pub(crate) cached_clock: bool,
208    /// Live byte estimate (dynamic per-entry weights + [`ENTRY_OVERHEAD`] per
209    /// key). Compared against [`Self::maxmemory`] to drive eviction.
210    pub(crate) used_memory: u64,
211    /// Soft byte ceiling. `0` = unlimited; the entire accounting + eviction
212    /// machinery short-circuits to a single not-taken branch in that case.
213    pub(crate) maxmemory: u64,
214    /// Active eviction policy. Only consulted when `used_memory > maxmemory`.
215    pub(crate) eviction_policy: EvictionPolicy,
216    /// Total keys evicted by [`Self::try_evict_after_write`] — surfaced via
217    /// `INFO memory` / `MEMORY STATS`.
218    pub(crate) evictions_total: u64,
219    /// Monotonic access counter; the upper 32 bits are unused, the lower 32
220    /// stamp `Entry::lru_clock` on each access while eviction is enabled.
221    pub(crate) clock_counter: u64,
222    /// `used_memory` peak across the shard's lifetime; surfaced as
223    /// `used_memory_peak` in `INFO memory`.
224    pub(crate) used_memory_peak: u64,
225    /// Keys expired since startup (lazy reap path AND
226    /// [`Self::tick_expire`]). Surfaced via `INFO keyspace` / `MEMORY STATS`
227    /// once those fields land.
228    pub(crate) expired_keys_total: u64,
229    /// Count of live keys carrying a TTL — the size of Redis's "expire set"
230    /// (`INFO keyspace`'s `expires=`). Maintained in O(1) at every TTL
231    /// transition (`insert_entry` / `remove_entry` deltas + the in-place
232    /// EXPIRE / PERSIST / SET sites) so the gauge never pays an O(n) keyspace
233    /// scan; [`Self::ttl_pending_count`] is the O(n) ground truth used to
234    /// assert this counter never drifts.
235    pub(crate) expires: u64,
236    /// `WATCH` version counters — present only for keys that have been
237    /// `WATCH`-ed at least once. [`Self::record_watch`] inserts the entry
238    /// (version 0 = "never written since first watch"); every subsequent
239    /// write on this shard calls [`Self::bump_if_watched`] which increments
240    /// only if the key is present in the map. Keys never `WATCH`-ed pay
241    /// one empty-map hashmap lookup per write (~10 ns).
242    ///
243    /// The map grows monotonically — entries are never evicted, even
244    /// when no conn is currently watching the key. For high-key-churn
245    /// workloads this can become a memory item; v1.x acceptable since
246    /// the entry is `Vec<u8>` + `u64` (~ 30 B + key length) and only
247    /// touched on writes / WATCH calls.
248    pub(crate) watch_versions: std::collections::HashMap<Vec<u8>, u64>,
249    /// Optional handle to the runtime's bio thread (v1.25 A.3). Set by
250    /// `kevy-rt::Runtime::run` via [`Self::set_bio_drop_sender`] before
251    /// the shard reactor loop starts. `None` = inline drop (bare-Store
252    /// embedders, snapshots-loader programs, the test harness — anything
253    /// without a kevy-rt runtime around it). Reads on the hot path are
254    /// one `Option::as_ref` branch; the steady-state inline-drop path
255    /// pays nothing beyond that branch.
256    pub(crate) bio_drop_sender: Option<value::BioDropSender>,
257    /// v1.25 A.2 batch-send buffer. Heavy `Value`s displaced by SET
258    /// overwrites accumulate here instead of paying one mpsc send per
259    /// drop; flushed in one `mpsc::Sender::send` at the end of every
260    /// reactor iteration (via [`Self::flush_pending_drops`], invoked
261    /// from `kevy-rt`'s epoll + io_uring reactor loops before the AOF
262    /// fsync window). Amortising the channel cost over N drops lets
263    /// the heap-heavy threshold sit at 1 KB — small enough that the
264    /// Axis I 256 B – 16 KB SET tail benefits, big enough that
265    /// sub-µs small-class drops still go inline (the push + flush
266    /// branch would cost more than the inline free).
267    ///
268    /// **Latency window**: drops sit in this buffer ≤ one reactor
269    /// iteration (10s of µs at busy-poll, ≤ park-timeout at idle —
270    /// 50 ms by default). On a reactor with no traffic the buffer
271    /// stays small (no new SETs to displace anything); on a reactor
272    /// with sustained writes the per-iter flush fires fast enough
273    /// that worst-case stall is bounded by `MAX_PENDING_DROPS`.
274    ///
275    /// **Bounded growth**: at `MAX_PENDING_DROPS` items the
276    /// `maybe_offload_drop` path force-flushes — protects against
277    /// pathological "thousand SETs in one iter never flush" cases
278    /// (would otherwise hold thousands of Box<Value>s in RAM until
279    /// the iter ends).
280    pub(crate) pending_drops: Vec<Box<Value>>,
281}
282
283/// Maximum [`Store::pending_drops`] depth before forcing a flush
284/// inside `maybe_offload_drop` (rather than waiting for the reactor's
285/// per-iter `flush_pending_drops`). Caps memory held in the batch
286/// buffer at ≤ 64 × sizeof(Box<Value>) (≤ 512 B of pointers + whatever
287/// the boxed payloads weigh — which we WANT to ship anyway, since
288/// holding the bio-bound batch defeats the point of off-reactor frees).
289/// 64 picked as: amortises mpsc send cost (~few hundred ns) across
290/// enough drops that per-drop overhead is ≤ 10 ns, while staying small
291/// enough that worst-case bunch-up latency at the bio thread is bounded.
292pub(crate) const MAX_PENDING_DROPS: usize = 64;
293
294impl Store {
295    pub fn new() -> Self {
296        Store::default()
297    }
298
299    /// Refresh the coarse cached clock (`Self::cached_ns`) from a single
300    /// `Instant::now()`. Call once per reactor-loop batch / reaper tick; the
301    /// per-access read path then skips its own clock read. Lazy expiry is
302    /// coarse to this cadence (a key expires ≤ one refresh-interval late,
303    /// never early — writes stamp deadlines from a fresh clock).
304    #[inline]
305    pub fn refresh_clock(&mut self) {
306        self.cached_ns = now_ns();
307    }
308
309    /// Enable/disable trusting the cached clock for lazy expiry (see
310    /// `Self::cached_ns`). Call with `true` only when something refreshes the
311    /// clock regularly (the server reactor per batch, the embedded background
312    /// reaper per tick); leave `false` for manual-reaper mode. Seeds the cache
313    /// when enabling so the first access is accurate.
314    #[inline]
315    pub fn set_cached_clock(&mut self, on: bool) {
316        self.cached_clock = on;
317        if on {
318            self.refresh_clock();
319        }
320    }
321
322    /// Install (or clear, with `maxmemory == 0`) the eviction limit and
323    /// policy. Cheap; safe to call repeatedly (e.g. on `CONFIG SET`).
324    #[inline]
325    pub fn set_max_memory(&mut self, maxmemory: u64, policy: EvictionPolicy) {
326        self.maxmemory = maxmemory;
327        self.eviction_policy = policy;
328    }
329
330    /// Install the runtime's bio-drop channel (v1.25 A.3 + A.2). Called
331    /// once from `kevy-rt::Runtime::run` per shard before the reactor
332    /// loop starts. After install, [`Self::maybe_offload_drop`] (invoked
333    /// from the SET overwrite fast path) accumulates oversize `Value`s
334    /// into a per-shard batch; the reactor calls
335    /// [`Self::flush_pending_drops`] at the end of every iter to ship
336    /// the batch in one mpsc send. Bounded the Axis I 10 KB SET p999/max
337    /// blow-up that synchronous `Box::<[u8]>::drop` of a jemalloc
338    /// large-class slot caused (see `kevy_rt::bio`).
339    #[inline]
340    pub fn set_bio_drop_sender(&mut self, sender: value::BioDropSender) {
341        self.bio_drop_sender = Some(sender);
342    }
343
344    /// Accumulate `old` into the per-shard bio-drop batch buffer
345    /// ([`Store::pending_drops`]) if it's heap-heavy AND a bio channel
346    /// is installed. Otherwise drop inline. The hot path is one branch
347    /// on `bio_drop_sender.is_none()` followed by the variant-cheap
348    /// [`Value::is_heap_heavy`] check; for the `Value::Str(SmallBytes)`
349    /// steady state of typical bench shapes the inline-drop path is
350    /// preserved unchanged.
351    ///
352    /// **v1.25 A.2 batch model**: per-send mpsc cost (atomic +
353    /// cross-thread cacheline) is amortised across the batch by
354    /// [`Self::flush_pending_drops`], which the reactor calls once per
355    /// iter. Force-flushes here when the buffer hits
356    /// [`MAX_PENDING_DROPS`] to bound RAM in-flight.
357    #[inline]
358    pub(crate) fn maybe_offload_drop(&mut self, old: Value) {
359        if self.bio_drop_sender.is_none() {
360            // No channel (bare Store / embedded reaper / tests): the
361            // Value falls out of scope and drops inline. Same
362            // behaviour as v1.24.
363            drop(old);
364            return;
365        }
366        if !old.is_heap_heavy() {
367            // Under-threshold: jemalloc small-class free is sub-µs.
368            // The Vec::push + force-flush branch costs more than the
369            // inline free for this size — leave it inline.
370            drop(old);
371            return;
372        }
373        self.pending_drops.push(Box::new(old));
374        if self.pending_drops.len() >= MAX_PENDING_DROPS {
375            self.flush_pending_drops();
376        }
377    }
378
379    /// Ship the per-shard bio-drop batch buffer to the bio thread in
380    /// one mpsc send. Called from `kevy-rt`'s reactor loop at the end
381    /// of every iteration (both the epoll `Shard::run` and the io_uring
382    /// `Shard::run_uring` paths, just before the AOF fsync window so a
383    /// pending fsync stall doesn't pin a batch-ful of heavy values in
384    /// per-shard memory).
385    ///
386    /// Empty-buffer fast path: zero work, predictable not-taken
387    /// branch. Reactor calls this unconditionally per iter; the steady-
388    /// state cost for a no-SET-overwrite iter is one length check.
389    ///
390    /// `SendError` here means the bio thread has exited (shutdown
391    /// territory — `Runtime::run` has dropped its sender AFTER the
392    /// shard threads joined). Drop the batch inline; the `SendError`
393    /// payload carries the `Vec` back so its `Box<Value>`s run their
394    /// Drop here, preserving correctness.
395    #[inline]
396    pub fn flush_pending_drops(&mut self) {
397        if self.pending_drops.is_empty() {
398            return;
399        }
400        let tx = match self.bio_drop_sender.as_ref() {
401            Some(tx) => tx,
402            // Shouldn't happen — caller (`maybe_offload_drop`) only
403            // pushes when the sender exists. Defensive: if a future
404            // refactor invokes `flush_pending_drops` from somewhere
405            // unconditional, drop the batch inline.
406            None => {
407                self.pending_drops.clear();
408                return;
409            }
410        };
411        let batch = std::mem::take(&mut self.pending_drops);
412        if let Err(_send_err) = tx.send(batch) {
413            // Bio thread is gone (shutdown). The SendError carries
414            // the Vec, which drops here — every Box<Value> runs its
415            // Drop inline. Benign one-time stall during tear-down.
416        }
417    }
418
419    /// Live byte estimate (see field doc).
420    #[inline]
421    pub fn used_memory(&self) -> u64 {
422        self.used_memory
423    }
424
425    /// `used_memory` high-water mark since startup.
426    #[inline]
427    pub fn used_memory_peak(&self) -> u64 {
428        self.used_memory_peak
429    }
430
431    /// Configured `maxmemory` (0 = unlimited).
432    #[inline]
433    pub fn maxmemory(&self) -> u64 {
434        self.maxmemory
435    }
436
437    /// Configured eviction policy.
438    #[inline]
439    pub fn eviction_policy(&self) -> EvictionPolicy {
440        self.eviction_policy
441    }
442
443    /// Total keys evicted since startup.
444    #[inline]
445    pub fn evictions_total(&self) -> u64 {
446        self.evictions_total
447    }
448
449    /// Live keys carrying a TTL (`INFO keyspace`'s `expires=`). O(1) — reads
450    /// the maintained counter, not an O(n) scan (cf. [`Self::ttl_pending_count`]).
451    #[inline]
452    pub fn expires_count(&self) -> usize {
453        self.expires as usize
454    }
455
456    /// Apply a signed delta to the [`Self::expires`] counter, clamped at 0.
457    /// Centralises the saturating arithmetic for every TTL-transition site.
458    #[inline]
459    pub(crate) fn adjust_expires(&mut self, delta: i64) {
460        if delta != 0 {
461            self.expires = (self.expires as i64 + delta).max(0) as u64;
462        }
463    }
464
465    /// `WATCH` — record this key in the version tracker and return its
466    /// current version. Subsequent writes on this shard bump the version
467    /// via [`Self::bump_if_watched`]. Caller (the conn's origin shard)
468    /// stores the returned version; `EXEC` later asks every owning shard
469    /// "is the version still N?" via [`Self::key_version`].
470    ///
471    /// Keys that have never been written stay at version 0 — the first
472    /// write after a `WATCH` bumps to 1, which is what makes the "dirty"
473    /// comparison work (stored 0 ≠ current 1 ⇒ abort EXEC).
474    pub fn record_watch(&mut self, key: &[u8]) -> u64 {
475        *self
476            .watch_versions
477            .entry(key.to_vec())
478            .or_insert(0)
479    }
480
481    /// Read-only version lookup used by `EXEC`'s pre-execution check.
482    /// Returns `0` for keys never `WATCH`-ed (matches the initial value
483    /// `record_watch` would have inserted, so a `WATCH` → no-write →
484    /// `EXEC` sequence sees the stored 0 == current 0 and proceeds).
485    #[inline]
486    pub fn key_version(&self, key: &[u8]) -> u64 {
487        self.watch_versions.get(key).copied().unwrap_or(0)
488    }
489
490    /// Bump the version of `key` if (and only if) it has been `WATCH`-ed at
491    /// least once. Write-side call after every mutation. The empty check
492    /// runs BEFORE the key is hashed — the common nothing-watched case
493    /// pays one branch, not a guaranteed-miss probe.
494    #[inline]
495    pub fn bump_if_watched(&mut self, key: &[u8]) {
496        if self.watch_versions.is_empty() {
497            return;
498        }
499        if let Some(v) = self.watch_versions.get_mut(key) {
500            *v = v.wrapping_add(1);
501        }
502    }
503
504    /// Invalidate every watched key in one shot. Called from `FLUSHDB`
505    /// / `FLUSHALL` execution paths — every WATCH against this shard
506    /// must invalidate so a pending `EXEC` aborts.
507    pub fn bump_all_watched(&mut self) {
508        for v in self.watch_versions.values_mut() {
509            *v = v.wrapping_add(1);
510        }
511    }
512
513    /// Cached weight of `key` (dynamic part + [`ENTRY_OVERHEAD`]). Returns
514    /// `None` when the key is absent or expired (no implicit reap).
515    pub fn estimate_key_bytes(&self, key: &[u8]) -> Option<u64> {
516        self.map.get(key).map(|e| e.weight() + ENTRY_OVERHEAD)
517    }
518
519    /// O(1) precondition check the dispatch layer calls before every write
520    /// command. Returns `Err(OutOfMemory)` only when `maxmemory > 0`, the
521    /// budget is already over, AND the policy is `NoEviction` (Redis
522    /// behaviour). All other policies let the write proceed and recover via
523    /// [`Self::try_evict_after_write`].
524    #[inline]
525    pub fn precheck_for_write(&self) -> Result<(), StoreError> {
526        if self.maxmemory == 0 || self.used_memory <= self.maxmemory {
527            return Ok(());
528        }
529        if self.eviction_policy == EvictionPolicy::NoEviction {
530            return Err(StoreError::OutOfMemory);
531        }
532        Ok(())
533    }
534
535    /// Run after every write command. No-op when disabled or under budget;
536    /// otherwise samples per [`Self::eviction_policy`] and removes keys until
537    /// back under `maxmemory` or no eligible candidate remains. Returns the
538    /// number of keys evicted (0 on the common fast path).
539    #[inline]
540    pub fn try_evict_after_write(&mut self) -> usize {
541        if self.maxmemory == 0 || self.used_memory <= self.maxmemory {
542            return 0;
543        }
544        evict::evict_until_under_limit(self)
545    }
546
547}
548
549/// Apply a signed delta to a `u64` (saturating both directions). Used by
550/// `Store::account_delta` / `reweigh_entry` so the in-place mutators don't
551/// have to repeat the same overflow-guarded match.
552#[inline]
553pub(crate) fn apply_delta(v: &mut u64, delta: i64) {
554    if delta >= 0 {
555        *v = v.saturating_add(delta as u64);
556    } else {
557        *v = v.saturating_sub((-delta) as u64);
558    }
559}
560
561/// Heap bytes a `SmallBytes`-encoded key would own (`&[u8]` mirror of
562/// `SmallBytes::heap_bytes`; 22-byte inline boundary per `kevy-bytes`).
563#[inline]
564pub(crate) fn key_heap_bytes_for(key: &[u8]) -> u64 {
565    if key.len() <= 22 { 0 } else { key.len() as u64 }
566}
567
568#[cfg(test)]
569mod tests;
570#[cfg(test)]
571mod tests_memory;
572#[cfg(test)]
573mod tests_snapshot;
574#[cfg(test)]
575mod tests_string_encoding;