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;