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` clones the source's value and TTL and places them at dst.
7//! - `randomkey` collects matching keys and picks one by index.
8//! - `unlink` is an alias for `del`; kevy has no async deletion, so
9//! sync delete is the unblocking semantic.
10//! - `touch` counts existing keys and reads bump LRU/LFU bookkeeping
11//! as a side effect.
12
13use crate::KevyResult;
14
15use crate::CopyMode;
16use crate::store::ensure_writable;
17use crate::store::{Inner, Store, commit_write};
18
19impl Store {
20 /// `COPY src dst [REPLACE]` — copy `src`'s value, of any type, and
21 /// its remaining TTL to `dst`. Returns `true` when the copy happened.
22 ///
23 /// Semantics:
24 /// - `false` if `src` doesn't exist.
25 /// - `false` if `dst` exists and `mode` is [`CopyMode::IfAbsent`].
26 /// - A source with a TTL gives the destination the same deadline.
27 ///
28 /// ```
29 /// use kevy_embedded::{Config, CopyMode, Store};
30 ///
31 /// let s = Store::open(Config::default())?;
32 /// s.set(b"a", b"1")?;
33 /// assert!(s.copy(b"a", b"b", CopyMode::IfAbsent)?);
34 /// assert_eq!(s.get(b"b")?.as_deref(), Some(&b"1"[..]));
35 /// s.hset(b"h", &[(b"f", b"v")])?;
36 /// assert!(s.copy(b"h", b"b", CopyMode::Replace)?);
37 /// assert_eq!(s.hget(b"b", b"f")?.as_deref(), Some(&b"v"[..]));
38 /// # Ok::<(), kevy_embedded::KevyError>(())
39 /// ```
40 pub fn copy(&self, src: &[u8], dst: &[u8], mode: CopyMode) -> KevyResult<bool> {
41 ensure_writable(self)?;
42 // the source's lock is released before the destination's is
43 // taken: the two keys may live on one shard
44 let cloned = self.wshard(src).store.clone_with_ttl(src);
45 let Some((value, ttl_ms)) = cloned else {
46 return Ok(false);
47 };
48 let mut g = self.wshard(dst);
49 let replaced = g.store.key_exists(dst);
50 if replaced && !matches!(mode, CopyMode::Replace) {
51 return Ok(false);
52 }
53 let frames = placed_frames(&g, dst, &value, ttl_ms);
54 g.store.put_with_ttl(dst.to_vec(), value, ttl_ms);
55 commit_copy(&mut g, [b"COPY", src, dst], frames, replaced)?;
56 Ok(true)
57 }
58
59 /// `RANDOMKEY` — return a randomly-chosen existing key, or
60 /// `None` when the keyspace is empty.
61 ///
62 /// A shard is drawn in proportion to how many keys it holds, and that
63 /// shard picks from a random point in its table, so the cost does not
64 /// grow with the keyspace.
65 ///
66 /// ```
67 /// # use kevy_embedded::{Config, Store};
68 /// let s = Store::open(Config::default())?;
69 /// assert_eq!(s.randomkey(), None);
70 /// s.set(b"only", b"1")?;
71 /// assert_eq!(s.randomkey(), Some(b"only".to_vec()));
72 /// # Ok::<(), kevy_embedded::KevyError>(())
73 /// ```
74 pub fn randomkey(&self) -> Option<Vec<u8>> {
75 let sizes: Vec<usize> =
76 self.shards.iter().map(|sh| crate::store_glue::lock_read(sh).store.dbsize()).collect();
77 let total: usize = sizes.iter().sum();
78 if total == 0 {
79 return None;
80 }
81 let mut draw = {
82 let mut g = crate::store_glue::lock_write(&self.shards[0]);
83 g.store.rand_draw() as usize % total
84 };
85 for (i, n) in sizes.iter().enumerate() {
86 if draw < *n {
87 if let Some(k) = crate::store_glue::lock_write(&self.shards[i]).store.random_key() {
88 return Some(k);
89 }
90 break;
91 }
92 draw -= n;
93 }
94 // the shard drawn emptied since it was counted: any key will do
95 self.shards.iter().find_map(|sh| crate::store_glue::lock_write(sh).store.random_key())
96 }
97
98 /// `UNLINK key [key ...]` — alias for [`Self::del`]. In Redis
99 /// this is the async (non-blocking) variant; kevy is in-process
100 /// so the sync `del` IS the unblocking semantic. Returns count
101 /// actually removed.
102 pub fn unlink(&self, keys: &[&[u8]]) -> KevyResult<usize> {
103 self.del(keys)
104 }
105
106 /// `TOUCH key [key ...]` — count keys that exist. Side effect:
107 /// the existence check refreshes LRU/LFU bookkeeping on the
108 /// touched shards, matching Redis semantics.
109 pub fn touch(&self, keys: &[&[u8]]) -> KevyResult<usize> {
110 self.exists(keys)
111 }
112}
113
114impl crate::Store {
115 /// Order-insensitive prefix checksum for migration
116 /// verification: `(row_count, xor_of_row_digests)`. Matches the
117 /// server's `PREFIX.DIGEST` bit for bit (same canonicalization).
118 pub fn prefix_digest(&self, prefix: &[u8]) -> (u64, u64) {
119 let mut pat = prefix.to_vec();
120 pat.push(b'*');
121 let keys = self.keys(Some(&pat), None);
122 let mut xor = 0u64;
123 for key in &keys {
124 xor ^= self.row_digest_embedded(key);
125 }
126 (keys.len() as u64, xor)
127 }
128
129 /// One row's digest under a SINGLE shard write-lock acquisition,
130 /// with the row reads inside the store's bulk-read peek scope
131 /// — a cold row hashes from ONE record read, never promotes
132 /// and never advances the 2nd-touch gate — a full-prefix digest
133 /// must not thrash the hot tier (server twin: `cmd_digest`).
134 fn row_digest_embedded(&self, key: &[u8]) -> u64 {
135 let mut g = self.wshard(key);
136 g.store.peek_scope(|s| {
137 let mut h = FNV_OFFSET;
138 fnv(&mut h, key);
139 let ty = s.type_of(key);
140 fnv(&mut h, ty.as_bytes());
141 digest_row_body(s, key, ty, &mut h);
142 h
143 })
144 }
145}
146
147/// Fold one row's canonicalized value into the FNV state, per type
148/// (hash fields and set members sort first; zset folds score bits
149/// then member, rank order). Reads the store directly — the caller
150/// already holds the shard lock and the peek scope.
151fn digest_row_body(s: &mut kevy_store::Store, key: &[u8], ty: &str, h: &mut u64) {
152 match ty {
153 "string" => {
154 if let Ok(Some(v)) = s.get(key) {
155 let v = v.to_vec();
156 fnv(h, &v);
157 }
158 }
159 "hash" => {
160 if let Ok(flat) = s.hgetall(key) {
161 let mut pairs: Vec<(&[u8], &[u8])> =
162 flat.chunks(2).map(|c| (c[0].as_slice(), c[1].as_slice())).collect();
163 pairs.sort();
164 for (f, v) in pairs {
165 fnv(h, f);
166 fnv(h, v);
167 }
168 }
169 }
170 "list" => {
171 if let Ok(items) = s.lrange(key, 0, -1) {
172 for i in items {
173 fnv(h, &i);
174 }
175 }
176 }
177 "set" => {
178 if let Ok(mut ms) = s.smembers(key) {
179 ms.sort();
180 for m in ms {
181 fnv(h, &m);
182 }
183 }
184 }
185 "zset" => {
186 if let Ok(items) = s.zrange(key, 0, -1) {
187 for (member, score) in items {
188 fnv(h, &score.to_bits().to_le_bytes());
189 fnv(h, &member);
190 }
191 }
192 }
193 _ => {}
194 }
195}
196
197const FNV_OFFSET: u64 = 0xCBF2_9CE4_8422_2325;
198const FNV_PRIME: u64 = 0x0000_0100_0000_01B3;
199
200fn fnv(h: &mut u64, bytes: &[u8]) {
201 for &b in bytes {
202 *h ^= u64::from(b);
203 *h = h.wrapping_mul(FNV_PRIME);
204 }
205}
206
207/// The commands that rebuild a copied value at `dst`, when this shard
208/// records its writes anywhere
209#[cfg(feature = "persist")]
210fn placed_frames(
211 g: &Inner,
212 dst: &[u8],
213 value: &kevy_store::Value,
214 ttl_ms: Option<u64>,
215) -> Option<Vec<u8>> {
216 crate::store_glue::records_writes(g)
217 .then(|| kevy_persist::value_as_v1_frames(dst, value, ttl_ms))
218}
219
220#[cfg(not(feature = "persist"))]
221fn placed_frames(_: &Inner, _: &[u8], _: &kevy_store::Value, _: Option<u64>) -> Option<Vec<u8>> {
222 None
223}
224
225/// Record a copy as the commands that rebuild its value, after a DEL
226/// when it replaced a key, so a replay does not merge into what was
227/// there; with nothing to record into, the argv runs the commit's other
228/// steps
229fn commit_copy(
230 g: &mut Inner,
231 argv: [&[u8]; 3],
232 frames: Option<Vec<u8>>,
233 replaced: bool,
234) -> KevyResult<()> {
235 let Some(frames) = frames else {
236 return commit_write(g, &argv);
237 };
238 if replaced {
239 commit_write(g, &[b"DEL", argv[2]])?;
240 }
241 let (mut pos, mut cmd) = (0, kevy_resp::Argv::default());
242 while pos < frames.len() {
243 cmd.clear();
244 pos += kevy_resp::parse_command_into(&frames[pos..], &mut cmd)
245 .ok()
246 .flatten()
247 .expect("the value serializer writes whole commands");
248 let parts: Vec<&[u8]> = cmd.iter().collect();
249 commit_write(g, &parts)?;
250 }
251 Ok(())
252}