Skip to main content

kevy_store/
keyspace.rs

1//! Generic key operations + persistence hooks on [`Store`]:
2//! `del`/`exists`/`expire`/`persist`/`pttl`/`type_of`/`dbsize`/`flush`/
3//! `snapshot_each`/`load_*`/`collect_keys`. Type-agnostic; typed accessors
4//! live in the per-type modules (string/hash/list/set/zset).
5//!
6//! Split out of [`crate`] for file-size hygiene.
7
8#[cfg(not(feature = "std"))]
9use crate::nostd_prelude::*;
10use alloc::sync::Arc;
11use core::time::Duration;
12
13use crate::value::{HashData, SetData, Value, ZSetData};
14use crate::{
15    Entry, RenameOutcome, SmallBytes, Store, deadline_at, glob_match, now_ns, pack_deadline,
16    remaining_ms,
17};
18
19impl Store {
20    // ---- generic key ops (type-agnostic) -------------------------------
21
22    /// `DEL` — returns the count of keys actually removed.
23    pub fn del(&mut self, keys: &[&[u8]]) -> usize {
24        let now = now_ns();
25        let mut removed = 0;
26        for k in keys {
27            if self.reap(k, now) && self.remove_entry(k).is_some() {
28                removed += 1;
29            }
30        }
31        removed
32    }
33
34    /// `EXISTS` — count of live keys (duplicates count per occurrence).
35    pub fn exists(&mut self, keys: &[&[u8]]) -> usize {
36        keys.iter().filter(|k| self.live_entry(k).is_some()).count()
37    }
38
39    /// Set `key`'s deadline `ttl` from now. `false` if the key is not
40    /// live — a key already past its own deadline is reaped first, so
41    /// EXPIRE on it answers as if it were absent rather than reviving it.
42    pub fn expire(&mut self, key: &[u8], ttl: Duration) -> bool {
43        let now = now_ns();
44        if !self.reap(key, now) {
45            return false;
46        }
47        let Some(e) = self.map.get_mut(key) else {
48            return false;
49        };
50        let had = e.expire_at_ns.is_some();
51        e.expire_at_ns = pack_deadline(deadline_at(now, ttl));
52        let delta = i64::from(e.expire_at_ns.is_some()) - i64::from(had);
53        self.adjust_expires(delta);
54        true
55    }
56
57    /// `EXPIREAT`/`PEXPIREAT` semantics: set an **absolute** wall-clock
58    /// deadline (Unix epoch millis). This is the persistence-safe form —
59    /// a deadline survives restart unchanged, unlike the relative
60    /// [`Self::expire`] (whose duration is re-anchored to "now"). A
61    /// deadline already in the past deletes the key immediately (Redis
62    /// behaviour). Returns `true` iff the key existed (and was either
63    /// re-dated or deleted). The wall-clock → monotonic-`Instant`
64    /// conversion happens here so callers persist absolute time but the
65    /// hot path keeps its cheap monotonic deadline.
66    pub fn expire_at_unix_ms(&mut self, key: &[u8], deadline_ms: u64) -> bool {
67        let now = now_ns();
68        if !self.reap(key, now) || !self.map.contains_key(key) {
69            return false;
70        }
71        let wall_now = crate::now_unix_ms();
72        if deadline_ms <= wall_now {
73            // Past deadline: delete now, just like Redis EXPIREAT in the past.
74            self.remove_entry(key);
75            return true;
76        }
77        let remaining = Duration::from_millis(deadline_ms - wall_now);
78        if let Some(e) = self.map.get_mut(key) {
79            let had = e.expire_at_ns.is_some();
80            e.expire_at_ns = pack_deadline(deadline_at(now, remaining));
81            let delta = i64::from(e.expire_at_ns.is_some()) - i64::from(had);
82            self.adjust_expires(delta);
83        }
84        true
85    }
86
87    /// Cross-shard RENAME step 1: atomically remove the entry at
88    /// `key` (if any), returning the `(value, ttl_ms_remaining)`. The
89    /// orchestrator on the origin shard ships the result into a
90    /// follow-up [`Self::put_with_ttl`] on the destination shard.
91    /// Lazy-reaps an expired entry before the take (so an expired
92    /// key is observed as `None`, not silently rehomed).
93    pub fn take_with_ttl(&mut self, key: &[u8]) -> Option<(Value, Option<u64>)> {
94        let now = now_ns();
95        if !self.reap(key, now) {
96            return None;
97        }
98        // A cold stub cannot leave this shard (its ColdRef names THIS
99        // shard's vlog) — materialize before shipping. `remove_entry`
100        // then credits nothing (the value is hot after promotion).
101        if matches!(self.map.get(key).map(|e| &e.value), Some(Value::Cold(_))) {
102            self.promote_in_place(key);
103        }
104        let entry = self.remove_entry(key)?;
105        let ttl_ms = entry.expire_at_ns.map(|ns| remaining_ms(ns, now));
106        Some((entry.value, ttl_ms))
107    }
108
109    /// Clone `key`'s whole entry — value plus remaining TTL — without
110    /// removing it. The read half of a transaction snapshot: pair it
111    /// with [`Self::put_with_ttl`] to restore, or with a delete when
112    /// this returns `None` (the key did not exist).
113    ///
114    /// Unlike [`Self::take_with_ttl`] this leaves the entry in place,
115    /// so a transaction can record the prior state on first touch and
116    /// still let the closure read its own writes afterwards.
117    pub fn clone_with_ttl(&mut self, key: &[u8]) -> Option<(Value, Option<u64>)> {
118        let now = now_ns();
119        if !self.reap(key, now) {
120            return None;
121        }
122        let entry = self.map.get(key)?;
123        let ttl_ms = entry.expire_at_ns.map(|ns| remaining_ms(ns, now));
124        // Cloning a cold stub would alias its vlog record (two stubs,
125        // one dead-note each — double credit). COPY-class callers get a
126        // freshly materialized value instead; the original stays cold.
127        if let Some(fresh) = self.tier_peek_value(key, &entry.value) {
128            return Some((fresh, ttl_ms));
129        }
130        Some((entry.value.clone(), ttl_ms))
131    }
132
133    /// Cross-shard RENAME step 2: write `value` at `key` on this
134    /// shard, overwriting any prior entry. `ttl_ms` is set as a TTL
135    /// relative to *now* (i.e. the orchestrator should have computed
136    /// the remaining TTL on the source shard via `take_with_ttl` and
137    /// is shipping that exact remaining value here).
138    pub fn put_with_ttl(&mut self, key: Vec<u8>, value: Value, ttl_ms: Option<u64>) {
139        let expire_at = ttl_ms.map(|ms| deadline_at(now_ns(), Duration::from_millis(ms)));
140        let entry = Entry::new(value, expire_at);
141        // Overwrite — drop any existing entry first so the accounting
142        // doesn't double-count.
143        self.remove_entry(&key);
144        self.insert_entry(SmallBytes::from_vec(key), entry);
145    }
146
147    /// Whether a live (non-expired) entry exists at `key`. Reaps an
148    /// expired entry as a side effect. Used by the cross-shard RENAME
149    /// orchestrator's `nx` pre-check.
150    pub fn key_exists(&mut self, key: &[u8]) -> bool {
151        let now = now_ns();
152        self.reap(key, now) && self.map.contains_key(key)
153    }
154
155    /// `RENAME` (or `RENAMENX` if `nx`). Atomic on this shard. Returns
156    /// the outcome so the dispatch layer can emit the right RESP frame
157    /// (RENAME: `+OK` or `-ERR no such key`; RENAMENX: `:1`/`:0`/error).
158    ///
159    /// Cross-shard rename is the runtime's job — by the time this is
160    /// called, both `src` and `dst` are guaranteed to live on the same
161    /// shard. See `kevy-rt::start_rename` for the cross-shard split.
162    pub fn rename(&mut self, src: &[u8], dst: &[u8], nx: bool) -> RenameOutcome {
163        let now = now_ns();
164        if !self.reap(src, now) {
165            return RenameOutcome::NoSuchSrc;
166        }
167        // A seg-backed stub is keyed by its row key inside the segment
168        // — the vlog's rename forward-pointer cannot express it.
169        // Materialize first; the segment record strands.
170        #[cfg(all(feature = "std", not(target_arch = "wasm32")))]
171        if let Some(e) = self.map.get(src)
172            && matches!(&e.value, Value::Cold(c) if c.is_seg())
173        {
174            self.promote_in_place(src);
175        }
176        if src == dst {
177            // Redis 6+ semantics: same-key rename is a no-op `+OK`.
178            // (RENAMENX same-key returns `:0` per Redis since dst
179            // technically already exists at src's address.)
180            return if nx { RenameOutcome::DstExists } else { RenameOutcome::Renamed };
181        }
182        if nx {
183            // Reap dst before the existence test so a TTL-expired dst
184            // doesn't block the rename.
185            let dst_live = self.reap(dst, now) && self.map.contains_key(dst);
186            if dst_live {
187                return RenameOutcome::DstExists;
188            }
189        }
190        // Take src's entry out — keepalive form: the entry (and any
191        // cold stub inside it) is re-homed intact, so RENAME moves a
192        // cold key WITHOUT reading its value and without crediting its
193        // record dead. Preserves TTL across rename, matching Redis.
194        let Some(entry) = self.take_entry_keepalive(src) else {
195            return RenameOutcome::NoSuchSrc;
196        };
197        // Drop any pre-existing dst (overwrite semantics). reap above
198        // already handled TTL-expired dst, but the live-dst case still
199        // needs removal.
200        self.remove_entry(dst);
201        // The record's embedded key is stale now — register the
202        // forward pointer compaction resolves through (and re-account
203        // the stub cost for dst's key heap bytes).
204        self.tier_note_renamed(&entry.value, src, dst);
205        self.insert_entry(SmallBytes::from_vec(dst.to_vec()), entry);
206        RenameOutcome::Renamed
207    }
208
209    /// Drop `key`'s deadline, making it immortal. `false` if the key is
210    /// not live, or was live with no deadline to drop.
211    pub fn persist(&mut self, key: &[u8]) -> bool {
212        let now = now_ns();
213        if !self.reap(key, now) {
214            return false;
215        }
216        let cleared = match self.map.get_mut(key) {
217            Some(e) if e.expire_at_ns.is_some() => {
218                e.expire_at_ns = None;
219                true
220            }
221            _ => false,
222        };
223        if cleared {
224            self.adjust_expires(-1);
225        }
226        cleared
227    }
228
229    /// Remaining TTL in ms: `-2` no key, `-1` no expiry, else `>= 0`.
230    pub fn pttl(&mut self, key: &[u8]) -> i64 {
231        let now = now_ns();
232        if !self.reap(key, now) {
233            return -2;
234        }
235        match self.map.get(key).and_then(|e| e.expire_at_ns) {
236            None => -1,
237            Some(ns) => remaining_ms(ns, now) as i64,
238        }
239    }
240
241    /// Redis's TYPE name for what `key` holds — `"none"` when it is
242    /// absent or expired. `&mut` because an expired key is reaped on the
243    /// way past rather than reported as its old type.
244    pub fn type_of(&mut self, key: &[u8]) -> &'static str {
245        let now = now_ns();
246        if !self.reap(key, now) {
247            return "none";
248        }
249        self.map.get(key).map_or("none", |e| e.value.type_name())
250    }
251
252    /// Live keys in this shard. Counts entries, not bytes, and does not
253    /// reap: a key past its deadline that nothing has touched yet is still
254    /// in the map and still counted here, exactly as Redis's DBSIZE
255    /// behaves against lazily-expired keys.
256    pub fn dbsize(&self) -> usize {
257        self.map.len()
258    }
259
260    /// One arbitrary live key, drawn by probing a random slot and walking
261    /// forward (wrapping once) to the first occupied, unexpired one.
262    ///
263    /// This used to be `collect_keys(None, Some(1))` — the first key in
264    /// hash-bucket order, i.e. the same key every call until it was deleted.
265    /// O(1) expected, same slight run-length bias as Redis's
266    /// `dictGetRandomKey`, and the contract is "arbitrary", not "uniform".
267    pub fn random_key(&mut self) -> Option<Vec<u8>> {
268        let now = now_ns();
269        let start = self.rng.next_u64() as usize;
270        let cap = self.map.capacity();
271        let start = if cap == 0 { 0 } else { start % cap };
272        self.map
273            .iter_from_bucket(start)
274            .chain(self.map.iter().take(start))
275            .find(|(_, e)| !e.is_expired_at(now))
276            .map(|(k, _)| k.to_vec())
277    }
278
279    /// One raw draw from the store's random stream, for callers that need
280    /// randomness OUTSIDE the store — the RANDOMKEY reducer's weighted
281    /// reservoir runs on the origin shard, which must not have to invent its
282    /// own entropy source to pick between candidates.
283    pub fn rand_draw(&mut self) -> u64 {
284        self.rng.next_u64()
285    }
286
287    /// Wipe every key in this shard's keyspace (the `FLUSHALL`/`FLUSHDB`
288    /// primitive). Resets `used_memory`; `used_memory_peak` is
289    /// lifetime-cumulative and intentionally not reset.
290    ///
291    /// Named `flushall` — **not** `flush` — to avoid colliding with
292    /// `Write::flush`'s "sync buffered writes to disk" meaning. This method
293    /// DESTROYS data; it does not persist it.
294    pub fn flushall(&mut self) {
295        self.map.clear();
296        self.used_memory = 0;
297        self.expires = 0;
298        // Every cold stub died with the map — the whole vlog is dead,
299        // and every row segment is garbage.
300        self.tier_on_flushall();
301        #[cfg(all(feature = "std", not(target_arch = "wasm32")))]
302        self.segrows_flush();
303        // peak is lifetime-cumulative; intentionally not reset.
304    }
305
306    /// Count live (non-expired) keys that carry a TTL — the size of the
307    /// "expire set" Redis tracks. Useful as an introspection signal for
308    /// confirming the TTL subsystem actually registered keys. O(n) over the
309    /// keyspace; call it for diagnostics, not on the hot path.
310    pub fn ttl_pending_count(&self) -> usize {
311        let now = now_ns();
312        self.map.values().filter(|e| e.expire_at_ns.is_some() && !e.is_expired_at(now)).count()
313    }
314
315    // ---- persistence hooks ---------------------------------------------
316
317    /// Visit every live entry as `(key, &value, ttl_ms)` for snapshotting.
318    pub fn snapshot_each<F: FnMut(&[u8], &Value, Option<u64>)>(&self, mut f: F) {
319        let now = now_ns();
320        for (k, e) in &self.map {
321            if e.is_expired_at(now) {
322                continue;
323            }
324            let ttl = e.expire_at_ns.map(|ns| remaining_ms(ns, now));
325            f(k.as_slice(), &e.value, ttl);
326        }
327    }
328
329    pub(crate) fn insert_loaded(&mut self, key: Vec<u8>, value: Value, ttl_ms: Option<u64>) {
330        let expire_at = ttl_ms.map(|ms| deadline_at(now_ns(), Duration::from_millis(ms)));
331        self.insert_entry(SmallBytes::from_vec(key), Entry::new(value, expire_at));
332    }
333
334    /// Install a string from a snapshot or AOF replay, re-deriving the
335    /// value variant through SET's own encoding rules so a loaded key
336    /// lands where a live SET of the same bytes would. See the comment
337    /// inside: getting this wrong made every loaded string permanently
338    /// unspillable.
339    pub fn load_str(&mut self, key: Vec<u8>, value: Vec<u8>, ttl_ms: Option<u64>) {
340        // Re-materialize through the SET encoding rules so a loaded
341        // value lands on the exact variant a live SET of these bytes
342        // would: canonical integers back to `Int` (the L2 shape the
343        // snapshot serialized them from), > BULK_THRESHOLD bytes back
344        // to `ArcBulk` — restoring GET's writev path AND the tiering
345        // spillable class (a snapshot-loaded bulk value must be
346        // demotable; the old unconditional `Value::Str` made every
347        // loaded string permanently unspillable).
348        let value = crate::string_set::pick_value_for_set_owned(value);
349        self.insert_loaded(key, value, ttl_ms);
350    }
351
352    /// Install a hash from a snapshot or AOF replay as `(field, value)`
353    /// pairs. Both sides become `SmallBytes`, so short values live in the
354    /// slot rather than in their own allocation; see the comment inside for
355    /// where a giant hash goes instead.
356    pub fn load_hash(
357        &mut self,
358        key: Vec<u8>,
359        fields: Vec<(Vec<u8>, Vec<u8>)>,
360        ttl_ms: Option<u64>,
361    ) {
362        // Both field and value are SmallBytes (short values inline in the
363        // slot, no per-value heap alloc). `from_vec` reuses each Vec's
364        // allocation on the >22 B heap path. Giant hashes load straight
365        // into buckets — same switch a live HSET applies.
366        if fields.len() > crate::seg_map::HS_PROMOTE {
367            let mut seg = crate::seg_map::SegMap::default();
368            for (f, v) in fields {
369                seg.insert(SmallBytes::from_vec(f), SmallBytes::from_vec(v));
370            }
371            self.insert_loaded(key, Value::SegHash(Arc::new(seg)), ttl_ms);
372            return;
373        }
374        let hash_data: HashData = fields
375            .into_iter()
376            .map(|(f, v)| (SmallBytes::from_vec(f), SmallBytes::from_vec(v)))
377            .collect();
378        self.insert_loaded(key, Value::Hash(Arc::new(hash_data)), ttl_ms);
379    }
380
381    /// Install a list from a snapshot or AOF replay, in the given order.
382    pub fn load_list(&mut self, key: Vec<u8>, items: Vec<Vec<u8>>, ttl_ms: Option<u64>) {
383        // Same encoding switch a live push applies: a list past the
384        // promotion threshold loads straight into segments, so a
385        // snapshot restore of a giant list lands COW-ready.
386        let value = if items.len() > crate::list_seg::SEG_PROMOTE {
387            Value::SegList(Arc::new(crate::list_seg::SegListData::from_flat(
388                items.into_iter().collect(),
389            )))
390        } else {
391            Value::List(Arc::new(items.into_iter().collect()))
392        };
393        self.insert_loaded(key, value, ttl_ms);
394    }
395
396    /// Install a set from a snapshot or AOF replay. Duplicate members in
397    /// the input collapse, as they would on SADD.
398    pub fn load_set(&mut self, key: Vec<u8>, members: Vec<Vec<u8>>, ttl_ms: Option<u64>) {
399        // Same encoding switch a live SADD applies: a giant set loads
400        // straight into buckets, COW-ready.
401        if members.len() > crate::seg_map::HS_PROMOTE {
402            let mut seg = crate::seg_map::SegMap::default();
403            for m in members {
404                seg.insert(SmallBytes::from_vec(m), ());
405            }
406            self.insert_loaded(key, Value::SegSet(Arc::new(seg)), ttl_ms);
407            return;
408        }
409        let set_data: SetData = members.into_iter().map(SmallBytes::from_vec).collect();
410        self.insert_loaded(key, Value::Set(Arc::new(set_data)), ttl_ms);
411    }
412
413    /// Count live keys under a byte prefix and how many of them carry
414    /// a TTL. O(keyspace) — a stats/ops call, not a hot-path primitive.
415    pub fn prefix_stats(&self, prefix: &[u8]) -> (u64, u64) {
416        let now = now_ns();
417        let mut keys = 0u64;
418        let mut expires = 0u64;
419        for (k, e) in &self.map {
420            if e.is_expired_at(now) || !k.as_slice().starts_with(prefix) {
421                continue;
422            }
423            keys += 1;
424            if e.expire_at_ns.is_some() {
425                expires += 1;
426            }
427        }
428        (keys, expires)
429    }
430
431    /// Collect live keys (optionally matching a glob `pattern`, up to `limit`).
432    /// Used by KEYS/SCAN/RANDOMKEY. Treats expired keys as absent (no removal).
433    pub fn collect_keys(&self, pattern: Option<&[u8]>, limit: Option<usize>) -> Vec<Vec<u8>> {
434        let now = now_ns();
435        let mut out = Vec::new();
436        for (k, e) in &self.map {
437            if e.is_expired_at(now) {
438                continue;
439            }
440            if let Some(p) = pattern
441                && !glob_match(p, k.as_slice())
442            {
443                continue;
444            }
445            out.push(k.to_vec());
446            if limit.is_some_and(|lim| out.len() >= lim) {
447                break;
448            }
449        }
450        out
451    }
452
453    /// Install a sorted set from a snapshot or AOF replay as
454    /// `(member, score)` pairs. Order in the input does not matter — the
455    /// set orders itself, as it would on ZADD.
456    pub fn load_zset(&mut self, key: Vec<u8>, pairs: Vec<(Vec<u8>, f64)>, ttl_ms: Option<u64>) {
457        let mut z = ZSetData::default();
458        for (m, score) in pairs {
459            z.insert(&m, score);
460        }
461        // Same encoding switch a live ZADD applies: giant zsets load
462        // straight into the segmented representation, COW-ready.
463        let value = if z.len() > crate::zset_seg::Z_PROMOTE {
464            Value::SegZSet(Arc::new(crate::zset_seg::SegZSetData::from_flat(&z)))
465        } else {
466            Value::ZSet(Arc::new(z))
467        };
468        self.insert_loaded(key, value, ttl_ms);
469    }
470}