kevy_embedded/store_inner.rs
1//! The [`Store`]'s shared internals — per-shard [`Inner`], the
2//! last-clone [`DropGuard`], and the [`WeakStore`] handle (split out
3//! of `store.rs` to keep it under the 500-LOC project ceiling;
4//! behaviour unchanged).
5
6// A discarded fsync. A transient failure self-heals — `dirty` stays
7// set and the next tick retries — but a persistent one (full disk,
8// read-only remount, EIO) means `appendfsync everysec` has quietly
9// become "never" with nothing saying so. Open question §2.
10#![expect(
11 clippy::let_underscore_must_use,
12 reason = "a persistent fsync failure is invisible; an open question"
13)]
14
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::sync::{Arc, Mutex, RwLock, Weak};
17use std::thread::JoinHandle;
18
19#[cfg(feature = "persist")]
20use kevy_persist::Aof;
21
22use crate::config::Config;
23use crate::pubsub::PubsubBus;
24#[cfg(any(feature = "persist", not(target_arch = "wasm32")))]
25use crate::store::lock_write;
26use crate::store::{Shards, Store};
27
28/// Weak handle to a `Store` — does not keep the underlying keyspace alive.
29///
30/// Used by the URL-keyed registry in `kevy-client` so that multiple
31/// `Connection::connect("mem://name")` calls share the same backing store
32/// without leaking it when all strong handles go away.
33///
34/// ```
35/// let s = kevy_embedded::Store::open(kevy_embedded::Config::default())?;
36/// s.set(b"k", b"v")?;
37/// let weak = s.downgrade();
38/// let again = weak.upgrade().ok_or("the store is still alive")?;
39/// assert_eq!(again.get(b"k")?.as_deref(), Some(&b"v"[..]), "same keyspace");
40/// drop((s, again));
41/// assert!(weak.upgrade().is_none(), "the last strong handle is gone");
42/// # Ok::<(), Box<dyn std::error::Error>>(())
43/// ```
44#[derive(Debug, Clone)]
45pub struct WeakStore {
46 shards: Weak<Vec<Arc<RwLock<Inner>>>>,
47 guard: Weak<DropGuard>,
48 config: Config,
49 #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
50 feed_weak: Option<std::sync::Weak<Mutex<kevy_replicate::feed::FeedSource>>>,
51 blocker_weak: Weak<crate::ops_blocking::Blocker>,
52 #[cfg(feature = "index")]
53 indexes_weak: Weak<crate::ops_index::IndexReg>,
54 #[cfg(feature = "index")]
55 views_weak: Weak<crate::ops_view::ViewReg>,
56}
57
58impl WeakStore {
59 /// Try to upgrade back to a `Store`. Returns `None` if the last strong
60 /// reference has already been dropped.
61 pub fn upgrade(&self) -> Option<Store> {
62 let guard = self.guard.upgrade()?;
63 Some(Store {
64 shards: self.shards.upgrade()?,
65 config: self.config.clone(),
66 #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
67 feed: self.feed_weak.as_ref().and_then(std::sync::Weak::upgrade),
68 blocker: self.blocker_weak.upgrade()?,
69 #[cfg(feature = "index")]
70 indexes: self.indexes_weak.upgrade()?,
71 #[cfg(feature = "index")]
72 views: self.views_weak.upgrade()?,
73 #[cfg(feature = "index")]
74 tables: guard.catalog.tables.clone(),
75 // The report rides the DropGuard (engine lifetime), so a
76 // resurrection that outlives every full Store handle
77 // still reports the ORIGINAL boot's replay verdict.
78 open_report: guard.open_report.clone(),
79 guard,
80 })
81 }
82}
83
84impl Store {
85 /// Get a weak handle that does not keep the keyspace alive.
86 pub fn downgrade(&self) -> WeakStore {
87 WeakStore {
88 shards: Arc::downgrade(&self.shards),
89 guard: Arc::downgrade(&self.guard),
90 config: self.config.clone(),
91 #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
92 feed_weak: self.feed.as_ref().map(Arc::downgrade),
93 blocker_weak: Arc::downgrade(&self.blocker),
94 #[cfg(feature = "index")]
95 indexes_weak: Arc::downgrade(&self.indexes),
96 #[cfg(feature = "index")]
97 views_weak: Arc::downgrade(&self.views),
98 }
99 }
100}
101
102#[derive(Debug)]
103pub(crate) struct Inner {
104 pub(crate) store: kevy_store::Store,
105 #[cfg(feature = "persist")]
106 pub(crate) aof: Option<Aof>,
107 /// Pub/sub bus. Only shard 0's is ever used (pub/sub is process-wide);
108 /// other shards carry an idle one (cheap).
109 pub(crate) bus: PubsubBus,
110 /// Shared replication source if this store is an embed-as-writer.
111 /// Every shard holds a clone of the same `Arc<Mutex<...>>` so
112 /// `commit_write` can push mutations without reaching back up
113 /// through the `DropGuard`.
114 #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
115 pub(crate) writer_source:
116 Option<std::sync::Arc<Mutex<kevy_replicate::source::ReplicationSource>>>,
117 /// CDC feed (one stream per store); every shard holds a clone
118 /// so `commit_write` pushes effects inline. `None` = feed off.
119 #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
120 pub(crate) feed: Option<std::sync::Arc<Mutex<kevy_replicate::feed::FeedSource>>>,
121 /// Blocking-pop wake channel clone (see `Store::blocker`).
122 pub(crate) blocker: Option<Arc<crate::ops_blocking::Blocker>>,
123 /// This shard's index segments + the store-level registry
124 /// handle (for the commit_write hook).
125 #[cfg(feature = "index")]
126 pub(crate) idx_segs: crate::ops_index::ShardSegs,
127 #[cfg(feature = "index")]
128 pub(crate) idx_reg: Option<Arc<crate::ops_index::IndexReg>>,
129 /// This shard's view states + registry handle.
130 #[cfg(feature = "index")]
131 pub(crate) view_segs: crate::ops_view::ShardViews,
132 #[cfg(feature = "index")]
133 pub(crate) view_reg: Option<Arc<crate::ops_view::ViewReg>>,
134 /// The catalog registries and where the catalog stands as recorded
135 /// state (the replica runner and the snapshot writers reach it here).
136 #[cfg(feature = "index")]
137 pub(crate) catalog: Option<Arc<crate::catalog_record::CatalogRegs>>,
138}
139
140impl Inner {
141 pub(crate) fn new(
142 store: kevy_store::Store,
143 #[cfg(feature = "persist")] aof: Option<Aof>,
144 ) -> Self {
145 Inner {
146 store,
147 #[cfg(feature = "persist")]
148 aof,
149 bus: PubsubBus::new(),
150 #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
151 writer_source: None,
152 #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
153 feed: None,
154 blocker: None,
155 #[cfg(feature = "index")]
156 idx_segs: crate::ops_index::ShardSegs::default(),
157 #[cfg(feature = "index")]
158 idx_reg: None,
159 #[cfg(feature = "index")]
160 view_segs: crate::ops_view::ShardViews::default(),
161 #[cfg(feature = "index")]
162 view_reg: None,
163 #[cfg(feature = "index")]
164 catalog: None,
165 }
166 }
167}
168
169/// Owns the reaper-thread handle + the shards for the final AOF flush. Lives
170/// in an `Arc<DropGuard>` shared across every `Store` clone; the drop logic
171/// fires only when the last clone goes away.
172#[derive(Debug)]
173pub(crate) struct DropGuard {
174 /// Set by [`Store::shutdown`]: every later write fails with
175 /// `KevyError::Closed`. Shared across clones (it lives here so ANY
176 /// clone's shutdown gates ALL clones' writes).
177 pub(crate) shutdown: AtomicBool,
178 /// The boot replay verdict. Owned by the guard (engine lifetime),
179 /// so `WeakStore::upgrade` can rebuild a full `Store` — with the
180 /// original boot's report — even after every full handle dropped
181 /// while a subscription kept the engine alive.
182 pub(crate) open_report: Arc<crate::metric::OpenReport>,
183 /// The catalog registries — owned by the guard (engine lifetime) for
184 /// the same reason as `open_report`: a `WeakStore::upgrade` after
185 /// every full handle dropped must still see the declared tables.
186 #[cfg(feature = "index")]
187 pub(crate) catalog: Arc<crate::catalog_record::CatalogRegs>,
188 pub(crate) reaper_stop: Option<Arc<AtomicBool>>,
189 pub(crate) reaper_join: Mutex<Option<JoinHandle<()>>>,
190 // Read by the persist flush; without it the strong ref still
191 // pins the shards until the LAST clone (incl. subscriptions) drops.
192 #[cfg_attr(not(feature = "persist"), allow(dead_code))]
193 pub(crate) shards_for_flush: Shards,
194 /// Replica runner thread + reconnect machinery, present iff this
195 /// store was opened with `Config::replica_upstream = Some(...)`.
196 /// Joined here so the runner stops cleanly when the last `Store`
197 /// clone goes away.
198 #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
199 pub(crate) replica_runner: Option<crate::replica_runner::ReplicaRunner>,
200 /// Feed close-marker inputs — the feed handle + data dir,
201 /// present iff feed enabled AND persistent. Written after the AOF
202 /// flush so the marker's cursor describes durable state.
203 #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
204 pub(crate) feed_close:
205 Option<(std::sync::Arc<Mutex<kevy_replicate::feed::FeedSource>>, std::path::PathBuf)>,
206 /// Replica-source listener + accepted connection threads, present
207 /// iff this store is an embed-as-writer
208 /// (`Config::embed_writer_listen_addr = Some(...)`). Joined on
209 /// last-clone drop.
210 #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
211 pub(crate) replica_source: Option<crate::replica_source::ReplicaSource>,
212 /// Exclusive claim on the persist dir (`with_persist`) — one engine
213 /// per directory; a second `Store::open` on the same dir errors
214 /// instead of interleaving appends into this engine's AOF. Held for
215 /// the engine lifetime; released here when the last clone drops
216 /// (the drop body's final AOF flush runs before fields drop).
217 #[cfg(feature = "persist")]
218 pub(crate) _dir_lock: Option<kevy_persist::DirLock>,
219}
220
221impl Drop for DropGuard {
222 fn drop(&mut self) {
223 // Stop the replica runner FIRST so no more frames arrive while
224 // we're shutting down + flushing the AOF (frames would race
225 // with the shutdown path).
226 #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
227 if let Some(r) = &self.replica_runner {
228 r.shutdown();
229 }
230 // Stop the writer-source accept + connection threads next, so
231 // no new replica picks up bytes mid-flush.
232 #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
233 if let Some(rs) = &self.replica_source {
234 rs.shutdown();
235 }
236 // Stop + join the reaper, then flush every shard's AOF so EverySec
237 // users don't lose the last sub-second of writes.
238 if let Some(stop) = &self.reaper_stop {
239 stop.store(true, Ordering::Relaxed);
240 }
241 if let Some(j) =
242 self.reaper_join.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()
243 {
244 j.thread().unpark();
245 let _ = j.join();
246 }
247 #[cfg(feature = "persist")]
248 for shard in self.shards_for_flush.iter() {
249 let mut g = lock_write(shard);
250 if let Some(aof) = &mut g.aof {
251 // Unconditional: `maybe_sync` is a no-op inside the EverySec
252 // window, which let the fsynced close marker below claim
253 // durability the AOF tail didn't have yet — a power loss in
254 // that gap resumed the cursor over a rolled-back store, the
255 // one phantom the generation fence cannot detect. Same
256 // discipline as the server's shutdown_drain.
257 let _ = aof.sync_now();
258 }
259 }
260 // With the AOF durable, record the feed continuity
261 // marker — the cursor now exactly describes on-disk state.
262 #[cfg(all(feature = "replicate", not(target_arch = "wasm32")))]
263 if let Some((feed, dir)) = &self.feed_close {
264 Store::feed_write_close_marker(feed, dir);
265 }
266 #[cfg(not(target_arch = "wasm32"))]
267 self.free_entries_off_thread();
268 }
269}
270
271impl DropGuard {
272 /// Freeing the entries is most of the cost of closing a large store,
273 /// and nothing after the close depends on it, so a thread does it. The
274 /// parts that hold files (the AOF, the tier log) stay behind and close
275 /// here, before the directory lock is released. If the thread cannot
276 /// start, the closure holding the entries drops here instead.
277 #[cfg(not(target_arch = "wasm32"))]
278 fn free_entries_off_thread(&self) {
279 let entries: Vec<kevy_store::DetachedEntries> =
280 self.shards_for_flush.iter().map(|s| lock_write(s).store.detach_entries()).collect();
281 let _ = std::thread::Builder::new()
282 .name(String::from("kevy-embedded-free"))
283 .spawn(move || drop(entries));
284 }
285}