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 std::io;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::{Arc, Mutex, RwLock, RwLockReadGuard, RwLockWriteGuard, Weak};
8use std::thread::JoinHandle;
9
10use kevy_persist::{Aof, Argv};
11use kevy_store::{ExpireStats, StoreError};
12
13use crate::config::Config;
14use crate::pubsub::PubsubBus;
15use crate::shard::{build_shards, shard_idx};
16
17/// The keyspace shards (`hash(key) % n`), each a fully independent
18/// `kevy_store::Store` + AOF behind its own lock. `n == 1` (the default) is a
19/// one-element vec = the original single-lock store.
20pub(crate) type Shards = Arc<Vec<Arc<RwLock<Inner>>>>;
21
22/// The embedded keyspace.
23///
24/// **`Store` is `Clone`** (since v1.1.0). A clone is a cheap `Arc` bump:
25/// every clone reaches the same underlying shards + AOF + reaper + pub/sub
26/// bus. The reaper thread is joined and each shard's AOF is flushed exactly
27/// once, when the **last** clone is dropped.
28///
29/// ```
30/// use kevy_embedded::{Config, Store};
31///
32/// # fn main() -> std::io::Result<()> {
33/// let s = Store::open(Config::default().with_ttl_reaper_manual())?;
34/// let s2 = s.clone();
35/// std::thread::spawn(move || {
36///     s2.set(b"from-thread", b"v").unwrap();
37/// }).join().unwrap();
38/// assert_eq!(s.get(b"from-thread")?, Some(b"v".to_vec()));
39/// # Ok(())
40/// # }
41/// ```
42///
43/// Every method takes `&self`. Sharding (see [`Config::with_shards`]) lets a
44/// multi-threaded consumer scale across cores; pub/sub is process-wide
45/// (handled on shard 0).
46#[derive(Clone)]
47pub struct Store {
48    pub(crate) shards: Shards,
49    /// Shared drop guard: signals + joins reaper and flushes AOFs when the
50    /// LAST `Store` clone (or `Subscription`) holding a strong ref drops.
51    pub(crate) guard: Arc<DropGuard>,
52    pub(crate) config: Config,
53}
54
55/// Weak handle to a `Store` — does not keep the underlying keyspace alive.
56///
57/// Used by the URL-keyed registry in `kevy-client` so that multiple
58/// `Connection::open("mem://name")` calls share the same backing store
59/// without leaking it when all strong handles go away.
60pub struct WeakStore {
61    shards: Weak<Vec<Arc<RwLock<Inner>>>>,
62    guard: Weak<DropGuard>,
63    config: Config,
64}
65
66impl WeakStore {
67    /// Try to upgrade back to a `Store`. Returns `None` if the last strong
68    /// reference has already been dropped.
69    pub fn upgrade(&self) -> Option<Store> {
70        Some(Store {
71            shards: self.shards.upgrade()?,
72            guard: self.guard.upgrade()?,
73            config: self.config.clone(),
74        })
75    }
76}
77
78pub(crate) struct Inner {
79    pub(crate) store: kevy_store::Store,
80    pub(crate) aof: Option<Aof>,
81    /// Pub/sub bus. Only shard 0's is ever used (pub/sub is process-wide);
82    /// other shards carry an idle one (cheap).
83    pub(crate) bus: PubsubBus,
84    /// Phase 3 / v1.21: shared replication source if this store is
85    /// an embed-as-writer. Every shard holds a clone of the same
86    /// `Arc<Mutex<...>>` so `commit_write` can push mutations
87    /// without reaching back up through the `DropGuard`.
88    #[cfg(not(target_arch = "wasm32"))]
89    pub(crate) writer_source:
90        Option<std::sync::Arc<Mutex<kevy_replicate::source::ReplicationSource>>>,
91}
92
93impl Inner {
94    pub(crate) fn new(store: kevy_store::Store, aof: Option<Aof>) -> Self {
95        Inner {
96            store,
97            aof,
98            bus: PubsubBus::new(),
99            #[cfg(not(target_arch = "wasm32"))]
100            writer_source: None,
101        }
102    }
103}
104
105/// Owns the reaper-thread handle + the shards for the final AOF flush. Lives
106/// in an `Arc<DropGuard>` shared across every `Store` clone; the drop logic
107/// fires only when the last clone goes away.
108pub(crate) struct DropGuard {
109    reaper_stop: Option<Arc<AtomicBool>>,
110    reaper_join: Mutex<Option<JoinHandle<()>>>,
111    shards_for_flush: Shards,
112    /// Replica runner thread + reconnect machinery, present iff this
113    /// store was opened with `Config::replica_upstream = Some(...)`.
114    /// Joined here so the runner stops cleanly when the last `Store`
115    /// clone goes away.
116    #[cfg(not(target_arch = "wasm32"))]
117    pub(crate) replica_runner: Option<crate::replica_runner::ReplicaRunner>,
118    /// Replica-source listener + accepted connection threads, present
119    /// iff this store is a Phase 3 embed-as-writer
120    /// (`Config::embed_writer_listen_addr = Some(...)`). Joined on
121    /// last-clone drop.
122    #[cfg(not(target_arch = "wasm32"))]
123    pub(crate) replica_source: Option<crate::replica_source::ReplicaSource>,
124}
125
126impl Store {
127    /// Open an embedded keyspace per `config`.
128    ///
129    /// - Pure in-memory when `config.data_dir` is `None`.
130    /// - With persistence: each shard loads its snapshot then replays its AOF
131    ///   (`config.shards > 1` re-shards a legacy single AOF on first open).
132    /// - Spawns a background TTL reaper thread when
133    ///   `config.ttl_reaper == Background` (the default).
134    /// - When `config.replica_upstream = Some("host:port")`, spawns a
135    ///   background thread that streams replication frames from the
136    ///   named primary and applies them to this store; local writes are
137    ///   rejected with `READONLY` (see [`Self::open_replica`]).
138    pub fn open(config: Config) -> io::Result<Self> {
139        let shards: Shards = Arc::new(build_shards(&config)?);
140        let (reaper_stop, reaper_join) = crate::reaper::spawn_reaper(&config, &shards)?;
141        #[cfg(not(target_arch = "wasm32"))]
142        let replica_runner = crate::replica_glue::spawn_replica_runner(&config, &shards);
143        #[cfg(not(target_arch = "wasm32"))]
144        let replica_source = match config.embed_writer_listen_addr.as_ref() {
145            Some(addr) => {
146                let rs = crate::replica_source::ReplicaSource::spawn(
147                    addr,
148                    config.embed_writer_backlog_bytes,
149                )?;
150                // Inject the shared source Arc into every shard's
151                // Inner so `commit_write` pushes mutations into the
152                // backlog inline. Done once at open under the
153                // shard's write lock; reads of `Inner::writer_source`
154                // afterwards are uncontended.
155                let shared = rs.shared_source();
156                for shard in shards.iter() {
157                    let mut g = lock_write(shard);
158                    g.writer_source = Some(shared.clone());
159                }
160                Some(rs)
161            }
162            None => None,
163        };
164        let guard = Arc::new(DropGuard {
165            reaper_stop,
166            reaper_join: Mutex::new(reaper_join),
167            shards_for_flush: shards.clone(),
168            #[cfg(not(target_arch = "wasm32"))]
169            replica_runner,
170            #[cfg(not(target_arch = "wasm32"))]
171            replica_source,
172        });
173        Ok(Store { shards, guard, config })
174    }
175
176    /// Convenience constructor for an embed-as-read-replica store
177    /// streaming writes from `upstream` (`"host:port"` of a kevy
178    /// server's replication listener).
179    ///
180    /// The replica:
181    /// - has its local AOF force-disabled (the upstream stream is the
182    ///   source of truth; replica AOF would diverge and double-apply
183    ///   on restart);
184    /// - rejects every local write with a `READONLY` `io::Error`
185    ///   (you can still call read APIs concurrently);
186    /// - reconnects with exponential backoff on disconnect, resuming
187    ///   from the last applied offset;
188    /// - gets a process-unique `replica_id` so an open / drop / reopen
189    ///   cycle within the primary's reconnect window does not look like
190    ///   the same slot from the primary's POV (which would evict
191    ///   backlog frames the new embed still needs from offset 0).
192    ///   Override via [`Config::with_replica_id`] when you specifically
193    ///   want the slot to be re-claimed across restarts.
194    ///
195    /// For full builder control (custom replica id, backoff bounds,
196    /// snapshot dir, etc.) use [`Self::open`] with
197    /// [`Config::with_replica_upstream`] + the related setters
198    /// instead.
199    #[cfg(not(target_arch = "wasm32"))]
200    pub fn open_replica(upstream: impl Into<String>) -> io::Result<Self> {
201        let cfg = Config::default()
202            .without_aof()
203            .with_replica_id(crate::replica_glue::fresh_replica_id())
204            .with_replica_upstream(upstream);
205        Self::open(cfg)
206    }
207
208    /// `true` when this store was opened against a replication
209    /// upstream — local writes are rejected with `READONLY`.
210    pub fn is_replica(&self) -> bool {
211        self.config.replica_upstream.is_some()
212    }
213
214    /// Retarget this replica at a new primary URL (`host:port`). The
215    /// runner picks up the change on its next connect — which is
216    /// forced now by `shutdown`ing the current socket clone, so the
217    /// retarget lands within `Config::replica_reconnect_min` (default
218    /// 100 ms) of this call.
219    ///
220    /// Returns `Err` with `ErrorKind::InvalidInput` when this store is
221    /// not a replica (no upstream was configured at open). Application
222    /// code typically drives this from a kevy-elect failover signal —
223    /// see `docs/cluster.md` "embed-as-read-replica" / Phase 2 / T2.7.
224    /// kevy-embedded itself stays elect-protocol-agnostic; the
225    /// integration glue lives in the application.
226    #[cfg(not(target_arch = "wasm32"))]
227    pub fn set_replica_upstream(&self, new_upstream: impl Into<String>) -> io::Result<()> {
228        if !self.is_replica() {
229            return Err(io::Error::new(
230                io::ErrorKind::InvalidInput,
231                "set_replica_upstream called on a non-replica store",
232            ));
233        }
234        let Some(runner) = self.guard.replica_runner.as_ref() else {
235            return Err(io::Error::new(
236                io::ErrorKind::InvalidInput,
237                "replica runner is not active (open was racy?)",
238            ));
239        };
240        runner.set_upstream(new_upstream.into());
241        Ok(())
242    }
243
244    /// Get a weak handle that does not keep the keyspace alive.
245    pub fn downgrade(&self) -> WeakStore {
246        WeakStore {
247            shards: Arc::downgrade(&self.shards),
248            guard: Arc::downgrade(&self.guard),
249            config: self.config.clone(),
250        }
251    }
252
253    /// The active config (a clone — modifying it has no effect on the
254    /// running store). Useful for introspection / `INFO`-style telemetry.
255    pub fn config(&self) -> &Config {
256        &self.config
257    }
258
259    // ---- escape hatches -------------------------------------------------
260
261    /// Run `f` against the underlying `kevy_store::Store` under its lock. Use
262    /// for direct access to methods this crate hasn't wrapped. The closure can
263    /// mutate, but *does not auto-log to the AOF* — call [`Self::log`] yourself
264    /// if the mutation must survive a crash.
265    ///
266    /// **Sharded stores:** this targets shard 0 only. Use [`Self::with_key`]
267    /// to reach the shard owning a specific key.
268    pub fn with<F, R>(&self, f: F) -> R
269    where
270        F: FnOnce(&mut kevy_store::Store) -> R,
271    {
272        let mut g = self.lock();
273        f(&mut g.store)
274    }
275
276    /// Like [`Self::with`] but targets the shard that owns `key`.
277    pub fn with_key<F, R>(&self, key: &[u8], f: F) -> R
278    where
279        F: FnOnce(&mut kevy_store::Store) -> R,
280    {
281        let mut g = self.wshard(key);
282        f(&mut g.store)
283    }
284
285    /// `KEYS` / `SCAN`-glob across **every shard** — the cross-shard
286    /// replacement for `with(|s| s.collect_keys(pat, lim))`, which only sees
287    /// shard 0 once sharding is on. Behaves identically to `with(...)` when
288    /// `shard_count() == 1`. `limit` bounds the *total* returned across shards.
289    /// Takes a read lock per shard (concurrent-safe).
290    pub fn collect_keys(&self, pattern: Option<&[u8]>, limit: Option<usize>) -> Vec<Vec<u8>> {
291        let mut out = Vec::new();
292        for shard in self.shards.iter() {
293            if limit.is_some_and(|l| out.len() >= l) {
294                break;
295            }
296            let remaining = limit.map(|l| l - out.len());
297            out.extend(lock_read(shard).store.collect_keys(pattern, remaining));
298        }
299        out
300    }
301
302    /// Run `f` against **each shard's** underlying `kevy_store::Store` (in
303    /// shard-index order) — the cross-shard escape hatch. The caller assembles
304    /// the merged result. Pairs with [`Self::shard_count`]. For a single key,
305    /// prefer [`Self::with_key`]; for a glob scan, prefer [`Self::collect_keys`].
306    pub fn for_each_shard<F: FnMut(&mut kevy_store::Store)>(&self, mut f: F) {
307        for shard in self.shards.iter() {
308            f(&mut lock_write(shard).store);
309        }
310    }
311
312    /// Number of keyspace shards (`== Config::shards`).
313    #[inline]
314    pub fn shard_count(&self) -> usize {
315        self.shards.len()
316    }
317
318    /// Append a raw RESP-frame argument list to the shard owning its key's
319    /// AOF. No-op when persistence is disabled.
320    pub fn log(&self, parts: &[&[u8]]) -> io::Result<()> {
321        let mut g = match parts.get(1) {
322            Some(key) => self.wshard(key),
323            None => self.lock(),
324        };
325        if let Some(aof) = &mut g.aof {
326            let argv = Argv::from(parts.iter().map(|p| p.to_vec()).collect::<Vec<_>>());
327            aof.append(&argv)?;
328        }
329        Ok(())
330    }
331
332    // ---- maintenance ----------------------------------------------------
333
334    /// Run one TTL-reaper tick across every shard. Required call cadence in
335    /// `Manual` mode (~10×/s to match Redis `hz=10`). Returns the summed stats.
336    pub fn tick(&self) -> ExpireStats {
337        let mut total = ExpireStats::default();
338        for shard in self.shards.iter() {
339            let stats = {
340                let mut g = lock_write(shard);
341                g.store.tick_expire(self.config.reaper_samples, self.config.reaper_max_rounds)
342            };
343            total.sampled += stats.sampled;
344            total.expired += stats.expired;
345            // Auto-rewrite rides the caller-driven tick in Manual mode; the
346            // non-blocking path releases the lock for the disk spill.
347            crate::reaper::concurrent_auto_rewrite(
348                shard,
349                self.config.auto_aof_rewrite_pct,
350                self.config.auto_aof_rewrite_min_size,
351                self.config.metric_sink.as_ref(),
352            );
353        }
354        total
355    }
356
357    // Durability methods (`rewrite_aof`, `save_snapshot`) live in
358    // `crate::store_persist` to keep this file under the 500-LOC
359    // project ceiling.
360    // Data-type methods live in `crate::ops` / `crate::info`.
361
362    /// Crate-internal: clone shard 0's handle for a `Subscription`'s bus.
363    pub(crate) fn inner_handle(&self) -> Arc<RwLock<Inner>> {
364        self.shards[0].clone()
365    }
366
367    /// Crate-internal: clone the shared `Arc<DropGuard>`.
368    pub(crate) fn guard_handle(&self) -> Arc<DropGuard> {
369        self.guard.clone()
370    }
371
372    fn shard_for(&self, key: &[u8]) -> &Arc<RwLock<Inner>> {
373        &self.shards[shard_idx(key, self.shards.len())]
374    }
375
376    /// Write-lock the shard owning `key`.
377    pub(crate) fn wshard(&self, key: &[u8]) -> RwLockWriteGuard<'_, Inner> {
378        lock_write(self.shard_for(key))
379    }
380
381    /// Read-lock the shard owning `key` (GET fast path — concurrent readers
382    /// across shards run in parallel).
383    pub(crate) fn rshard(&self, key: &[u8]) -> RwLockReadGuard<'_, Inner> {
384        lock_read(self.shard_for(key))
385    }
386
387    /// Write-lock shard 0 — pub/sub bus + keyless escape hatches.
388    pub(crate) fn lock(&self) -> RwLockWriteGuard<'_, Inner> {
389        lock_write(&self.shards[0])
390    }
391
392    /// Run `f` over every shard's write guard, summing a `usize` (DBSIZE etc.).
393    pub(crate) fn sum_shards<F: Fn(&mut Inner) -> usize>(&self, f: F) -> usize {
394        self.shards.iter().map(|s| f(&mut lock_write(s))).sum()
395    }
396
397    /// Run `f` over every shard's write guard, summing a `u64`.
398    pub(crate) fn sum_shards_u64<F: Fn(&mut Inner) -> u64>(&self, f: F) -> u64 {
399        self.shards.iter().map(|s| f(&mut lock_write(s))).sum()
400    }
401
402    /// Run a fallible `f` over every shard (mutating, e.g. FLUSHALL).
403    pub(crate) fn try_for_each_shard<F: FnMut(&mut Inner) -> io::Result<()>>(
404        &self,
405        mut f: F,
406    ) -> io::Result<()> {
407        for s in self.shards.iter() {
408            f(&mut lock_write(s))?;
409        }
410        Ok(())
411    }
412}
413
414/// Write-lock an `Inner`, recovering from poison (short critical sections; a
415/// panic in one doesn't corrupt the keyspace).
416pub(crate) fn lock_write(shard: &RwLock<Inner>) -> RwLockWriteGuard<'_, Inner> {
417    shard.write().unwrap_or_else(std::sync::PoisonError::into_inner)
418}
419
420/// Read-lock an `Inner`, recovering from poison.
421pub(crate) fn lock_read(shard: &RwLock<Inner>) -> RwLockReadGuard<'_, Inner> {
422    shard.read().unwrap_or_else(std::sync::PoisonError::into_inner)
423}
424
425fn log_argv(aof: &mut Option<Aof>, parts: &[&[u8]]) -> io::Result<()> {
426    if let Some(aof) = aof {
427        let argv = Argv::from(parts.iter().map(|p| p.to_vec()).collect::<Vec<_>>());
428        aof.append(&argv)?;
429    }
430    Ok(())
431}
432
433/// Complete a write on one shard: AOF-log the canonical RESP command,
434/// publish to the embed-as-writer replication source (if configured),
435/// then run that shard's post-write eviction sweep.
436pub(crate) fn commit_write(inner: &mut Inner, parts: &[&[u8]]) -> io::Result<()> {
437    log_argv(&mut inner.aof, parts)?;
438    #[cfg(not(target_arch = "wasm32"))]
439    if let Some(src) = &inner.writer_source {
440        crate::replica_source::push_into(src, parts);
441    }
442    inner.store.try_evict_after_write();
443    Ok(())
444}
445
446pub(crate) fn store_err(e: StoreError) -> io::Error {
447    io::Error::new(io::ErrorKind::InvalidInput, format!("kevy-store: {e:?}"))
448}
449
450impl Drop for DropGuard {
451    fn drop(&mut self) {
452        // Stop the replica runner FIRST so no more frames arrive while
453        // we're shutting down + flushing the AOF (frames would race
454        // with the shutdown path).
455        #[cfg(not(target_arch = "wasm32"))]
456        if let Some(r) = &self.replica_runner {
457            r.shutdown();
458        }
459        // Stop the writer-source accept + connection threads next, so
460        // no new replica picks up bytes mid-flush.
461        #[cfg(not(target_arch = "wasm32"))]
462        if let Some(rs) = &self.replica_source {
463            rs.shutdown();
464        }
465        // Stop + join the reaper, then flush every shard's AOF so EverySec
466        // users don't lose the last sub-second of writes.
467        if let Some(stop) = &self.reaper_stop {
468            stop.store(true, Ordering::Relaxed);
469        }
470        if let Some(j) = self
471            .reaper_join
472            .lock()
473            .unwrap_or_else(std::sync::PoisonError::into_inner)
474            .take()
475        {
476            let _ = j.join();
477        }
478        for shard in self.shards_for_flush.iter() {
479            let mut g = lock_write(shard);
480            if let Some(aof) = &mut g.aof {
481                let _ = aof.maybe_sync();
482            }
483        }
484    }
485}
486
487#[cfg(test)]
488#[path = "store_tests.rs"]
489mod tests;
490#[cfg(test)]
491#[path = "store_tests_shard.rs"]
492mod tests_shard;