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;
7#[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
8use std::sync::Mutex;
9use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard};
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(Debug, 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 #[cfg(feature = "persist")]
129 let dir_lock = crate::store_wire::claim_dir(&config)?;
130 let bb = crate::store_wire::boot_backbone(&config)?;
131 let (shards, open_report) = (bb.shards, Arc::new(bb.open_report));
132 #[cfg(feature = "index")]
133 let catalog = bb.catalog;
134 #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
135 let (replica_runner, replica_source, feed) =
136 crate::store_wire::wire_replication(&config, &shards)?;
137 let blocker = crate::store_wire::wire_blocker(&shards);
138 // Engine-lifetime state: `store_wire::build_guard` (fn-length rule).
139 let guard = crate::store_wire::build_guard(
140 &open_report,
141 bb.reaper_stop,
142 bb.reaper_join,
143 &shards,
144 #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
145 replica_runner,
146 #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
147 replica_source,
148 #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
149 &feed,
150 &config,
151 #[cfg(feature = "index")]
152 &catalog,
153 #[cfg(feature = "persist")]
154 dir_lock,
155 );
156 let store = Store {
157 shards,
158 guard,
159 config,
160 #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
161 feed,
162 blocker,
163 #[cfg(feature = "index")]
164 indexes: catalog.indexes.clone(),
165 #[cfg(feature = "index")]
166 views: catalog.views.clone(),
167 #[cfg(feature = "index")]
168 tables: catalog.tables.clone(),
169 open_report,
170 };
171 store.boot_ancillary()?;
172 Ok(store)
173 }
174
175 /// Post-construction bring-up: the catalog a 6.4 directory kept in
176 /// side files, and the optional read-only RESP listener. Split from
177 /// [`Self::open_inner`] for the fn-length rule.
178 fn boot_ancillary(&self) -> KevyResult<()> {
179 #[cfg(all(feature = "index", feature = "persist"))]
180 self.settle_sidecars()?;
181 #[cfg(all(feature = "listener", not(target_arch = "wasm32")))]
182 if let Some(addr) = self.config.resp_listener {
183 crate::listener::spawn(addr, self.downgrade())?;
184 }
185 Ok(())
186 }
187
188 /// Convenience constructor for an embed-as-read-replica store
189 /// streaming writes from `upstream` (`"host:port"` of a kevy
190 /// server's replication listener).
191 ///
192 /// The replica:
193 /// - has its local AOF force-disabled (the upstream stream is the
194 /// source of truth; replica AOF would diverge and double-apply
195 /// on restart);
196 /// - rejects every local write with a `READONLY` `io::Error`
197 /// (you can still call read APIs concurrently);
198 /// - reconnects with exponential backoff on disconnect, resuming
199 /// from the last applied offset;
200 /// - gets a process-unique `replica_id` so an open / drop / reopen
201 /// cycle within the primary's reconnect window does not look like
202 /// the same slot from the primary's POV (which would evict
203 /// backlog frames the new embed still needs from offset 0).
204 /// Override via [`Config::with_replica_id`] when you specifically
205 /// want the slot to be re-claimed across restarts.
206 ///
207 /// For full builder control (custom replica id, backoff bounds,
208 /// snapshot dir, etc.) use [`Self::open`] with
209 /// [`Config::with_replica_upstream`] + the related setters
210 /// instead.
211 #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
212 pub fn open_replica(upstream: impl Into<String>) -> KevyResult<Self> {
213 let cfg = Config::default()
214 .without_aof()
215 .with_replica_id(crate::replica_glue::fresh_replica_id())
216 .with_replica_upstream(upstream);
217 Self::open(cfg)
218 }
219
220 /// `true` when this store was opened against a replication
221 /// upstream — local writes are rejected with `READONLY`.
222 #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
223 pub fn is_replica(&self) -> bool {
224 self.config.replica_upstream.is_some()
225 }
226
227 /// Flush every shard's AOF to disk (a real fsync), write the feed
228 /// continuity marker, then refuse every later write (they fail with
229 /// [`KevyError::Closed`]; reads stay available). Idempotent and
230 /// clone-safe: any clone's `shutdown` gates them all, so a signal
231 /// handler's teardown is two deterministic lines —
232 /// `store.shutdown()?; std::process::exit(0)` — instead of praying
233 /// every task's `Arc<Store>` drops in time. Writes racing the call
234 /// may land after the fsync; writes issued after it returns cannot.
235 pub fn shutdown(&self) -> std::io::Result<()> {
236 use std::sync::atomic::Ordering;
237 self.guard.shutdown.store(true, Ordering::Release);
238 #[cfg(feature = "persist")]
239 for shard in self.shards.iter() {
240 let mut g = lock_write(shard);
241 if let Some(aof) = &mut g.aof {
242 aof.sync_now()?;
243 }
244 }
245 #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
246 if let (Some(feed), Some(dir)) = (&self.feed, &self.config.data_dir) {
247 Store::feed_write_close_marker(feed, dir);
248 }
249 Ok(())
250 }
251
252 /// Retarget this replica at a new primary URL (`host:port`). The
253 /// runner picks up the change on its next connect — which is
254 /// forced now by `shutdown`ing the current socket clone, so the
255 /// retarget lands within `Config::replica_reconnect_min` (default
256 /// 100 ms) of this call.
257 ///
258 /// Returns `Err` with `ErrorKind::InvalidInput` when this store is
259 /// not a replica (no upstream was configured at open). Application
260 /// code typically drives this from a `kevy-elect` failover signal —
261 /// see [`docs/cluster.md`](https://github.com/goliajp/kevy/blob/develop/docs/cluster.md).
262 /// `kevy-embedded` itself stays elect-protocol-agnostic; the
263 /// integration glue lives in the application.
264 #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
265 pub fn set_replica_upstream(&self, new_upstream: impl Into<String>) -> KevyResult<()> {
266 if !self.is_replica() {
267 return Err(KevyError::InvalidInput(
268 "set_replica_upstream called on a non-replica store".into(),
269 ));
270 }
271 let Some(runner) = self.guard.replica_runner.as_ref() else {
272 return Err(KevyError::InvalidInput(
273 "replica runner is not active (open was racy?)".into(),
274 ));
275 };
276 runner.set_upstream(new_upstream.into());
277 Ok(())
278 }
279
280 /// The active config (a clone — modifying it has no effect on the
281 /// running store). Useful for introspection / `INFO`-style telemetry.
282 pub fn config(&self) -> &Config {
283 &self.config
284 }
285
286 // ---- escape hatches -------------------------------------------------
287
288 /// Run `f` against the underlying `kevy_store::Store` under its lock. Use
289 /// for direct access to methods this crate hasn't wrapped. The closure can
290 /// mutate, but *does not auto-log to the AOF* — call [`Self::log`] yourself
291 /// if the mutation must survive a crash.
292 ///
293 /// **Sharded stores:** this targets shard 0 only. Use [`Self::with_key`]
294 /// to reach the shard owning a specific key.
295 pub fn with<F, R>(&self, f: F) -> R
296 where
297 F: FnOnce(&mut kevy_store::Store) -> R,
298 {
299 let mut g = self.lock();
300 f(&mut g.store)
301 }
302
303 /// Like [`Self::with`] but targets the shard that owns `key`.
304 pub fn with_key<F, R>(&self, key: &[u8], f: F) -> R
305 where
306 F: FnOnce(&mut kevy_store::Store) -> R,
307 {
308 let mut g = self.wshard(key);
309 f(&mut g.store)
310 }
311
312 /// `KEYS` / `SCAN`-glob across **every shard** — the cross-shard
313 /// replacement for `with(|s| s.collect_keys(pat, lim))`, which only sees
314 /// shard 0 once sharding is on. Behaves identically to `with(...)` when
315 /// `shard_count() == 1`. `limit` bounds the *total* returned across shards.
316 /// Takes a read lock per shard (concurrent-safe).
317 pub fn collect_keys(&self, pattern: Option<&[u8]>, limit: Option<usize>) -> Vec<Vec<u8>> {
318 let mut out = Vec::new();
319 for shard in self.shards.iter() {
320 if limit.is_some_and(|l| out.len() >= l) {
321 break;
322 }
323 let remaining = limit.map(|l| l - out.len());
324 out.extend(lock_read(shard).store.collect_keys(pattern, remaining));
325 }
326 out
327 }
328
329 /// Run `f` against **each shard's** underlying `kevy_store::Store` (in
330 /// shard-index order) — the cross-shard escape hatch. The caller assembles
331 /// the merged result. Pairs with [`Self::shard_count`]. For a single key,
332 /// prefer [`Self::with_key`]; for a glob scan, prefer [`Self::collect_keys`].
333 pub fn for_each_shard<F: FnMut(&mut kevy_store::Store)>(&self, mut f: F) {
334 for shard in self.shards.iter() {
335 f(&mut lock_write(shard).store);
336 }
337 }
338
339 /// Number of keyspace shards (`== Config::shards`).
340 #[inline]
341 pub fn shard_count(&self) -> usize {
342 self.shards.len()
343 }
344
345 /// Append a raw RESP-frame argument list to the shard owning its key's
346 /// AOF. No-op when persistence is disabled.
347 #[cfg(feature = "persist")]
348 pub fn log(&self, parts: &[&[u8]]) -> KevyResult<()> {
349 let mut g = match parts.get(1) {
350 Some(key) => self.wshard(key),
351 None => self.lock(),
352 };
353 if let Some(aof) = &mut g.aof {
354 let argv = Argv::from(parts.iter().map(|p| p.to_vec()).collect::<Vec<_>>());
355 aof.append(&argv)?;
356 }
357 Ok(())
358 }
359
360 // ---- maintenance (Store::tick lives in store_tick.rs) ---------------
361
362 /// The B9 transparency suite's deterministic demotion seam
363 /// (`KEVY_TEST_FORCE_DEMOTE` genre): demote `key` to the cold tier
364 /// NOW, ignoring the watermark — the suite drives cold state
365 /// per-key, never by eviction timing. Returns whether a demotion
366 /// happened (false: tiering off / key absent / not spillable).
367 #[cfg(all(feature = "tier", not(target_arch = "wasm32")))]
368 #[doc(hidden)]
369 pub fn debug_force_demote(&self, key: &[u8]) -> bool {
370 self.wshard(key).store.debug_force_demote(key)
371 }
372
373 /// Tiering counters summed across shards:
374 /// `(demotions_total, promotions_total)` — the minimal counter pair
375 /// (the full INFO gauge set is `tier_info`). Zeros when tiering is off.
376 #[cfg(all(feature = "tier", not(target_arch = "wasm32")))]
377 pub fn tier_counters(&self) -> (u64, u64) {
378 let mut d = 0u64;
379 let mut p = 0u64;
380 for shard in self.shards.iter() {
381 let s = lock_read(shard).store.tier_stats();
382 d += s.demotions_total;
383 p += s.promotions_total;
384 }
385 (d, p)
386 }
387
388 // Durability methods (`rewrite_aof`, `save_snapshot`) live in
389 // `crate::store_persist` to keep this file under the 500-LOC
390 // project ceiling.
391 // Data-type methods live in `crate::ops` / `crate::info`.
392
393 /// Crate-internal: clone shard 0's handle for a `Subscription`'s bus.
394 pub(crate) fn inner_handle(&self) -> Arc<RwLock<Inner>> {
395 self.shards[0].clone()
396 }
397
398 /// Crate-internal: clone the shared `Arc<DropGuard>`.
399 pub(crate) fn guard_handle(&self) -> Arc<DropGuard> {
400 self.guard.clone()
401 }
402
403 fn shard_for(&self, key: &[u8]) -> &Arc<RwLock<Inner>> {
404 &self.shards[shard_idx(key, self.shards.len())]
405 }
406
407 /// Write-lock the shard owning `key`.
408 pub(crate) fn wshard(&self, key: &[u8]) -> RwLockWriteGuard<'_, Inner> {
409 lock_write(self.shard_for(key))
410 }
411
412 /// Read-lock the shard owning `key` (GET fast path — concurrent readers
413 /// across shards run in parallel).
414 pub(crate) fn rshard(&self, key: &[u8]) -> RwLockReadGuard<'_, Inner> {
415 lock_read(self.shard_for(key))
416 }
417
418 /// Write-lock shard 0 — pub/sub bus + keyless escape hatches.
419 pub(crate) fn lock(&self) -> RwLockWriteGuard<'_, Inner> {
420 lock_write(&self.shards[0])
421 }
422
423 /// Run `f` over every shard's write guard, summing a `usize` (DBSIZE etc.).
424 pub(crate) fn sum_shards<F: Fn(&mut Inner) -> usize>(&self, f: F) -> usize {
425 self.shards.iter().map(|s| f(&mut lock_write(s))).sum()
426 }
427
428 /// Run `f` over every shard's write guard, summing a `u64`.
429 pub(crate) fn sum_shards_u64<F: Fn(&mut Inner) -> u64>(&self, f: F) -> u64 {
430 self.shards.iter().map(|s| f(&mut lock_write(s))).sum()
431 }
432
433 /// Read-lock variant of [`Self::sum_shards`]: takes each shard's SHARED
434 /// lock for read-only aggregations (DBSIZE etc.) that never mutate the
435 /// keyspace — the underlying counter methods are all `&self`.
436 pub(crate) fn sum_shards_read<F: Fn(&Inner) -> usize>(&self, f: F) -> usize {
437 self.shards.iter().map(|s| f(&lock_read(s))).sum()
438 }
439
440 /// `u64` read-lock variant of [`Self::sum_shards_read`].
441 pub(crate) fn sum_shards_u64_read<F: Fn(&Inner) -> u64>(&self, f: F) -> u64 {
442 self.shards.iter().map(|s| f(&lock_read(s))).sum()
443 }
444
445 /// Run a fallible `f` over every shard (mutating, e.g. FLUSHALL).
446 pub(crate) fn try_for_each_shard<F: FnMut(&mut Inner) -> KevyResult<()>>(
447 &self,
448 mut f: F,
449 ) -> KevyResult<()> {
450 for s in self.shards.iter() {
451 f(&mut lock_write(s))?;
452 }
453 Ok(())
454 }
455}
456
457pub(crate) use crate::store_glue::{commit_write, lock_read, lock_write, store_err};
458
459#[cfg(test)]
460#[path = "store_test_suites.rs"]
461mod test_suites;