Skip to main content

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}