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}