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".as_slice(), b"alice".as_slice())]).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#![cfg_attr(not(feature = "std"), no_std)]
44
45#[cfg(all(not(feature = "std"), not(feature = "external-clock")))]
46compile_error!(
47    "kevy-store without `std` needs the `external-clock` feature: the TTL \
48     clock must be host-fed when std::time is unavailable"
49);
50
51extern crate alloc;
52
53/// The alloc-crate slice of the std prelude, for `no_std` builds — glob-
54/// imported per file so the std build stays byte-for-byte untouched.
55#[cfg(not(feature = "std"))]
56pub(crate) mod nostd_prelude {
57    pub(crate) use alloc::boxed::Box;
58    pub(crate) use alloc::format;
59    pub(crate) use alloc::string::{String, ToString};
60    pub(crate) use alloc::vec::Vec;
61}
62#[cfg(not(feature = "std"))]
63use nostd_prelude::*;
64
65/// The two side maps (`hfttl`, `watch_versions`) ride std's table on std
66/// and the self-hosted `KevyMap` without it.
67#[cfg(feature = "std")]
68pub(crate) type SideMap<K, V> = std::collections::HashMap<K, V>;
69#[cfg(not(feature = "std"))]
70pub(crate) type SideMap<K, V> = kevy_map::KevyMap<K, V>;
71
72mod accounting;
73#[cfg(feature = "std")]
74mod bio_drop;
75
76/// Without `std` there is no bio thread (`bio_drop` module is compiled
77/// out) — displaced heavy values drop inline on the caller.
78#[cfg(not(feature = "std"))]
79impl Store {
80    #[inline]
81    pub(crate) fn maybe_offload_drop(&mut self, old: Value) {
82        drop(old);
83    }
84}
85mod bitmap;
86mod clock;
87mod entry;
88mod error;
89pub use error::{KevyError, KevyResult};
90pub mod evict;
91pub mod expire;
92pub use expire::ExpireStats;
93pub(crate) use entry::Entry;
94mod hash;
95mod hash_read;
96mod hash_ttl;
97pub use hash_ttl::{HExpireCode, HExpireCond};
98mod keyspace;
99mod keyspace_load;
100mod list;
101pub mod list_seg;
102pub mod seg_map;
103mod list_read;
104mod notify;
105mod rng;
106mod scan;
107pub use notify::KeyspaceEvent;
108mod list_ops;
109mod set;
110mod set_read;
111mod small_set;
112pub use small_set::{SmallSetData, SmallSetIter};
113mod small_hash;
114pub use small_hash::{SmallHashData, SmallHashIter};
115mod small_list;
116pub use small_list::{SmallListData, SmallListIter};
117mod small_zset;
118pub use small_zset::{SmallZSetData, SmallZSetIter};
119mod snapshot;
120pub use snapshot::SnapshotView;
121mod stream;
122mod string;
123mod string_rmw;
124mod string_set;
125#[cfg(all(feature = "std", not(target_arch = "wasm32")))]
126mod segrows;
127#[cfg(all(feature = "std", not(target_arch = "wasm32")))]
128mod segwindow;
129mod tier;
130#[cfg(all(feature = "std", not(target_arch = "wasm32")))]
131mod tier_codec;
132mod tier_demote;
133mod tier_serve;
134#[cfg(all(feature = "std", not(target_arch = "wasm32")))]
135pub use segrows::SealedRows;
136
137#[cfg(all(feature = "std", not(target_arch = "wasm32")))]
138pub use segwindow::apply_segmented;
139#[cfg(all(feature = "std", not(target_arch = "wasm32")))]
140pub use tier::TierStats;
141#[cfg(all(feature = "std", not(target_arch = "wasm32")))]
142pub use tier_serve::{ColdBatchReader, ColdRead, PeekRow, SyncColdRead};
143mod types;
144pub use types::{EvictionPolicy, RenameOutcome, StoreError};
145mod util;
146mod value;
147mod value_cold;
148mod zset;
149pub mod zset_seg;
150mod zset_algebra;
151mod zset_range;
152pub use zset_algebra::{ZAggregate, zdiff, zinter, zintercard, zunion};
153mod zset_flags;
154pub use zset_flags::{ZaddFlags, ZaddReport};
155pub use stream::{
156    AutoclaimResult, ConsumerGroup, ConsumerState, EntryBatch, GroupCreateMode,
157    LoadedGroup, LoadedPelEntry, LoadedStreamEntry, PelEntry, PendingExtended,
158    PendingExtendedRow, PendingSummary, ReadGroupId, StreamData, StreamId, StreamIdError,
159    XAddIdSpec, XClaimOpts, now_unix_ms, parse_explicit_id, parse_range_end,
160    parse_range_start, parse_xadd_id,
161};
162pub use string::{GetReply, GetShared};
163pub use util::glob_match;
164pub use value::*;
165
166pub(crate) use clock::{deadline_at, now_ns, pack_deadline, remaining_ms};
167use kevy_map::KevyMap;
168/// Feed kevy's monotonic clock on `wasm32-unknown-unknown`, which has no
169/// `Instant`. The embedding host advances time (ns since an arbitrary fixed
170/// epoch, e.g. `Date.now() * 1e6`) before TTL-sensitive ops and once per
171/// reaper tick. No-op concept on native targets, where the OS clock is the
172/// source — hence wasm-only.
173#[cfg(any(feature = "external-clock", all(target_arch = "wasm32", target_os = "unknown")))]
174pub use clock::set_clock_ns;
175/// Feed kevy's wall clock (Unix-epoch millis, e.g. `Date.now()`) on
176/// `wasm32-unknown-unknown`, where `SystemTime::now()` traps. Used by `XADD`
177/// auto-IDs and `EXPIREAT`/`PEXPIREAT`.
178#[cfg(any(feature = "external-clock", all(target_arch = "wasm32", target_os = "unknown")))]
179pub use clock::set_wall_clock_ms;
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    /// The random source. SPOP and SRANDMEMBER promise an ARBITRARY member;
194    /// before this they returned the first one in hash-bucket order, which for
195    /// a given set is the same member every time.
196    pub(crate) rng: rng::Rng,
197    /// Per-field hash TTLs: key → (field → absolute unix-ms
198    /// deadline). Holds ONLY keys with live field TTLs — one
199    /// `is_empty()` branch per hash access when the feature is unused.
200    pub(crate) hfttl: SideMap<SmallBytes, KevyMap<SmallBytes, u64>>,
201    /// Coarse cached monotonic clock (ns since [`epoch`]), refreshed by the
202    /// reactor loop / reaper tick via [`Self::refresh_clock`]. Lazy expiry on
203    /// the read path (`live_entry`) compares deadlines against this instead of
204    /// calling `Instant::now()` per access — the Redis cached-`mstime` model.
205    /// `0` (the `Default`) reads as "epoch" → keys look live until the first
206    /// refresh, the safe direction (expires at most one refresh-interval late,
207    /// never early — writes stamp deadlines from a *fresh* clock).
208    pub(crate) cached_ns: u64,
209    /// Whether lazy expiry trusts `Self::cached_ns` (set by a reactor/reaper
210    /// that calls [`Self::refresh_clock`]) instead of reading a fresh clock per
211    /// access. Enabled by the server reactor and the embedded background
212    /// reaper; left `false` (the `Default`) for manual-reaper / bare-`Store`
213    /// use, where nothing refreshes the cache so each access reads fresh —
214    /// preserving "lazy expiry works without an explicit tick".
215    pub(crate) cached_clock: bool,
216    /// Live byte estimate (dynamic per-entry weights + [`ENTRY_OVERHEAD`] per
217    /// key). Compared against [`Self::maxmemory`] to drive eviction.
218    pub(crate) used_memory: u64,
219    /// Soft byte ceiling. `0` = unlimited; the entire accounting + eviction
220    /// machinery short-circuits to a single not-taken branch in that case.
221    pub(crate) maxmemory: u64,
222    /// Active eviction policy. Only consulted when `used_memory > maxmemory`.
223    pub(crate) eviction_policy: EvictionPolicy,
224    /// Total keys evicted by [`Self::try_evict_after_write`] — surfaced via
225    /// `INFO memory` / `MEMORY STATS`.
226    pub(crate) evictions_total: u64,
227    /// Monotonic access counter; the upper 32 bits are unused, the lower 32
228    /// stamp `Entry::lru_clock` on each access while eviction is enabled.
229    pub(crate) clock_counter: u64,
230    /// `used_memory` peak across the shard's lifetime; surfaced as
231    /// `used_memory_peak` in `INFO memory`.
232    pub(crate) used_memory_peak: u64,
233    /// Keys expired since startup (lazy reap path AND
234    /// [`Self::tick_expire`]). Surfaced via `INFO keyspace` / `MEMORY STATS`
235    /// once those fields land.
236    pub(crate) expired_keys_total: u64,
237    /// Which store-origin keyspace events to capture (see
238    /// [`crate::notify`]). All-off default = every hook is one byte
239    /// test.
240    pub(crate) notify_capture: u8,
241    /// Captured events awaiting the serving layer's drain
242    /// ([`Self::take_notify_events`]), in capture order.
243    pub(crate) notify_events: Vec<(notify::KeyspaceEvent, Vec<u8>)>,
244    /// Keys this store dropped because their TTL passed, awaiting the
245    /// serving layer's drain ([`Self::take_expired_keys`]).
246    ///
247    /// Separate from `notify_events` and **always on**, because it
248    /// carries correctness rather than observability: an expiring key
249    /// must still leave every secondary index and invalidate every
250    /// WATCH on it, and neither may depend on whether some client
251    /// happened to subscribe to keyspace notifications.
252    pub(crate) expired_keys: Vec<Vec<u8>>,
253    /// Count of live keys carrying a TTL — the size of Redis's "expire set"
254    /// (`INFO keyspace`'s `expires=`). Maintained in O(1) at every TTL
255    /// transition (`insert_entry` / `remove_entry` deltas + the in-place
256    /// EXPIRE / PERSIST / SET sites) so the gauge never pays an O(n) keyspace
257    /// scan; [`Self::ttl_pending_count`] is the O(n) ground truth used to
258    /// assert this counter never drifts.
259    pub(crate) expires: u64,
260    /// `WATCH` version counters — present only for keys that have been
261    /// `WATCH`-ed at least once. [`Self::record_watch`] inserts the entry
262    /// (version 0 = "never written since first watch"); every subsequent
263    /// write on this shard calls [`Self::bump_if_watched`] which increments
264    /// only if the key is present in the map. Keys never `WATCH`-ed pay
265    /// one empty-map hashmap lookup per write (~10 ns).
266    ///
267    /// The map grows monotonically — entries are never evicted, even
268    /// when no conn is currently watching the key. For high-key-churn
269    /// workloads this can become a memory item; v1.x acceptable since
270    /// the entry is `Vec<u8>` + `u64` (~ 30 B + key length) and only
271    /// touched on writes / WATCH calls.
272    pub(crate) watch_versions: SideMap<Vec<u8>, u64>,
273    /// Optional handle to the runtime's bio thread. Set by
274    /// `kevy-rt::Runtime::run` via [`Self::set_bio_drop_sender`] before
275    /// the shard reactor loop starts. `None` = inline drop (bare-Store
276    /// embedders, snapshots-loader programs, the test harness — anything
277    /// without a kevy-rt runtime around it). Reads on the hot path are
278    /// one `Option::as_ref` branch; the steady-state inline-drop path
279    /// pays nothing beyond that branch.
280    #[cfg(feature = "std")]
281    pub(crate) bio_drop_sender: Option<value::BioDropSender>,
282    /// Batch-send buffer. Heavy `Value`s displaced by SET
283    /// overwrites accumulate here instead of paying one mpsc send per
284    /// drop; flushed in one `mpsc::Sender::send` at the end of every
285    /// reactor iteration (via [`Self::flush_pending_drops`], invoked
286    /// from `kevy-rt`'s epoll + io_uring reactor loops before the AOF
287    /// fsync window). Amortising the channel cost over N drops lets
288    /// the heap-heavy threshold sit at 1 KB — small enough that the
289    /// Axis I 256 B – 16 KB SET tail benefits, big enough that
290    /// sub-µs small-class drops still go inline (the push + flush
291    /// branch would cost more than the inline free).
292    ///
293    /// **Latency window**: drops sit in this buffer ≤ one reactor
294    /// iteration (10s of µs at busy-poll, ≤ park-timeout at idle —
295    /// 50 ms by default). On a reactor with no traffic the buffer
296    /// stays small (no new SETs to displace anything); on a reactor
297    /// with sustained writes the per-iter flush fires fast enough
298    /// that worst-case stall is bounded by `MAX_PENDING_DROPS`.
299    ///
300    /// **Bounded growth**: at `MAX_PENDING_DROPS` items the
301    /// `maybe_offload_drop` path force-flushes — protects against
302    /// pathological "thousand SETs in one iter never flush" cases
303    /// (would otherwise hold thousands of Box<Value>s in RAM until
304    /// the iter ends).
305    #[cfg(feature = "std")]
306    pub(crate) pending_drops: Vec<Value>,
307    /// Transparent-tiering state. `None` = off —
308    /// today's paths byte-identical ([`Store::enable_tiering`]).
309    #[cfg(all(feature = "std", not(target_arch = "wasm32")))]
310    pub(crate) tier: Option<tier::TierState>,
311    /// Row-segment directory — the persistent second backing behind
312    /// `Value::Cold` ([`segrows`]). `None` = off, today's paths
313    /// byte-identical.
314    #[cfg(all(feature = "std", not(target_arch = "wasm32")))]
315    pub(crate) segrows: Option<segrows::SegRows>,
316    /// Hot gate for the stub funnels: true iff EITHER cold backing
317    /// (vlog tier / row segments) is enabled. One predictable branch
318    /// keeps tier_serve/tier_resolve at their no-backing cost on
319    /// deployments that never demote — the measured shape of the
320    /// write-path funnels (perfgate legacy angles).
321    #[cfg_attr(any(not(feature = "std"), target_arch = "wasm32"), allow(dead_code))]
322    pub(crate) cold_backing: bool,
323    /// The promotion gate's first-touch serve scratch (`tier_serve`):
324    /// a cold value decoded for ONE read, never installed in the map.
325    #[cfg(all(feature = "std", not(target_arch = "wasm32")))]
326    pub(crate) tier_scratch: Option<Entry>,
327    /// Bulk-read (no-promote peek) mode, scoped by
328    /// [`Store::peek_scope`]: while set, a cold materializing read serves
329    /// via pread WITHOUT setting the probation mark and WITHOUT
330    /// promoting — digest / scope-move / export reads are not access
331    /// signals.
332    #[cfg(all(feature = "std", not(target_arch = "wasm32")))]
333    pub(crate) tier_peek: bool,
334}
335
336
337mod store_admin;
338
339/// No row-segment backend on this target.
340#[cfg(not(all(feature = "std", not(target_arch = "wasm32"))))]
341impl Store {
342    /// Cfg twin of the segrows accessor: always empty.
343    pub fn row_seg_files(&self) -> Vec<(u32, alloc::string::String)> {
344        Vec::new()
345    }
346
347    /// A v7 snapshot cannot load where the segment backend is absent.
348    pub fn load_row_stub(&mut self, _key: Vec<u8>, _seq: u32, _weight: u32) {
349        panic!("row-segment snapshot record on a target without the segment backend");
350    }
351}
352
353// Accounting micro-helpers live in `util` (500-LOC split); re-exported
354// so the crate-wide `crate::apply_delta` / `crate::key_heap_bytes_for`
355// paths keep working.
356pub(crate) use util::{apply_delta, key_heap_bytes_for};
357
358#[cfg(test)]
359mod tests;
360#[cfg(test)]
361mod tests_list_seg;
362#[cfg(test)]
363mod tests_memory;
364#[cfg(test)]
365mod tests_seg_map;
366#[cfg(test)]
367mod tests_zset_seg;
368#[cfg(test)]
369mod tests_snapshot;
370#[cfg(test)]
371mod tests_string_encoding;
372#[cfg(all(test, feature = "std", not(target_arch = "wasm32")))]
373mod tests_tier;
374#[cfg(all(test, feature = "std", not(target_arch = "wasm32")))]
375mod tests_tier_peek;