kevy_embedded/ops.rs
1//! Data-type methods on [`Store`] — string, hash, list, set, sorted set,
2//! plus the pub/sub `publish` / `subscribe` / `psubscribe` entry points.
3//!
4//! All of these are thin facades over `kevy_store::Store` (the keyspace)
5//! and `pubsub::PubsubBus` (the in-process bus); they hold the embedded
6//! mutex for the duration of the underlying call, then drop it. AOF
7//! logging + post-write eviction sweep run via `commit_write` from
8//! `store.rs`. Behaviour and ABI are unchanged from the original
9//! single-file layout — this module only exists to keep `store.rs` under
10//! the 500-LOC cap.
11
12use crate::KevyResult;
13use std::time::Duration;
14
15use kevy_store::StoreError;
16
17use crate::pubsub::Subscription;
18use crate::store::ensure_writable;
19use crate::store::{Store, commit_write, store_err};
20
21// wasm has no replica runner, so the read-only guard is unconditionally a
22// no-op. Mirrors `replica_glue::ensure_writable`'s signature so the rest of
23// this file is target-agnostic.
24impl Store {
25 // ---- string ops -----------------------------------------------------
26
27 /// `SET key value` (no TTL, no NX/XX). Returns `true` always under the
28 /// embedded API (Redis semantics: SET overwrites; NX/XX vetoes would
29 /// return `false` but we don't expose those here — use [`Store::with`]
30 /// for the full surface).
31 pub fn set(&self, key: &[u8], value: &[u8]) -> KevyResult<bool> {
32 ensure_writable(self)?;
33 let mut g = self.wshard(key);
34 let ok = g.store.set(key, value.to_vec(), None, false, false);
35 commit_write(&mut g, &[b"SET", key, value])?;
36 Ok(ok)
37 }
38
39 /// `SET key value PX ms` — overwrites + sets TTL. The AOF records an
40 /// **absolute** `PEXPIREAT` deadline (not the relative `ttl`) so the key
41 /// expires at the same wall-clock instant after a restart — a relative
42 /// `PEXPIRE` would be re-anchored to replay-time, resetting the TTL to a
43 /// fresh full duration on every restart (seen as a production
44 /// incident: cache keys never expired across restarts).
45 pub fn set_with_ttl(&self, key: &[u8], value: &[u8], ttl: Duration) -> KevyResult<bool> {
46 ensure_writable(self)?;
47 let mut g = self.wshard(key);
48 let ok = g.store.set(key, value.to_vec(), Some(ttl), false, false);
49 let ms = ttl.as_millis().min(u128::from(u64::MAX)) as u64;
50 let deadline = kevy_store::now_unix_ms().saturating_add(ms);
51 commit_write(&mut g, &[b"SET", key, value])?;
52 commit_write(&mut g, &[b"PEXPIREAT", key, deadline.to_string().as_bytes()])?;
53 Ok(ok)
54 }
55
56 /// `GET key` — `Some(bytes)` on hit, `None` on miss or expired.
57 ///
58 /// The lock is **policy-gated** (see [`Self::reads_use_shared_lock`]):
59 /// whenever the active eviction policy won't consume a per-read LRU/LFU
60 /// tick — `maxmemory == 0` (the default), or the `NoEviction` /
61 /// `*Random` / `VolatileTtl` policies — this takes the **shared** lock and a
62 /// non-mutating [`get_shared`](kevy_store::Store::get_shared) lookup. That
63 /// is a lock-*correctness* choice: a read-only GET has no business holding
64 /// the exclusive lock and blocking a concurrent writer on its shard. It is
65 /// **not** a throughput win — concurrent GETs still contend on the shard's
66 /// `RwLock` word (there is no lock-free read path), so read scaling is
67 /// bounded by shard count, not core count. Expired keys still read as
68 /// `None` here (lazy expire-skip; the reaper / next write reclaims them).
69 /// Only the true LRU/LFU policies fall back to the exclusive lock + mutating
70 /// get so each access stamps the clock the eviction scorer ranks by.
71 pub fn get(&self, key: &[u8]) -> KevyResult<Option<Vec<u8>>> {
72 if self.reads_use_shared_lock() {
73 let g = self.rshard(key);
74 return Ok(g.store.get_shared(key).map_err(store_err)?.map(|c| c.into_owned()));
75 }
76 let mut g = self.wshard(key);
77 Ok(g.store.get(key).map_err(store_err)?.map(|c| c.into_owned()))
78 }
79
80 /// Whether the GET read-lane can take the SHARED shard lock rather than the
81 /// exclusive one — true when no per-read LRU/LFU tick would be consumed:
82 /// `maxmemory == 0` (eviction never runs), or the `NoEviction` / `*Random`
83 /// / `VolatileTtl` policies (whose scorer ignores the per-read clock —
84 /// writes still tick the clock the `*Random` policies sample). Only the
85 /// `*Lru` / `*Lfu` policies need the exclusive lock to stamp the access clock.
86 fn reads_use_shared_lock(&self) -> bool {
87 // Tiering consumes per-read state like LRU/LFU (gate marks +
88 // access-clock stamps) — it needs the exclusive lock.
89 #[cfg(all(feature = "tier", not(target_arch = "wasm32")))]
90 if self.config().tier_budget.is_some() {
91 return false;
92 }
93 let p = self.config().eviction_policy;
94 self.config().maxmemory == 0 || !(p.uses_lru() || p.uses_lfu())
95 }
96
97 /// `GET` for the FFI zero-copy *shared* lane (`kevy_get_shared`). Bulk
98 /// values come back as an `Arc::clone` — **no byte copy** — so the FFI can
99 /// hand JS a buffer viewing the engine's own storage (the win vs the plain
100 /// [`Self::get`], which `into_owned`-copies). The lookup is non-mutating;
101 /// the lock is policy-gated like [`Self::get`] ([`Self::reads_use_shared_lock`]).
102 /// This lane never stamps the LRU clock and never promotes a cold value.
103 pub fn get_shared_owned(&self, key: &[u8]) -> KevyResult<Option<kevy_store::GetShared>> {
104 if self.reads_use_shared_lock() {
105 let g = self.rshard(key);
106 return g.store.get_shared_owned(key).map_err(store_err);
107 }
108 let g = self.wshard(key);
109 g.store.get_shared_owned(key).map_err(store_err)
110 }
111
112 /// `DEL key1 [key2 ...]`. Returns the count of keys actually removed.
113 /// Keys fan out to their owning shards.
114 pub fn del(&self, keys: &[&[u8]]) -> KevyResult<usize> {
115 ensure_writable(self)?;
116 let mut total = 0;
117 for k in keys {
118 let mut g = self.wshard(k);
119 let n = g.store.del(&[*k]);
120 if n > 0 {
121 total += n;
122 commit_write(&mut g, &[b"DEL", k])?;
123 }
124 }
125 Ok(total)
126 }
127
128 /// `EXISTS key1 [key2 ...]`. Count of existing keys (duplicates counted
129 /// multiple times, matching Redis).
130 pub fn exists(&self, keys: &[&[u8]]) -> KevyResult<usize> {
131 let mut total = 0;
132 for k in keys {
133 total += self.wshard(k).store.exists(&[*k]);
134 }
135 Ok(total)
136 }
137
138 /// `INCR key`. Returns the post-increment value.
139 pub fn incr(&self, key: &[u8]) -> KevyResult<i64> {
140 self.incr_by(key, 1)
141 }
142
143 /// `INCRBY key delta`. Negative `delta` does DECR-style work.
144 pub fn incr_by(&self, key: &[u8], delta: i64) -> KevyResult<i64> {
145 ensure_writable(self)?;
146 let mut g = self.wshard(key);
147 let n = g.store.incr_by(key, delta).map_err(store_err)?;
148 commit_write(&mut g, &[b"INCRBY", key, delta.to_string().as_bytes()])?;
149 Ok(n)
150 }
151
152 /// `EXPIRE key seconds`. Returns `true` if a key was touched. The AOF
153 /// records an absolute `PEXPIREAT` deadline (see [`Self::set_with_ttl`])
154 /// so the TTL survives a restart unchanged.
155 pub fn expire(&self, key: &[u8], ttl: Duration) -> KevyResult<bool> {
156 ensure_writable(self)?;
157 let mut g = self.wshard(key);
158 let touched = g.store.expire(key, ttl);
159 if touched {
160 let ms = ttl.as_millis().min(u128::from(u64::MAX)) as u64;
161 let deadline = kevy_store::now_unix_ms().saturating_add(ms);
162 commit_write(&mut g, &[b"PEXPIREAT", key, deadline.to_string().as_bytes()])?;
163 }
164 Ok(touched)
165 }
166
167 /// `PERSIST key`. Returns `true` if a TTL was actually cleared.
168 pub fn persist(&self, key: &[u8]) -> KevyResult<bool> {
169 ensure_writable(self)?;
170 let mut g = self.wshard(key);
171 let touched = g.store.persist(key);
172 if touched {
173 commit_write(&mut g, &[b"PERSIST", key])?;
174 }
175 Ok(touched)
176 }
177
178 /// Remaining TTL in ms (or Redis-style `-1`/`-2` for no-TTL/no-key).
179 pub fn ttl_ms(&self, key: &[u8]) -> i64 {
180 self.wshard(key).store.pttl(key)
181 }
182
183 /// `TYPE key` — `"string"`, `"hash"`, `"list"`, `"set"`, `"zset"`, or `"none"`.
184 pub fn type_of(&self, key: &[u8]) -> &'static str {
185 self.wshard(key).store.type_of(key)
186 }
187
188 /// `DBSIZE` — total live keys across all shards.
189 ///
190 /// Aggregates under each shard's SHARED lock (the underlying `dbsize` is
191 /// `&self`). This is a latency fix, not a scaling one: a full-keyspace
192 /// count shouldn't hold every shard's *write* lock and stall concurrent
193 /// writers while it sums.
194 pub fn dbsize(&self) -> usize {
195 self.sum_shards_read(|i| i.store.dbsize())
196 }
197
198 /// `FLUSHALL` — empty every shard (each logs `FLUSHALL` so a replay reaches
199 /// the same empty state).
200 ///
201 /// Named `flushall` — **not** `flush` — to avoid colliding with
202 /// `Write::flush`'s "sync buffered writes to disk" meaning. This call
203 /// WIPES the store; durability needs no explicit call (each write appends
204 /// to the AOF, the shard's `BufWriter` lands per [`AppendFsync`] cadence
205 /// and on drop).
206 ///
207 /// [`AppendFsync`]: crate::AppendFsync
208 pub fn flushall(&self) -> KevyResult<()> {
209 ensure_writable(self)?;
210 let r = self.try_for_each_shard(|inner| {
211 inner.store.flushall();
212 commit_write(inner, &[b"FLUSHALL"])
213 });
214 // CDC feed contract: FLUSHALL breaks stream continuity.
215 #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
216 self.feed_bump_on_flush();
217 r
218 }
219
220 /// `MEMORY USAGE` for one key — `Some(bytes)` or `None` if absent.
221 ///
222 /// Read-only (`estimate_key_bytes` is `&self`), so it takes the shard's
223 /// SHARED lock rather than blocking a concurrent writer on that shard.
224 pub fn key_bytes(&self, key: &[u8]) -> Option<u64> {
225 self.rshard(key).store.estimate_key_bytes(key)
226 }
227
228 /// Live `used_memory` estimate (summed across shards).
229 ///
230 /// Aggregates under each shard's SHARED lock (latency fix — an INFO-time
231 /// memory sum shouldn't hold every shard's write lock; see [`Self::dbsize`]).
232 pub fn used_memory(&self) -> u64 {
233 self.sum_shards_u64_read(|i| i.store.used_memory())
234 }
235
236 /// `INFO`-style counter: total keys evicted by `maxmemory` (all shards).
237 ///
238 /// Read-only aggregation under each shard's SHARED lock (see [`Self::dbsize`]).
239 pub fn evictions_total(&self) -> u64 {
240 self.sum_shards_u64_read(|i| i.store.evictions_total())
241 }
242
243 /// `INFO`-style counter: total keys expired (lazy + active reaper, all shards).
244 ///
245 /// Read-only aggregation under each shard's SHARED lock (see [`Self::dbsize`]).
246 pub fn expired_keys_total(&self) -> u64 {
247 self.sum_shards_u64_read(|i| i.store.expired_keys_total())
248 }
249
250 // ---- hash ops -------------------------------------------------------
251
252 /// `HSET key field value [field value ...]`. Returns count newly added.
253 pub fn hset(&self, key: &[u8], pairs: &[(&[u8], &[u8])]) -> KevyResult<usize> {
254 ensure_writable(self)?;
255 let mut g = self.wshard(key);
256 let added = g.store.hset(key, pairs).map_err(store_err)?;
257 let mut parts: Vec<&[u8]> = Vec::with_capacity(2 + pairs.len() * 2);
258 parts.push(b"HSET");
259 parts.push(key);
260 for (f, v) in pairs {
261 parts.push(f);
262 parts.push(v);
263 }
264 commit_write(&mut g, &parts)?;
265 Ok(added)
266 }
267
268 /// `HGET key field`. `None` if absent.
269 pub fn hget(&self, key: &[u8], field: &[u8]) -> KevyResult<Option<Vec<u8>>> {
270 let mut g = self.wshard(key);
271 Ok(g.store
272 .hget(key, field)
273 .map_err(store_err)?
274 .map(<[u8]>::to_vec))
275 }
276
277 /// `HDEL key field [field ...]`. Returns count actually removed.
278 pub fn hdel(&self, key: &[u8], fields: &[&[u8]]) -> KevyResult<usize> {
279 ensure_writable(self)?;
280 let mut g = self.wshard(key);
281 let removed = g.store.hdel(key, fields).map_err(store_err)?;
282 if removed > 0 {
283 let mut parts: Vec<&[u8]> = Vec::with_capacity(2 + fields.len());
284 parts.push(b"HDEL");
285 parts.push(key);
286 for f in fields {
287 parts.push(f);
288 }
289 commit_write(&mut g, &parts)?;
290 }
291 Ok(removed)
292 }
293
294 // ---- list ops -------------------------------------------------------
295
296 /// `LPUSH key value [value ...]`. Returns the new list length.
297 pub fn lpush(&self, key: &[u8], values: &[&[u8]]) -> KevyResult<usize> {
298 push_helper(self, key, values, b"LPUSH", kevy_store::Store::lpush)
299 }
300
301 /// `RPUSH key value [value ...]`. Returns the new list length.
302 pub fn rpush(&self, key: &[u8], values: &[&[u8]]) -> KevyResult<usize> {
303 push_helper(self, key, values, b"RPUSH", kevy_store::Store::rpush)
304 }
305
306 /// `LPOP key count`. Returns popped values from the head.
307 pub fn lpop(&self, key: &[u8], count: usize) -> KevyResult<Vec<Vec<u8>>> {
308 pop_helper(self, key, count, false)
309 }
310
311 /// `RPOP key count`. Symmetric to `LPOP` from the tail.
312 pub fn rpop(&self, key: &[u8], count: usize) -> KevyResult<Vec<Vec<u8>>> {
313 pop_helper(self, key, count, true)
314 }
315
316 /// `LLEN key`. Length of the list at `key`; 0 if absent.
317 pub fn llen(&self, key: &[u8]) -> KevyResult<usize> {
318 self.wshard(key).store.llen(key).map_err(store_err)
319 }
320
321 // ---- set ops --------------------------------------------------------
322
323 /// `SADD key member [member ...]`. Returns count newly added.
324 pub fn sadd(&self, key: &[u8], members: &[&[u8]]) -> KevyResult<usize> {
325 push_helper(self, key, members, b"SADD", kevy_store::Store::sadd)
326 }
327
328 /// `SREM key member [member ...]`. Returns count actually removed.
329 pub fn srem(&self, key: &[u8], members: &[&[u8]]) -> KevyResult<usize> {
330 ensure_writable(self)?;
331 let mut g = self.wshard(key);
332 let removed = g.store.srem(key, members).map_err(store_err)?;
333 if removed > 0 {
334 let mut parts: Vec<&[u8]> = Vec::with_capacity(2 + members.len());
335 parts.push(b"SREM");
336 parts.push(key);
337 for m in members {
338 parts.push(m);
339 }
340 commit_write(&mut g, &parts)?;
341 }
342 Ok(removed)
343 }
344
345 /// `SMEMBERS key`. Order implementation-defined; empty if absent.
346 pub fn smembers(&self, key: &[u8]) -> KevyResult<Vec<Vec<u8>>> {
347 self.wshard(key).store.smembers(key).map_err(store_err)
348 }
349
350 /// `SCARD key`. Member count; 0 if absent.
351 pub fn scard(&self, key: &[u8]) -> KevyResult<usize> {
352 self.wshard(key).store.scard(key).map_err(store_err)
353 }
354
355 // ---- zset ops -------------------------------------------------------
356
357 /// `ZADD key score member [score member ...]`. Returns count newly added.
358 pub fn zadd(&self, key: &[u8], pairs: &[(f64, &[u8])]) -> KevyResult<usize> {
359 ensure_writable(self)?;
360 let mut g = self.wshard(key);
361 let added = g.store.zadd(key, pairs).map_err(store_err)?;
362 let mut score_strs: Vec<Vec<u8>> = Vec::with_capacity(pairs.len());
363 for (s, _) in pairs {
364 score_strs.push(format!("{s}").into_bytes());
365 }
366 let mut parts: Vec<&[u8]> = Vec::with_capacity(2 + pairs.len() * 2);
367 parts.push(b"ZADD");
368 parts.push(key);
369 for (i, (_, m)) in pairs.iter().enumerate() {
370 parts.push(&score_strs[i]);
371 parts.push(m);
372 }
373 commit_write(&mut g, &parts)?;
374 Ok(added)
375 }
376
377 /// `ZREM key member [member ...]`. Returns count actually removed.
378 pub fn zrem(&self, key: &[u8], members: &[&[u8]]) -> KevyResult<usize> {
379 ensure_writable(self)?;
380 let mut g = self.wshard(key);
381 let removed = g.store.zrem(key, members).map_err(store_err)?;
382 if removed > 0 {
383 let mut parts: Vec<&[u8]> = Vec::with_capacity(2 + members.len());
384 parts.push(b"ZREM");
385 parts.push(key);
386 for m in members {
387 parts.push(m);
388 }
389 commit_write(&mut g, &parts)?;
390 }
391 Ok(removed)
392 }
393
394 /// `ZSCORE key member`. `Some(score)` if present.
395 pub fn zscore(&self, key: &[u8], member: &[u8]) -> KevyResult<Option<f64>> {
396 self.wshard(key).store.zscore(key, member).map_err(store_err)
397 }
398
399 /// `ZCARD key`. Member count; 0 if absent.
400 pub fn zcard(&self, key: &[u8]) -> KevyResult<usize> {
401 self.wshard(key).store.zcard(key).map_err(store_err)
402 }
403
404 // ---- pub/sub --------------------------------------------------------
405
406 /// Dispatch one command as argv, appending the RESP-encoded reply to
407 /// `out` — the full read+write verb surface (`ESTORE_OPS` plus the
408 /// conn face). This is the generic entry the FFI layer builds every
409 /// language binding on: one function reaches the whole engine. The
410 /// read-only listener keeps its own narrower whitelist.
411 pub fn dispatch_argv(&self, argv: &[Vec<u8>], out: &mut Vec<u8>) {
412 crate::dispatch::dispatch(self, argv, out);
413 }
414
415 /// `PUBLISH channel payload`. Delivers `payload` to every subscriber on
416 /// `channel` (direct + pattern matches) inside this process. Returns
417 /// the count of receivers the message reached.
418 pub fn publish(&self, channel: &[u8], payload: &[u8]) -> usize {
419 // Clone matching senders under the lock, then release before
420 // send() so a slow receiver can't stall unrelated traffic.
421 let plans = {
422 // Pub/sub is process-wide; the bus lives on shard 0.
423 let g = self.lock();
424 g.bus.collect_delivery(channel, payload)
425 };
426 let mut count = 0;
427 for (frame, sender) in plans {
428 if sender.send(frame).is_ok() {
429 count += 1;
430 }
431 }
432 count
433 }
434
435 /// Open a [`Subscription`] subscribed to `channels`. Drop the handle
436 /// to unsubscribe from everything atomically. Pass `&[]` to start
437 /// with no subscriptions and add some later via
438 /// [`Subscription::subscribe`] / [`Subscription::psubscribe`].
439 pub fn subscribe(&self, channels: &[&[u8]]) -> Subscription {
440 let mut sub = Subscription::new(self.inner_handle(), self.guard_handle());
441 if !channels.is_empty() {
442 sub.subscribe(channels);
443 }
444 sub
445 }
446
447 /// Convenience: open a [`Subscription`] starting on pattern subscriptions.
448 pub fn psubscribe(&self, patterns: &[&[u8]]) -> Subscription {
449 let mut sub = Subscription::new(self.inner_handle(), self.guard_handle());
450 if !patterns.is_empty() {
451 sub.psubscribe(patterns);
452 }
453 sub
454 }
455}
456
457// ─────────────────────────────────────────────────────────────────────────
458// Shared list/set push + list pop helpers. `&Store` so we can lock + AOF-log.
459// ─────────────────────────────────────────────────────────────────────────
460
461fn push_helper<F>(
462 s: &Store,
463 key: &[u8],
464 values: &[&[u8]],
465 verb: &'static [u8],
466 op: F,
467) -> KevyResult<usize>
468where
469 F: FnOnce(&mut kevy_store::Store, &[u8], &[&[u8]]) -> Result<usize, StoreError>,
470{
471 ensure_writable(s)?;
472 let mut g = s.wshard(key);
473 let n = op(&mut g.store, key, values).map_err(store_err)?;
474 let mut parts: Vec<&[u8]> = Vec::with_capacity(2 + values.len());
475 parts.push(verb);
476 parts.push(key);
477 for v in values {
478 parts.push(v);
479 }
480 commit_write(&mut g, &parts)?;
481 Ok(n)
482}
483
484fn pop_helper(s: &Store, key: &[u8], count: usize, from_tail: bool) -> KevyResult<Vec<Vec<u8>>> {
485 ensure_writable(s)?;
486 let mut g = s.wshard(key);
487 let popped = if from_tail {
488 g.store.rpop(key, count).map_err(store_err)?
489 } else {
490 g.store.lpop(key, count).map_err(store_err)?
491 };
492 if !popped.is_empty() {
493 let verb: &[u8] = if from_tail { b"RPOP" } else { b"LPOP" };
494 let count_str = popped.len().to_string();
495 let parts: [&[u8]; 3] = [verb, key, count_str.as_bytes()];
496 commit_write(&mut g, &parts)?;
497 }
498 Ok(popped)
499}