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