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