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