Skip to main content

khive_runtime/events_split/
registry.rs

1use super::*;
2
3/// How a runtime reaches event storage when the split is configured.
4#[derive(Debug, Clone)]
5pub struct EventsSplitConfig {
6    /// The events database file. The events daemon is its only writer in
7    /// daemon deployments; embedded mode writes it directly.
8    pub db_path: PathBuf,
9    /// `Some(socket)` = forward appends to the events daemon at this socket
10    /// (daemon deployments). `None` = embedded mode: open `db_path` directly
11    /// in-process (one-shot CLI, tests).
12    pub socket_path: Option<PathBuf>,
13}
14
15// ---------------------------------------------------------------------------
16// Process-global handles
17// ---------------------------------------------------------------------------
18//
19// One events-split client (or one direct backend) per process, whatever the
20// number of runtimes and clones — the same shape as the daemon module's other
21// process-global state. Keyed by path so tests exercising two distinct paths
22// in one process stay isolated.
23
24#[cfg(unix)]
25pub(super) type ClientMap = std::collections::HashMap<PathBuf, Arc<EventsSplitClient>>;
26/// One admission per canonical path. The access mode belongs to the entry,
27/// so another mode cannot run raw-file preflight over a live SQLite pool.
28/// Strong entries retain the existing process lifetime, including derived stores.
29pub(super) type BackendMap = std::collections::HashMap<PathBuf, (bool, Arc<StorageBackend>)>;
30
31#[cfg(unix)]
32pub(super) fn client_registry() -> &'static std::sync::Mutex<ClientMap> {
33    static REGISTRY: std::sync::OnceLock<std::sync::Mutex<ClientMap>> = std::sync::OnceLock::new();
34    REGISTRY.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
35}
36
37pub(super) fn direct_backend_registry() -> &'static std::sync::Mutex<BackendMap> {
38    static REGISTRY: std::sync::OnceLock<std::sync::Mutex<BackendMap>> = std::sync::OnceLock::new();
39    REGISTRY.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
40}
41
42/// Release only one test fixture's process-registry entries when its scope ends.
43///
44/// Borrow a unique temporary directory immediately after creating it, before
45/// declaring its backend/store/client handles. Those handles then drop before
46/// this guard, and the borrowed directory outlives registry cleanup. Production
47/// callers retain the normal process-lifetime registry ownership.
48#[cfg(any(test, feature = "test-internals"))]
49#[doc(hidden)]
50pub struct TestRegistryGuard<'a> {
51    pub(super) root: &'a Path,
52    pub(super) canonical_root: PathBuf,
53}
54
55#[cfg(any(test, feature = "test-internals"))]
56impl<'a> TestRegistryGuard<'a> {
57    /// Keep `root` alive until this fixture's registry references are released.
58    pub fn new(root: &'a Path) -> Self {
59        Self {
60            root,
61            canonical_root: std::fs::canonicalize(root).expect("existing unique fixture root"),
62        }
63    }
64
65    pub(super) fn remove_entries<T>(
66        &self,
67        registry: &std::sync::Mutex<std::collections::HashMap<PathBuf, T>>,
68    ) -> Vec<T> {
69        let mut entries = registry
70            .lock()
71            .unwrap_or_else(std::sync::PoisonError::into_inner);
72        let keys: Vec<_> = entries
73            .keys()
74            .filter(|path| path.starts_with(self.root) || path.starts_with(&self.canonical_root))
75            .cloned()
76            .collect();
77        keys.into_iter()
78            .filter_map(|key| entries.remove(&key))
79            .collect()
80    }
81}
82
83#[cfg(any(test, feature = "test-internals"))]
84impl Drop for TestRegistryGuard<'_> {
85    fn drop(&mut self) {
86        // Destructors may close SQLite connections or senders. Run them only
87        // after releasing each registry lock; unrelated fixtures keep serving.
88        let backends = self.remove_entries(direct_backend_registry());
89        #[cfg(unix)]
90        let clients = self.remove_entries(client_registry());
91        drop(backends);
92        #[cfg(unix)]
93        drop(clients);
94    }
95}
96
97/// The process-wide client for `socket_path`, created (and its forwarder
98/// spawned) on first use. Requires a tokio runtime context on first call.
99#[cfg(unix)]
100pub fn client_for(socket_path: &Path) -> crate::error::RuntimeResult<Arc<EventsSplitClient>> {
101    let mut registry = client_registry()
102        .lock()
103        .unwrap_or_else(std::sync::PoisonError::into_inner);
104    if let Some(existing) = registry.get(socket_path) {
105        return Ok(Arc::clone(existing));
106    }
107    let client = EventsSplitClient::new(socket_path.to_path_buf())?;
108    registry.insert(socket_path.to_path_buf(), Arc::clone(&client));
109    Ok(client)
110}
111
112/// The process-wide direct (embedded-mode) backend for `db_path`, opened
113/// read-write on first use.
114pub fn direct_backend_for(db_path: &Path) -> crate::error::RuntimeResult<Arc<StorageBackend>> {
115    direct_backend_with_max_readers(db_path, false, None)
116}
117
118/// The process-wide direct backend for `db_path`, opened READ-ONLY on first
119/// use. The file must already exist — this constructor never creates or
120/// schema-initializes an events database, which is what a read-only runtime's
121/// no-DB-creation contract requires of its event lane.
122pub fn direct_backend_read_only_for(
123    db_path: &Path,
124) -> crate::error::RuntimeResult<Arc<StorageBackend>> {
125    direct_backend_with_max_readers(db_path, true, None)
126}
127
128/// Standalone opener: resolve its environment once before opening the event lane.
129pub(crate) fn direct_backend_with_max_readers(
130    db_path: &Path,
131    read_only: bool,
132    max_readers: Option<usize>,
133) -> crate::error::RuntimeResult<Arc<StorageBackend>> {
134    let mut config = crate::RuntimeConfig {
135        db_path: Some(db_path.to_path_buf()),
136        ..crate::RuntimeConfig::no_embeddings()
137    };
138    let wal_ceiling = config.resolve_wal_ceiling_policy(read_only)?;
139    let disk_guard = config.resolve_disk_guard_policy(read_only)?;
140    direct_backend_with_policies(
141        db_path,
142        read_only,
143        max_readers,
144        wal_ceiling,
145        disk_guard,
146        config.volume_lock_dir,
147    )
148}
149
150/// Open the direct event lane with the policies already resolved for its main
151/// backend. A writable open also needs the disk policy and the volume lock
152/// directory; a read-only open uses neither.
153pub(crate) fn direct_backend_with_policies(
154    db_path: &Path,
155    read_only: bool,
156    max_readers: Option<usize>,
157    wal_ceiling: WalCeilingPolicy,
158    disk_guard: Option<khive_db::EffectiveDiskGuardConfig>,
159    volume_lock_dir: Option<PathBuf>,
160) -> crate::error::RuntimeResult<Arc<StorageBackend>> {
161    wal_ceiling.validate_static(true, true, read_only)?;
162    if !read_only {
163        disk_guard
164            .ok_or_else(|| {
165                crate::error::RuntimeError::Internal("missing events disk policy".into())
166            })?
167            .validate()?;
168        khive_db::require_volume_lock_dir(volume_lock_dir.clone())?;
169    }
170    let mut registry = direct_backend_registry()
171        .lock()
172        .unwrap_or_else(std::sync::PoisonError::into_inner);
173    let absolute_path = absolutize(db_path);
174    let db_path = absolute_path.as_path();
175    refuse_events_db_symlinks(db_path)
176        .map_err(|e| crate::error::RuntimeError::Internal(e.to_string()))?;
177    #[cfg(unix)]
178    if !read_only {
179        if let Some(parent) = db_path.parent().filter(|p| !p.as_os_str().is_empty()) {
180            let _ = std::fs::create_dir_all(parent);
181        }
182    }
183    // Directory trust comes after creation (a fresh parent in a trusted
184    // ancestor must be statable) and before any open: SQLite's open is
185    // path-based, so the only defense against a component swapped between
186    // validation and open is that no untrusted local user can write the
187    // directories the path traverses.
188    #[cfg(unix)]
189    ensure_events_db_parent_trusted(db_path)
190        .map_err(|e| crate::error::RuntimeError::Internal(e.to_string()))?;
191    let key = std::fs::canonicalize(db_path)
192        .or_else(|error| {
193            if error.kind() != std::io::ErrorKind::NotFound {
194                return Err(error);
195            }
196            let parent = db_path.parent().ok_or(error)?;
197            let name = db_path.file_name().ok_or_else(|| {
198                std::io::Error::new(
199                    std::io::ErrorKind::InvalidInput,
200                    "events path has no file name",
201                )
202            })?;
203            std::fs::canonicalize(parent).map(|parent| parent.join(name))
204        })
205        .map_err(|e| crate::error::RuntimeError::Internal(e.to_string()))?;
206    if let Some((existing_read_only, existing)) = registry.get(&key) {
207        if *existing_read_only != read_only {
208            let existing_mode = if *existing_read_only {
209                "read-only"
210            } else {
211                "writable"
212            };
213            let requested_mode = if read_only { "read-only" } else { "writable" };
214            return Err(crate::error::RuntimeError::InvalidInput(format!(
215                "events database {} is already open {existing_mode} in this process; cannot \
216                 open it {requested_mode}; read-only events access requires a separate frozen snapshot",
217                key.display()
218            )));
219        }
220        let numbers = |p: Option<khive_db::EffectiveDiskGuardConfig>| {
221            p.map(|p| (p.reserve_bytes, p.guard_deadline_ms))
222        };
223        if numbers(existing.pool().effective_disk_guard_config()) != numbers(disk_guard) {
224            return Err(crate::error::RuntimeError::Internal(
225                "events database is already open with a different disk reserve/deadline policy"
226                    .into(),
227            ));
228        }
229        if !read_only && existing.pool().config().volume_lock_dir != volume_lock_dir {
230            return Err(crate::error::RuntimeError::Internal(
231                "events database is already open with a different volume-lock directory".into(),
232            ));
233        }
234        let existing_bytes = existing.pool().config().wal_ceiling.bytes;
235        if existing_bytes != wal_ceiling.bytes {
236            return Err(khive_db::SqliteError::InvalidConfig(format!(
237                "events database {} is already open with wal_ceiling_bytes={existing_bytes}; \
238                 requested {}; drain and restart before changing the WAL ceiling",
239                key.display(),
240                wal_ceiling.bytes
241            ))
242            .into());
243        }
244        return Ok(Arc::clone(existing));
245    }
246    let db_path = key.as_path();
247    // Embedded writable mode holds the events database to the daemon's own
248    // contract: owner-only from the first byte, never at the process umask,
249    // and — because event rows carry the same audit payloads either way — a
250    // pre-existing database or -wal/-shm sidecar is tightened to 0600 too,
251    // fail-closed. A create race just means SQLite finds the file present.
252    #[cfg(unix)]
253    if !read_only {
254        use std::os::unix::fs::OpenOptionsExt;
255        let _ = std::fs::OpenOptions::new()
256            .write(true)
257            .create_new(true)
258            .mode(0o600)
259            .open(db_path);
260        // Before the open, and only before it: see the precondition on
261        // `harden_events_db_sidecars`.
262        let _before_open = harden_events_db_sidecars(db_path)
263            .map_err(|e| crate::error::RuntimeError::Internal(e.to_string()))?;
264    }
265    let backend = Arc::new(if read_only {
266        StorageBackend::sqlite_read_only_with_max_readers_and_wal_ceiling(
267            db_path,
268            max_readers,
269            wal_ceiling,
270        )?
271    } else {
272        StorageBackend::sqlite_with_max_readers_and_policies(
273            db_path,
274            max_readers,
275            wal_ceiling,
276            disk_guard.ok_or_else(|| {
277                crate::error::RuntimeError::Internal("missing events disk policy".into())
278            })?,
279            khive_db::require_volume_lock_dir(volume_lock_dir)?,
280        )?
281    });
282    registry.insert(key, (read_only, Arc::clone(&backend)));
283    Ok(backend)
284}
285
286/// Forwarding metrics for the process-wide client at `socket_path`, if one
287/// exists. `None` means the split never initialized in this process.
288#[cfg(unix)]
289pub fn forwarding_metrics(socket_path: &Path) -> Option<EventsForwardingMetrics> {
290    let registry = client_registry()
291        .lock()
292        .unwrap_or_else(std::sync::PoisonError::into_inner);
293    registry.get(socket_path).map(|client| client.metrics())
294}