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;