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::{Arc, Mutex, RwLock, RwLockReadGuard, RwLockWriteGuard};
7
8use kevy_persist::Argv;
9use kevy_store::ExpireStats;
10
11use crate::config::Config;
12use crate::shard::{build_shards, shard_idx};
13
14pub use crate::store_inner::WeakStore;
15pub(crate) use crate::store_inner::{DropGuard, Inner};
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    /// v2.3 CDC feed handle (read API side); shards carry clones for
54    /// the write side. `None` = feed off (or wasm).
55    #[cfg(not(target_arch = "wasm32"))]
56    pub(crate) feed: Option<std::sync::Arc<Mutex<kevy_replicate::feed::FeedSource>>>,
57    /// v2.4 blocking-pop wake channel (always present; writers pay one
58    /// Relaxed load while nobody blocks).
59    pub(crate) blocker: Arc<crate::ops_blocking::Blocker>,
60    /// v2.5 index registry (catalog + version).
61    pub(crate) indexes: Arc<crate::ops_index::IndexReg>,
62    /// v2.6 view registry.
63    pub(crate) views: Arc<crate::ops_view::ViewReg>,
64}
65
66impl Store {
67    /// Open an embedded keyspace per `config`.
68    ///
69    /// - Pure in-memory when `config.data_dir` is `None`.
70    /// - With persistence: each shard loads its snapshot then replays its AOF
71    ///   (`config.shards > 1` re-shards a legacy single AOF on first open).
72    /// - Spawns a background TTL reaper thread when
73    ///   `config.ttl_reaper == Background` (the default).
74    /// - When `config.replica_upstream = Some("host:port")`, spawns a
75    ///   background thread that streams replication frames from the
76    ///   named primary and applies them to this store; local writes are
77    ///   rejected with `READONLY` (see [`Self::open_replica`]).
78    pub fn open(config: Config) -> io::Result<Self> {
79        Self::open_inner(config)
80    }
81
82    /// Answer one RESP request against this store using the SAME
83    /// read-only verb whitelist the embedded RESP listener serves
84    /// (`Config::with_resp_listener`). The reply is appended to `out`
85    /// as raw RESP bytes; write verbs answer `-ERR` like the listener
86    /// does. This is the programmatic face of the listener — tooling
87    /// (e.g. `kevy-cli --embed`) inspects a store without a socket.
88    #[cfg(not(target_arch = "wasm32"))]
89    pub fn dispatch_readonly(&self, argv: &[Vec<u8>], out: &mut Vec<u8>) {
90        crate::listener::verbs_dispatch(self, argv, out);
91    }
92
93    fn open_inner(config: Config) -> io::Result<Self> {
94        let shards: Shards = Arc::new(build_shards(&config)?);
95        let (reaper_stop, reaper_join) = crate::reaper::spawn_reaper(&config, &shards)?;
96        #[cfg(not(target_arch = "wasm32"))]
97        let replica_runner = crate::replica_glue::spawn_replica_runner(&config, &shards);
98        #[cfg(not(target_arch = "wasm32"))]
99        let replica_source = spawn_writer_source(&config, &shards)?;
100        #[cfg(not(target_arch = "wasm32"))]
101        let feed = Store::feed_open(&config)?;
102        #[cfg(not(target_arch = "wasm32"))]
103        if let Some(f) = &feed {
104            for shard in shards.iter() {
105                let mut g = lock_write(shard);
106                g.feed = Some(f.clone());
107            }
108        }
109        let (blocker, indexes, views) = wire_registries(&shards);
110        let guard = Arc::new(DropGuard {
111            reaper_stop,
112            reaper_join: Mutex::new(reaper_join),
113            shards_for_flush: shards.clone(),
114            #[cfg(not(target_arch = "wasm32"))]
115            replica_runner,
116            #[cfg(not(target_arch = "wasm32"))]
117            feed_close: match (&feed, &config.data_dir) {
118                (Some(f), Some(d)) => Some((f.clone(), d.clone())),
119                _ => None,
120            },
121            #[cfg(not(target_arch = "wasm32"))]
122            replica_source,
123        });
124        let store = Store {
125            shards,
126            guard,
127            config,
128            #[cfg(not(target_arch = "wasm32"))]
129            feed,
130            blocker,
131            indexes,
132            views,
133        };
134        store.idx_boot();
135        store.view_boot();
136        #[cfg(not(target_arch = "wasm32"))]
137        if let Some(addr) = store.config.resp_listener {
138            crate::listener::spawn(addr, store.downgrade())?;
139        }
140        Ok(store)
141    }
142
143    /// Convenience constructor for an embed-as-read-replica store
144    /// streaming writes from `upstream` (`"host:port"` of a kevy
145    /// server's replication listener).
146    ///
147    /// The replica:
148    /// - has its local AOF force-disabled (the upstream stream is the
149    ///   source of truth; replica AOF would diverge and double-apply
150    ///   on restart);
151    /// - rejects every local write with a `READONLY` `io::Error`
152    ///   (you can still call read APIs concurrently);
153    /// - reconnects with exponential backoff on disconnect, resuming
154    ///   from the last applied offset;
155    /// - gets a process-unique `replica_id` so an open / drop / reopen
156    ///   cycle within the primary's reconnect window does not look like
157    ///   the same slot from the primary's POV (which would evict
158    ///   backlog frames the new embed still needs from offset 0).
159    ///   Override via [`Config::with_replica_id`] when you specifically
160    ///   want the slot to be re-claimed across restarts.
161    ///
162    /// For full builder control (custom replica id, backoff bounds,
163    /// snapshot dir, etc.) use [`Self::open`] with
164    /// [`Config::with_replica_upstream`] + the related setters
165    /// instead.
166    #[cfg(not(target_arch = "wasm32"))]
167    pub fn open_replica(upstream: impl Into<String>) -> io::Result<Self> {
168        let cfg = Config::default()
169            .without_aof()
170            .with_replica_id(crate::replica_glue::fresh_replica_id())
171            .with_replica_upstream(upstream);
172        Self::open(cfg)
173    }
174
175    /// `true` when this store was opened against a replication
176    /// upstream — local writes are rejected with `READONLY`.
177    pub fn is_replica(&self) -> bool {
178        self.config.replica_upstream.is_some()
179    }
180
181    /// Retarget this replica at a new primary URL (`host:port`). The
182    /// runner picks up the change on its next connect — which is
183    /// forced now by `shutdown`ing the current socket clone, so the
184    /// retarget lands within `Config::replica_reconnect_min` (default
185    /// 100 ms) of this call.
186    ///
187    /// Returns `Err` with `ErrorKind::InvalidInput` when this store is
188    /// not a replica (no upstream was configured at open). Application
189    /// code typically drives this from a `kevy-elect` failover signal —
190    /// see [`docs/cluster.md`](https://github.com/goliajp/kevy/blob/develop/docs/cluster.md).
191    /// `kevy-embedded` itself stays elect-protocol-agnostic; the
192    /// integration glue lives in the application.
193    #[cfg(not(target_arch = "wasm32"))]
194    pub fn set_replica_upstream(&self, new_upstream: impl Into<String>) -> io::Result<()> {
195        if !self.is_replica() {
196            return Err(io::Error::new(
197                io::ErrorKind::InvalidInput,
198                "set_replica_upstream called on a non-replica store",
199            ));
200        }
201        let Some(runner) = self.guard.replica_runner.as_ref() else {
202            return Err(io::Error::new(
203                io::ErrorKind::InvalidInput,
204                "replica runner is not active (open was racy?)",
205            ));
206        };
207        runner.set_upstream(new_upstream.into());
208        Ok(())
209    }
210
211    /// The active config (a clone — modifying it has no effect on the
212    /// running store). Useful for introspection / `INFO`-style telemetry.
213    pub fn config(&self) -> &Config {
214        &self.config
215    }
216
217    // ---- escape hatches -------------------------------------------------
218
219    /// Run `f` against the underlying `kevy_store::Store` under its lock. Use
220    /// for direct access to methods this crate hasn't wrapped. The closure can
221    /// mutate, but *does not auto-log to the AOF* — call [`Self::log`] yourself
222    /// if the mutation must survive a crash.
223    ///
224    /// **Sharded stores:** this targets shard 0 only. Use [`Self::with_key`]
225    /// to reach the shard owning a specific key.
226    pub fn with<F, R>(&self, f: F) -> R
227    where
228        F: FnOnce(&mut kevy_store::Store) -> R,
229    {
230        let mut g = self.lock();
231        f(&mut g.store)
232    }
233
234    /// Like [`Self::with`] but targets the shard that owns `key`.
235    pub fn with_key<F, R>(&self, key: &[u8], f: F) -> R
236    where
237        F: FnOnce(&mut kevy_store::Store) -> R,
238    {
239        let mut g = self.wshard(key);
240        f(&mut g.store)
241    }
242
243    /// `KEYS` / `SCAN`-glob across **every shard** — the cross-shard
244    /// replacement for `with(|s| s.collect_keys(pat, lim))`, which only sees
245    /// shard 0 once sharding is on. Behaves identically to `with(...)` when
246    /// `shard_count() == 1`. `limit` bounds the *total* returned across shards.
247    /// Takes a read lock per shard (concurrent-safe).
248    pub fn collect_keys(&self, pattern: Option<&[u8]>, limit: Option<usize>) -> Vec<Vec<u8>> {
249        let mut out = Vec::new();
250        for shard in self.shards.iter() {
251            if limit.is_some_and(|l| out.len() >= l) {
252                break;
253            }
254            let remaining = limit.map(|l| l - out.len());
255            out.extend(lock_read(shard).store.collect_keys(pattern, remaining));
256        }
257        out
258    }
259
260    /// Run `f` against **each shard's** underlying `kevy_store::Store` (in
261    /// shard-index order) — the cross-shard escape hatch. The caller assembles
262    /// the merged result. Pairs with [`Self::shard_count`]. For a single key,
263    /// prefer [`Self::with_key`]; for a glob scan, prefer [`Self::collect_keys`].
264    pub fn for_each_shard<F: FnMut(&mut kevy_store::Store)>(&self, mut f: F) {
265        for shard in self.shards.iter() {
266            f(&mut lock_write(shard).store);
267        }
268    }
269
270    /// Number of keyspace shards (`== Config::shards`).
271    #[inline]
272    pub fn shard_count(&self) -> usize {
273        self.shards.len()
274    }
275
276    /// Append a raw RESP-frame argument list to the shard owning its key's
277    /// AOF. No-op when persistence is disabled.
278    pub fn log(&self, parts: &[&[u8]]) -> io::Result<()> {
279        let mut g = match parts.get(1) {
280            Some(key) => self.wshard(key),
281            None => self.lock(),
282        };
283        if let Some(aof) = &mut g.aof {
284            let argv = Argv::from(parts.iter().map(|p| p.to_vec()).collect::<Vec<_>>());
285            aof.append(&argv)?;
286        }
287        Ok(())
288    }
289
290    // ---- maintenance ----------------------------------------------------
291
292    /// Run one TTL-reaper tick across every shard. Required call cadence in
293    /// `Manual` mode (~10×/s to match Redis `hz=10`). Returns the summed stats.
294    pub fn tick(&self) -> ExpireStats {
295        let mut total = ExpireStats::default();
296        for shard in self.shards.iter() {
297            let stats = {
298                let mut g = lock_write(shard);
299                g.store.tick_expire(self.config.reaper_samples, self.config.reaper_max_rounds)
300            };
301            total.sampled += stats.sampled;
302            total.expired += stats.expired;
303            // Auto-rewrite rides the caller-driven tick in Manual mode; the
304            // non-blocking path releases the lock for the disk spill.
305            crate::reaper::concurrent_auto_rewrite(
306                shard,
307                self.config.auto_aof_rewrite_pct,
308                self.config.auto_aof_rewrite_min_size,
309                self.config.metric_sink.as_ref(),
310            );
311        }
312        total
313    }
314
315    // Durability methods (`rewrite_aof`, `save_snapshot`) live in
316    // `crate::store_persist` to keep this file under the 500-LOC
317    // project ceiling.
318    // Data-type methods live in `crate::ops` / `crate::info`.
319
320    /// Crate-internal: clone shard 0's handle for a `Subscription`'s bus.
321    pub(crate) fn inner_handle(&self) -> Arc<RwLock<Inner>> {
322        self.shards[0].clone()
323    }
324
325    /// Crate-internal: clone the shared `Arc<DropGuard>`.
326    pub(crate) fn guard_handle(&self) -> Arc<DropGuard> {
327        self.guard.clone()
328    }
329
330    fn shard_for(&self, key: &[u8]) -> &Arc<RwLock<Inner>> {
331        &self.shards[shard_idx(key, self.shards.len())]
332    }
333
334    /// Write-lock the shard owning `key`.
335    pub(crate) fn wshard(&self, key: &[u8]) -> RwLockWriteGuard<'_, Inner> {
336        lock_write(self.shard_for(key))
337    }
338
339    /// Read-lock the shard owning `key` (GET fast path — concurrent readers
340    /// across shards run in parallel).
341    pub(crate) fn rshard(&self, key: &[u8]) -> RwLockReadGuard<'_, Inner> {
342        lock_read(self.shard_for(key))
343    }
344
345    /// Write-lock shard 0 — pub/sub bus + keyless escape hatches.
346    pub(crate) fn lock(&self) -> RwLockWriteGuard<'_, Inner> {
347        lock_write(&self.shards[0])
348    }
349
350    /// Run `f` over every shard's write guard, summing a `usize` (DBSIZE etc.).
351    pub(crate) fn sum_shards<F: Fn(&mut Inner) -> usize>(&self, f: F) -> usize {
352        self.shards.iter().map(|s| f(&mut lock_write(s))).sum()
353    }
354
355    /// Run `f` over every shard's write guard, summing a `u64`.
356    pub(crate) fn sum_shards_u64<F: Fn(&mut Inner) -> u64>(&self, f: F) -> u64 {
357        self.shards.iter().map(|s| f(&mut lock_write(s))).sum()
358    }
359
360    /// Run a fallible `f` over every shard (mutating, e.g. FLUSHALL).
361    pub(crate) fn try_for_each_shard<F: FnMut(&mut Inner) -> io::Result<()>>(
362        &self,
363        mut f: F,
364    ) -> io::Result<()> {
365        for s in self.shards.iter() {
366            f(&mut lock_write(s))?;
367        }
368        Ok(())
369    }
370}
371
372
373pub(crate) use crate::store_glue::{commit_write, lock_read, lock_write, store_err};
374
375/// Spawn the embed-as-writer replication source (v3.2) when
376/// `Config::embed_writer_listen_addr` is set, wiring the shared source
377/// into every shard's `Inner` so `commit_write` pushes mutations into
378/// the backlog inline (done once at open under the shard's write lock;
379/// reads of `Inner::writer_source` afterwards are uncontended).
380///
381/// The snapshot provider freezes every shard's COW view under the
382/// source lock (so ack_offset and the frozen keyspace are one point in
383/// time — writes between the two would replay twice otherwise), then
384/// serializes outside the locks via the persist writer.
385#[cfg(not(target_arch = "wasm32"))]
386fn spawn_writer_source(
387    config: &Config,
388    shards: &Shards,
389) -> io::Result<Option<crate::replica_source::ReplicaSource>> {
390    let Some(addr) = config.embed_writer_listen_addr.as_ref() else {
391        return Ok(None);
392    };
393    let shards_for_snap: Shards = Arc::clone(shards);
394    let snapshot: crate::replica_source::SnapshotProvider =
395        Arc::new(move || crate::replica_source::freeze_and_serialize(&shards_for_snap));
396    let rs = crate::replica_source::ReplicaSource::spawn(
397        addr,
398        config.embed_writer_backlog_bytes,
399        snapshot,
400    )?;
401    let shared = rs.shared_source();
402    for shard in shards.iter() {
403        let mut g = lock_write(shard);
404        g.writer_source = Some(shared.clone());
405    }
406    Ok(Some(rs))
407}
408
409/// Create the store-level registries (blocking-pop waker, index +
410/// view catalogs) and hand every shard's `Inner` a clone.
411#[allow(clippy::type_complexity)]
412fn wire_registries(
413    shards: &Shards,
414) -> (
415    Arc<crate::ops_blocking::Blocker>,
416    Arc<crate::ops_index::IndexReg>,
417    Arc<crate::ops_view::ViewReg>,
418) {
419    let blocker = Arc::new(crate::ops_blocking::Blocker::new());
420    let indexes = Arc::new(crate::ops_index::IndexReg::default());
421    let views = Arc::new(crate::ops_view::ViewReg::default());
422    for shard in shards.iter() {
423        let mut g = lock_write(shard);
424        g.blocker = Some(blocker.clone());
425        g.idx_reg = Some(indexes.clone());
426        g.view_reg = Some(views.clone());
427    }
428    (blocker, indexes, views)
429}
430
431
432#[cfg(test)]
433#[path = "store_tests.rs"]
434mod tests;
435#[cfg(test)]
436#[path = "store_tests_shard.rs"]
437mod tests_shard;
438#[cfg(test)]
439#[path = "store_tests_p2.rs"]
440mod tests_p2;
441#[cfg(test)]
442#[path = "store_tests_p3.rs"]
443mod tests_p3;
444#[cfg(test)]
445#[path = "store_tests_bitmap.rs"]
446mod tests_bitmap;
447#[cfg(test)]
448#[path = "store_tests_bonus.rs"]
449mod tests_bonus;
450#[cfg(test)]
451#[path = "store_tests_scan.rs"]
452mod tests_scan;
453#[cfg(test)]
454#[path = "store_tests_atomic.rs"]
455mod tests_atomic;
456#[cfg(test)]
457#[path = "store_tests_more.rs"]
458mod tests_more;
459#[cfg(test)]
460#[path = "store_tests_keyspace.rs"]
461mod tests_keyspace;
462#[cfg(test)]
463#[path = "store_tests_atomic_all.rs"]
464mod tests_atomic_all;
465#[cfg(test)]
466#[path = "store_tests_replay_all.rs"]
467mod tests_replay_all;
468#[cfg(test)]
469#[path = "store_tests_op_table.rs"]
470mod tests_op_table;