Skip to main content

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