kevy_embedded/ops_keyspace.rs
1//! Cross-key operations: `copy`, `randomkey`, `unlink`, `touch`.
2//!
3//! These compose existing `kevy_store::Store` primitives at the
4//! embedded layer:
5//!
6//! - `copy` is `get` + (optional) read TTL + `set` on dst + `expire`
7//! on dst.
8//! - `randomkey` collects matching keys and picks one by index.
9//! - `unlink` is an alias for `del`; kevy has no async deletion, so
10//! sync delete is the unblocking semantic.
11//! - `touch` counts existing keys and reads bump LRU/LFU bookkeeping
12//! as a side effect.
13
14use crate::KevyResult;
15
16use crate::store::ensure_writable;
17use crate::store::{Store, commit_write};
18
19impl Store {
20 /// `COPY src dst [REPLACE]` — copy `src`'s value (and TTL if any)
21 /// to `dst`. Returns `true` when the copy happened.
22 ///
23 /// Semantics:
24 /// - `false` if `src` doesn't exist.
25 /// - `false` if `dst` exists and `replace = false`.
26 /// - Preserves source TTL on the destination via `pexpireat`.
27 pub fn copy(&self, src: &[u8], dst: &[u8], replace: bool) -> KevyResult<bool> {
28 ensure_writable(self)?;
29 // Read source under its own shard lock.
30 let src_val = match self.get(src)? {
31 Some(v) => v,
32 None => return Ok(false),
33 };
34 // Sample the source's TTL (ms since UNIX epoch) BEFORE the
35 // write — captures the deadline that should survive the copy.
36 let src_ttl_ms = self.ttl_ms(src);
37 // Veto if dst exists and replace is false.
38 if !replace {
39 // Use a fresh wshard on dst so this works cross-shard.
40 let mut g = self.wshard(dst);
41 if g.store.key_exists(dst) {
42 return Ok(false);
43 }
44 // AOF-log first (SET dst <value>), then write dst — both
45 // under dst's shard lock. Log-before-apply avoids cloning
46 // the value; an AOF error leaves memory untouched.
47 commit_write(&mut g, &[b"SET", dst, &src_val])?;
48 g.store.set(dst, src_val, None, false, false);
49 } else {
50 let mut g = self.wshard(dst);
51 commit_write(&mut g, &[b"SET", dst, &src_val])?;
52 g.store.set(dst, src_val, None, false, false);
53 }
54 // Re-attach absolute deadline if the source had one.
55 if src_ttl_ms > 0 {
56 let unix_ms = std::time::SystemTime::now()
57 .duration_since(std::time::UNIX_EPOCH)
58 .map(|d| d.as_millis() as u64)
59 .unwrap_or(0)
60 .saturating_add(src_ttl_ms as u64);
61 self.pexpireat(dst, unix_ms)?;
62 }
63 // The dst SET is AOF-logged above under dst's shard lock; the
64 // TTL re-attach goes through the `pexpireat` facade which logs
65 // its own PEXPIREAT. (An earlier regression wrote the dst value
66 // to memory only, so it vanished on reopen.)
67 Ok(true)
68 }
69
70 /// `RANDOMKEY` — return a randomly-chosen existing key, or
71 /// `None` when the keyspace is empty.
72 ///
73 /// Implementation: snapshot all keys via `collect_keys`, then
74 /// pick a uniform index. For large keyspaces this is O(N); a
75 /// future ship can add a `key_at(rank)` Store method for O(1)
76 /// random pick.
77 pub fn randomkey(&self) -> Option<Vec<u8>> {
78 let keys = self.collect_keys(None, None);
79 if keys.is_empty() {
80 return None;
81 }
82 // Cheap PRNG via nanosecond clock — embedded in-process so
83 // this just needs decent distribution, not crypto strength.
84 let idx = std::time::SystemTime::now()
85 .duration_since(std::time::UNIX_EPOCH)
86 .map(|d| d.subsec_nanos() as usize)
87 .unwrap_or(0)
88 % keys.len();
89 Some(keys[idx].clone())
90 }
91
92 /// `UNLINK key [key ...]` — alias for [`Self::del`]. In Redis
93 /// this is the async (non-blocking) variant; kevy is in-process
94 /// so the sync `del` IS the unblocking semantic. Returns count
95 /// actually removed.
96 pub fn unlink(&self, keys: &[&[u8]]) -> KevyResult<usize> {
97 self.del(keys)
98 }
99
100 /// `TOUCH key [key ...]` — count keys that exist. Side effect:
101 /// the existence check refreshes LRU/LFU bookkeeping on the
102 /// touched shards, matching Redis semantics.
103 pub fn touch(&self, keys: &[&[u8]]) -> KevyResult<usize> {
104 self.exists(keys)
105 }
106}
107
108impl crate::Store {
109 /// Order-insensitive prefix checksum for migration
110 /// verification: `(row_count, xor_of_row_digests)`. Matches the
111 /// server's `PREFIX.DIGEST` bit for bit (same canonicalization).
112 pub fn prefix_digest(&self, prefix: &[u8]) -> (u64, u64) {
113 let mut pat = prefix.to_vec();
114 pat.push(b'*');
115 let keys = self.keys(Some(&pat), None);
116 let mut xor = 0u64;
117 for key in &keys {
118 xor ^= self.row_digest_embedded(key);
119 }
120 (keys.len() as u64, xor)
121 }
122
123 /// One row's digest under a SINGLE shard write-lock acquisition,
124 /// with the row reads inside the store's bulk-read peek scope
125 /// — a cold row hashes from ONE record read, never promotes
126 /// and never advances the 2nd-touch gate — a full-prefix digest
127 /// must not thrash the hot tier (server twin: `cmd_digest`).
128 fn row_digest_embedded(&self, key: &[u8]) -> u64 {
129 let mut g = self.wshard(key);
130 g.store.peek_scope(|s| {
131 let mut h = FNV_OFFSET;
132 fnv(&mut h, key);
133 let ty = s.type_of(key);
134 fnv(&mut h, ty.as_bytes());
135 digest_row_body(s, key, ty, &mut h);
136 h
137 })
138 }
139}
140
141/// Fold one row's canonicalized value into the FNV state, per type
142/// (hash fields and set members sort first; zset folds score bits
143/// then member, rank order). Reads the store directly — the caller
144/// already holds the shard lock and the peek scope.
145fn digest_row_body(s: &mut kevy_store::Store, key: &[u8], ty: &str, h: &mut u64) {
146 match ty {
147 "string" => {
148 if let Ok(Some(v)) = s.get(key) {
149 let v = v.to_vec();
150 fnv(h, &v);
151 }
152 }
153 "hash" => {
154 if let Ok(flat) = s.hgetall(key) {
155 let mut pairs: Vec<(&[u8], &[u8])> =
156 flat.chunks(2).map(|c| (c[0].as_slice(), c[1].as_slice())).collect();
157 pairs.sort();
158 for (f, v) in pairs {
159 fnv(h, f);
160 fnv(h, v);
161 }
162 }
163 }
164 "list" => {
165 if let Ok(items) = s.lrange(key, 0, -1) {
166 for i in items {
167 fnv(h, &i);
168 }
169 }
170 }
171 "set" => {
172 if let Ok(mut ms) = s.smembers(key) {
173 ms.sort();
174 for m in ms {
175 fnv(h, &m);
176 }
177 }
178 }
179 "zset" => {
180 if let Ok(items) = s.zrange(key, 0, -1) {
181 for (member, score) in items {
182 fnv(h, &score.to_bits().to_le_bytes());
183 fnv(h, &member);
184 }
185 }
186 }
187 _ => {}
188 }
189}
190
191const FNV_OFFSET: u64 = 0xCBF2_9CE4_8422_2325;
192const FNV_PRIME: u64 = 0x0000_0100_0000_01B3;
193
194fn fnv(h: &mut u64, bytes: &[u8]) {
195 for &b in bytes {
196 *h ^= u64::from(b);
197 *h = h.wrapping_mul(FNV_PRIME);
198 }
199}