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;