Skip to main content

kevy_embedded/
store.rs

1//! [`Store`] — the embedded entry point. Wraps `kevy_store::Store` with
2//! per-shard locks (for cross-thread access), optional AOF auto-logging, an
3//! optional background TTL reaper, and an in-process pub/sub bus.
4
5use crate::KevyError;
6use crate::KevyResult;
7use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard};
8#[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
9use std::sync::Mutex;
10
11#[cfg(feature = "persist")]
12use kevy_persist::Argv;
13use kevy_store::ExpireStats;
14
15use crate::config::Config;
16use crate::shard::{build_shards, shard_idx};
17
18pub use crate::store_inner::WeakStore;
19pub(crate) use crate::store_inner::{DropGuard, Inner};
20
21/// The write gate every mutating facade entry crosses: rejects writes after
22/// [`Store::shutdown`] with [`KevyError::Closed`], and every local write on
23/// a replica with `READONLY`. One atomic load — free on the hot path.
24pub(crate) fn ensure_writable(store: &Store) -> Result<(), KevyError> {
25    if store.guard.shutdown.load(std::sync::atomic::Ordering::Acquire) {
26        return Err(KevyError::Closed);
27    }
28    #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
29    if store.is_replica() {
30        return Err(KevyError::ReadOnly);
31    }
32    Ok(())
33}
34
35/// The keyspace shards (`hash(key) % n`), each a fully independent
36/// `kevy_store::Store` + AOF behind its own lock. `n == 1` (the default) is a
37/// one-element vec = the original single-lock store.
38pub(crate) type Shards = Arc<Vec<Arc<RwLock<Inner>>>>;
39
40/// The embedded keyspace.
41///
42/// **`Store` is `Clone`**. A clone is a cheap `Arc` bump:
43/// every clone reaches the same underlying shards + AOF + reaper + pub/sub
44/// bus. The reaper thread is joined and each shard's AOF is flushed exactly
45/// once, when the **last** clone is dropped.
46///
47/// ```
48/// use kevy_embedded::{Config, Store};
49///
50/// # fn main() -> kevy_embedded::KevyResult<()> {
51/// let s = Store::open(Config::default().with_ttl_reaper_manual())?;
52/// let s2 = s.clone();
53/// std::thread::spawn(move || {
54///     s2.set(b"from-thread", b"v").unwrap();
55/// }).join().unwrap();
56/// assert_eq!(s.get(b"from-thread")?, Some(b"v".to_vec()));
57/// # Ok(())
58/// # }
59/// ```
60///
61/// Every method takes `&self`. Sharding (see [`Config::with_shards`]) lets a
62/// multi-threaded consumer scale across cores; pub/sub is process-wide
63/// (handled on shard 0).
64#[derive(Clone)]
65pub struct Store {
66    pub(crate) shards: Shards,
67    /// Shared drop guard: signals + joins reaper and flushes AOFs when the
68    /// LAST `Store` clone (or `Subscription`) holding a strong ref drops.
69    pub(crate) guard: Arc<DropGuard>,
70    pub(crate) config: Config,
71    /// CDC feed handle (read API side); shards carry clones for
72    /// the write side. `None` = feed off (or wasm).
73    #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
74    pub(crate) feed: Option<std::sync::Arc<Mutex<kevy_replicate::feed::FeedSource>>>,
75    /// Blocking-pop wake channel (always present; writers pay one
76    /// Relaxed load while nobody blocks).
77    pub(crate) blocker: Arc<crate::ops_blocking::Blocker>,
78    /// Index registry (catalog + version).
79    #[cfg(feature = "index")]
80    pub(crate) indexes: Arc<crate::ops_index::IndexReg>,
81    /// View registry.
82    #[cfg(feature = "index")]
83    pub(crate) views: Arc<crate::ops_view::ViewReg>,
84    /// Table registry (declarations only; runtime state = the
85    /// compiled indexes in `indexes`).
86    #[cfg(feature = "index")]
87    pub(crate) tables: Arc<crate::ops_table::TableReg>,
88    /// What this open's replay restored — and what it could not.
89    pub(crate) open_report: Arc<crate::metric::OpenReport>,
90}
91
92impl Store {
93    /// Open an embedded keyspace per `config`.
94    ///
95    /// - Pure in-memory when `config.data_dir` is `None`.
96    /// - With persistence: each shard loads its snapshot then replays its AOF
97    ///   (`config.shards > 1` re-shards a legacy single AOF on first open).
98    /// - Spawns a background TTL reaper thread when
99    ///   `config.ttl_reaper == Background` (the default).
100    /// - When `config.replica_upstream = Some("host:port")`, spawns a
101    ///   background thread that streams replication frames from the
102    ///   named primary and applies them to this store; local writes are
103    ///   rejected with `READONLY` (see [`Self::open_replica`]).
104    pub fn open(config: Config) -> KevyResult<Self> {
105        Self::open_inner(config)
106    }
107
108    /// What this open's replay restored — and, crucially, what it could
109    /// NOT: `dropped_bytes > 0` or `corrupt` means the store recovered
110    /// less than the files held (the dropped region was quarantined). Turn
111    /// this into a startup health check / alert — the machine-readable
112    /// twin of the boot WARN line.
113    pub fn open_report(&self) -> &crate::metric::OpenReport {
114        &self.open_report
115    }
116
117    /// Answer one RESP request against this store using the SAME
118    /// read-only verb whitelist the embedded RESP listener serves
119    /// (`Config::with_resp_listener`). The reply is appended to `out`
120    /// as raw RESP bytes; write verbs answer `-ERR` like the listener
121    /// does. This is the programmatic face of the listener — tooling
122    /// (e.g. `kevy-cli --embed`) inspects a store without a socket.
123    #[cfg(all(feature = "listener", not(target_arch = "wasm32")))]
124    pub fn dispatch_readonly(&self, argv: &[Vec<u8>], out: &mut Vec<u8>) {
125        crate::listener::verbs_dispatch(self, argv, out);
126    }
127
128    fn open_inner(config: Config) -> KevyResult<Self> {
129        let (shards, open_report) = build_shards(&config)?;
130        let shards: Shards = Arc::new(shards);
131        let (reaper_stop, reaper_join) = crate::reaper::spawn_reaper(&config, &shards)?;
132        #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
133        let (replica_runner, replica_source, feed) =
134            crate::store_wire::wire_replication(&config, &shards)?;
135        let blocker = crate::store_wire::wire_blocker(&shards);
136        #[cfg(feature = "index")]
137        let (indexes, views) = crate::store_wire::wire_registries(&shards);
138        let open_report = Arc::new(open_report);
139        // Guard construction (engine-lifetime state incl. the table
140        // registry — WeakStore::upgrade rebuilds from it) lives in
141        // `store_wire::build_guard`, split for the fn-length rule.
142        let guard = crate::store_wire::build_guard(
143            &open_report,
144            reaper_stop,
145            reaper_join,
146            &shards,
147            #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
148            replica_runner,
149            #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
150            replica_source,
151            #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
152            &feed,
153            &config,
154        );
155        #[cfg(feature = "index")]
156        let tables = guard.tables.clone();
157        let store = Store {
158            shards,
159            guard,
160            config,
161            #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
162            feed,
163            blocker,
164            #[cfg(feature = "index")]
165            indexes,
166            #[cfg(feature = "index")]
167            views,
168            #[cfg(feature = "index")]
169            tables,
170            open_report,
171        };
172        store.boot_ancillary()?;
173        Ok(store)
174    }
175
176    /// Post-construction bring-up: index/view boot scans and the
177    /// optional read-only RESP listener. Split from [`Self::open_inner`]
178    /// for the fn-length rule.
179    fn boot_ancillary(&self) -> KevyResult<()> {
180        #[cfg(feature = "index")]
181        self.idx_boot();
182        #[cfg(feature = "index")]
183        self.view_boot();
184        #[cfg(feature = "index")]
185        self.table_boot();
186        #[cfg(all(feature = "listener", not(target_arch = "wasm32")))]
187        if let Some(addr) = self.config.resp_listener {
188            crate::listener::spawn(addr, self.downgrade())?;
189        }
190        Ok(())
191    }
192
193    /// Convenience constructor for an embed-as-read-replica store
194    /// streaming writes from `upstream` (`"host:port"` of a kevy
195    /// server's replication listener).
196    ///
197    /// The replica:
198    /// - has its local AOF force-disabled (the upstream stream is the
199    ///   source of truth; replica AOF would diverge and double-apply
200    ///   on restart);
201    /// - rejects every local write with a `READONLY` `io::Error`
202    ///   (you can still call read APIs concurrently);
203    /// - reconnects with exponential backoff on disconnect, resuming
204    ///   from the last applied offset;
205    /// - gets a process-unique `replica_id` so an open / drop / reopen
206    ///   cycle within the primary's reconnect window does not look like
207    ///   the same slot from the primary's POV (which would evict
208    ///   backlog frames the new embed still needs from offset 0).
209    ///   Override via [`Config::with_replica_id`] when you specifically
210    ///   want the slot to be re-claimed across restarts.
211    ///
212    /// For full builder control (custom replica id, backoff bounds,
213    /// snapshot dir, etc.) use [`Self::open`] with
214    /// [`Config::with_replica_upstream`] + the related setters
215    /// instead.
216    #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
217    pub fn open_replica(upstream: impl Into<String>) -> KevyResult<Self> {
218        let cfg = Config::default()
219            .without_aof()
220            .with_replica_id(crate::replica_glue::fresh_replica_id())
221            .with_replica_upstream(upstream);
222        Self::open(cfg)
223    }
224
225    /// `true` when this store was opened against a replication
226    /// upstream — local writes are rejected with `READONLY`.
227    #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
228    pub fn is_replica(&self) -> bool {
229        self.config.replica_upstream.is_some()
230    }
231
232    /// Flush every shard's AOF to disk (a real fsync), write the feed
233    /// continuity marker, then refuse every later write (they fail with
234    /// [`KevyError::Closed`]; reads stay available). Idempotent and
235    /// clone-safe: any clone's `shutdown` gates them all, so a signal
236    /// handler's teardown is two deterministic lines —
237    /// `store.shutdown()?; std::process::exit(0)` — instead of praying
238    /// every task's `Arc<Store>` drops in time. Writes racing the call
239    /// may land after the fsync; writes issued after it returns cannot.
240    pub fn shutdown(&self) -> std::io::Result<()> {
241        use std::sync::atomic::Ordering;
242        self.guard.shutdown.store(true, Ordering::Release);
243        #[cfg(feature = "persist")]
244        for shard in self.shards.iter() {
245            let mut g = lock_write(shard);
246            if let Some(aof) = &mut g.aof {
247                aof.sync_now()?;
248            }
249        }
250        #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
251        if let (Some(feed), Some(dir)) = (&self.feed, &self.config.data_dir) {
252            Store::feed_write_close_marker(feed, dir);
253        }
254        Ok(())
255    }
256
257    /// Retarget this replica at a new primary URL (`host:port`). The
258    /// runner picks up the change on its next connect — which is
259    /// forced now by `shutdown`ing the current socket clone, so the
260    /// retarget lands within `Config::replica_reconnect_min` (default
261    /// 100 ms) of this call.
262    ///
263    /// Returns `Err` with `ErrorKind::InvalidInput` when this store is
264    /// not a replica (no upstream was configured at open). Application
265    /// code typically drives this from a `kevy-elect` failover signal —
266    /// see [`docs/cluster.md`](https://github.com/goliajp/kevy/blob/develop/docs/cluster.md).
267    /// `kevy-embedded` itself stays elect-protocol-agnostic; the
268    /// integration glue lives in the application.
269    #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
270    pub fn set_replica_upstream(&self, new_upstream: impl Into<String>) -> KevyResult<()> {
271        if !self.is_replica() {
272            return Err(KevyError::InvalidInput("set_replica_upstream called on a non-replica store".into()));
273        }
274        let Some(runner) = self.guard.replica_runner.as_ref() else {
275            return Err(KevyError::InvalidInput("replica runner is not active (open was racy?)".into()));
276        };
277        runner.set_upstream(new_upstream.into());
278        Ok(())
279    }
280
281    /// The active config (a clone — modifying it has no effect on the
282    /// running store). Useful for introspection / `INFO`-style telemetry.
283    pub fn config(&self) -> &Config {
284        &self.config
285    }
286
287    // ---- escape hatches -------------------------------------------------
288
289    /// Run `f` against the underlying `kevy_store::Store` under its lock. Use
290    /// for direct access to methods this crate hasn't wrapped. The closure can
291    /// mutate, but *does not auto-log to the AOF* — call [`Self::log`] yourself
292    /// if the mutation must survive a crash.
293    ///
294    /// **Sharded stores:** this targets shard 0 only. Use [`Self::with_key`]
295    /// to reach the shard owning a specific key.
296    pub fn with<F, R>(&self, f: F) -> R
297    where
298        F: FnOnce(&mut kevy_store::Store) -> R,
299    {
300        let mut g = self.lock();
301        f(&mut g.store)
302    }
303
304    /// Like [`Self::with`] but targets the shard that owns `key`.
305    pub fn with_key<F, R>(&self, key: &[u8], f: F) -> R
306    where
307        F: FnOnce(&mut kevy_store::Store) -> R,
308    {
309        let mut g = self.wshard(key);
310        f(&mut g.store)
311    }
312
313    /// `KEYS` / `SCAN`-glob across **every shard** — the cross-shard
314    /// replacement for `with(|s| s.collect_keys(pat, lim))`, which only sees
315    /// shard 0 once sharding is on. Behaves identically to `with(...)` when
316    /// `shard_count() == 1`. `limit` bounds the *total* returned across shards.
317    /// Takes a read lock per shard (concurrent-safe).
318    pub fn collect_keys(&self, pattern: Option<&[u8]>, limit: Option<usize>) -> Vec<Vec<u8>> {
319        let mut out = Vec::new();
320        for shard in self.shards.iter() {
321            if limit.is_some_and(|l| out.len() >= l) {
322                break;
323            }
324            let remaining = limit.map(|l| l - out.len());
325            out.extend(lock_read(shard).store.collect_keys(pattern, remaining));
326        }
327        out
328    }
329
330    /// Run `f` against **each shard's** underlying `kevy_store::Store` (in
331    /// shard-index order) — the cross-shard escape hatch. The caller assembles
332    /// the merged result. Pairs with [`Self::shard_count`]. For a single key,
333    /// prefer [`Self::with_key`]; for a glob scan, prefer [`Self::collect_keys`].
334    pub fn for_each_shard<F: FnMut(&mut kevy_store::Store)>(&self, mut f: F) {
335        for shard in self.shards.iter() {
336            f(&mut lock_write(shard).store);
337        }
338    }
339
340    /// Number of keyspace shards (`== Config::shards`).
341    #[inline]
342    pub fn shard_count(&self) -> usize {
343        self.shards.len()
344    }
345
346    /// Append a raw RESP-frame argument list to the shard owning its key's
347    /// AOF. No-op when persistence is disabled.
348    #[cfg(feature = "persist")]
349    pub fn log(&self, parts: &[&[u8]]) -> KevyResult<()> {
350        let mut g = match parts.get(1) {
351            Some(key) => self.wshard(key),
352            None => self.lock(),
353        };
354        if let Some(aof) = &mut g.aof {
355            let argv = Argv::from(parts.iter().map(|p| p.to_vec()).collect::<Vec<_>>());
356            aof.append(&argv)?;
357        }
358        Ok(())
359    }
360
361    // ---- maintenance ----------------------------------------------------
362
363    /// Run one TTL-reaper tick across every shard. Required call cadence in
364    /// `Manual` mode (~10×/s to match Redis `hz=10`). Returns the summed stats.
365    pub fn tick(&self) -> ExpireStats {
366        let mut total = ExpireStats::default();
367        for shard in self.shards.iter() {
368            let stats = {
369                let mut g = lock_write(shard);
370                // Tiering upkeep: budget re-resolution + the index/view
371                // floor feed, then the tick continuation of the
372                // budgeted spill.
373                #[cfg(all(feature = "tier", not(target_arch = "wasm32")))]
374                crate::shard::tier_tick_upkeep(&mut g, self.config.tier_budget, self.shards.len());
375                let _ = g.store.demote_step();
376                let _ = g.store.tier_compact_tick();
377                g.store.tick_expire(self.config.reaper_samples, self.config.reaper_max_rounds)
378            };
379            total.sampled += stats.sampled;
380            total.expired += stats.expired;
381            // Auto-rewrite rides the caller-driven tick in Manual mode; the
382            // non-blocking path releases the lock for the disk spill.
383            #[cfg(feature = "persist")]
384            crate::reaper::concurrent_auto_rewrite(
385                shard,
386                kevy_persist::RewritePolicy {
387                    pct: self.config.auto_aof_rewrite_pct,
388                    min_size: self.config.auto_aof_rewrite_min_size,
389                    bytes: self.config.auto_aof_rewrite_bytes,
390                    interval_secs: self.config.auto_aof_rewrite_interval_secs,
391                },
392                self.config.metric_sink.as_ref(),
393            );
394        }
395        total
396    }
397
398    /// The B9 transparency suite's deterministic demotion seam
399    /// (`KEVY_TEST_FORCE_DEMOTE` genre): demote `key` to the cold tier
400    /// NOW, ignoring the watermark — the suite drives cold state
401    /// per-key, never by eviction timing. Returns whether a demotion
402    /// happened (false: tiering off / key absent / not spillable).
403    #[cfg(all(feature = "tier", not(target_arch = "wasm32")))]
404    #[doc(hidden)]
405    pub fn debug_force_demote(&self, key: &[u8]) -> bool {
406        self.wshard(key).store.debug_force_demote(key)
407    }
408
409    /// Tiering counters summed across shards:
410    /// `(demotions_total, promotions_total)` — the minimal counter pair
411    /// (the full INFO gauge set is `tier_info`). Zeros when tiering is off.
412    #[cfg(all(feature = "tier", not(target_arch = "wasm32")))]
413    pub fn tier_counters(&self) -> (u64, u64) {
414        let mut d = 0u64;
415        let mut p = 0u64;
416        for shard in self.shards.iter() {
417            let s = lock_read(shard).store.tier_stats();
418            d += s.demotions_total;
419            p += s.promotions_total;
420        }
421        (d, p)
422    }
423
424    // Durability methods (`rewrite_aof`, `save_snapshot`) live in
425    // `crate::store_persist` to keep this file under the 500-LOC
426    // project ceiling.
427    // Data-type methods live in `crate::ops` / `crate::info`.
428
429    /// Crate-internal: clone shard 0's handle for a `Subscription`'s bus.
430    pub(crate) fn inner_handle(&self) -> Arc<RwLock<Inner>> {
431        self.shards[0].clone()
432    }
433
434    /// Crate-internal: clone the shared `Arc<DropGuard>`.
435    pub(crate) fn guard_handle(&self) -> Arc<DropGuard> {
436        self.guard.clone()
437    }
438
439    fn shard_for(&self, key: &[u8]) -> &Arc<RwLock<Inner>> {
440        &self.shards[shard_idx(key, self.shards.len())]
441    }
442
443    /// Write-lock the shard owning `key`.
444    pub(crate) fn wshard(&self, key: &[u8]) -> RwLockWriteGuard<'_, Inner> {
445        lock_write(self.shard_for(key))
446    }
447
448    /// Read-lock the shard owning `key` (GET fast path — concurrent readers
449    /// across shards run in parallel).
450    pub(crate) fn rshard(&self, key: &[u8]) -> RwLockReadGuard<'_, Inner> {
451        lock_read(self.shard_for(key))
452    }
453
454    /// Write-lock shard 0 — pub/sub bus + keyless escape hatches.
455    pub(crate) fn lock(&self) -> RwLockWriteGuard<'_, Inner> {
456        lock_write(&self.shards[0])
457    }
458
459    /// Run `f` over every shard's write guard, summing a `usize` (DBSIZE etc.).
460    pub(crate) fn sum_shards<F: Fn(&mut Inner) -> usize>(&self, f: F) -> usize {
461        self.shards.iter().map(|s| f(&mut lock_write(s))).sum()
462    }
463
464    /// Run `f` over every shard's write guard, summing a `u64`.
465    pub(crate) fn sum_shards_u64<F: Fn(&mut Inner) -> u64>(&self, f: F) -> u64 {
466        self.shards.iter().map(|s| f(&mut lock_write(s))).sum()
467    }
468
469    /// Read-lock variant of [`Self::sum_shards`]: takes each shard's SHARED
470    /// lock for read-only aggregations (DBSIZE etc.) that never mutate the
471    /// keyspace — the underlying counter methods are all `&self`.
472    pub(crate) fn sum_shards_read<F: Fn(&Inner) -> usize>(&self, f: F) -> usize {
473        self.shards.iter().map(|s| f(&lock_read(s))).sum()
474    }
475
476    /// `u64` read-lock variant of [`Self::sum_shards_read`].
477    pub(crate) fn sum_shards_u64_read<F: Fn(&Inner) -> u64>(&self, f: F) -> u64 {
478        self.shards.iter().map(|s| f(&lock_read(s))).sum()
479    }
480
481    /// Run a fallible `f` over every shard (mutating, e.g. FLUSHALL).
482    pub(crate) fn try_for_each_shard<F: FnMut(&mut Inner) -> KevyResult<()>>(
483        &self,
484        mut f: F,
485    ) -> KevyResult<()> {
486        for s in self.shards.iter() {
487            f(&mut lock_write(s))?;
488        }
489        Ok(())
490    }
491}
492
493
494pub(crate) use crate::store_glue::{commit_write, lock_read, lock_write, store_err};
495
496#[cfg(test)]
497#[path = "store_test_suites.rs"]
498mod test_suites;