Skip to main content

kmp_adapter_embedded/adapter/
store.rs

1use std::fs;
2use std::path::Path;
3use std::sync::Arc;
4
5use kmp_domain::PortError;
6
7use super::engine::redb::RedbEngine;
8use super::engine::{Engine, ReadTx, Table, WriteTx};
9use super::format_version::{self, StorageEngine};
10
11/// Every kernel persistence port on one local store.
12///
13/// The engine behind it is chosen when the data directory is created and
14/// hidden behind the storage seam
15/// ([ADR-018](../../../../archive/docs/adr/ADR-018-multi-process-embedded-store.md)):
16/// SQLite for every fresh store, with redb retained only to open and migrate
17/// stamped format-1 stores. Cloning is cheap (shared engine handle). Commits
18/// are fsync-durable on both engines, so
19/// each successful port write survives `kill -9`; a crash mid-transaction
20/// loses only the in-flight transaction.
21#[derive(Debug, Clone)]
22pub struct EmbeddedKernelStore {
23    engine: Arc<dyn Engine>,
24}
25
26impl EmbeddedKernelStore {
27    /// Opens (or initializes) the store inside `data_dir`, applying the
28    /// ADR-012 fail-fast rules before touching the engine. A fresh directory
29    /// gets the default engine; an existing one opens with the engine it was
30    /// created with.
31    pub fn open(data_dir: &Path) -> Result<Self, PortError> {
32        Self::open_as(data_dir, None)
33    }
34
35    /// [`open`](Self::open) with the engine chosen: a fresh directory is
36    /// created for SQLite, and an existing one must already be `engine` — a
37    /// store is never reinterpreted as another engine's. redb is accepted
38    /// only when a format-1 stamp already exists.
39    pub fn open_with_engine(data_dir: &Path, engine: StorageEngine) -> Result<Self, PortError> {
40        if engine == StorageEngine::Redb && !format_version::format_version_path(data_dir).exists()
41        {
42            return Err(PortError::InvalidState(
43                "the redb engine is legacy-only and cannot create a new store; use SQLite"
44                    .to_string(),
45            ));
46        }
47        Self::open_as(data_dir, Some(engine))
48    }
49
50    /// The engine a data directory was created with, without opening it.
51    pub fn engine_of(data_dir: &Path) -> Result<StorageEngine, PortError> {
52        format_version::check_or_stamp_as(data_dir, None)
53    }
54
55    fn open_as(data_dir: &Path, wanted: Option<StorageEngine>) -> Result<Self, PortError> {
56        fs::create_dir_all(data_dir).map_err(|error| {
57            PortError::Unavailable(format!(
58                "embedded store could not create data dir `{}`: {error}",
59                data_dir.display()
60            ))
61        })?;
62        let engine = format_version::check_or_stamp_as(data_dir, wanted)?;
63
64        let store_file = format_version::store_file_path_for(data_dir, engine);
65        fs::create_dir_all(store_file.parent().expect("store file has a parent")).map_err(
66            |error| {
67                PortError::Unavailable(format!(
68                    "embedded store could not create store dir under `{}`: {error}",
69                    data_dir.display()
70                ))
71            },
72        )?;
73
74        Self::open_store_file(&store_file, engine)
75    }
76
77    /// Opens a bare store file, without the data-directory layout or its
78    /// format gate. Only two callers may want this: `open`, which has just
79    /// applied the gate itself, and the migration, which reads a *copy* of a
80    /// store whose format this binary refuses to open in place.
81    pub(crate) fn open_store_file(
82        store_file: &Path,
83        engine: StorageEngine,
84    ) -> Result<Self, PortError> {
85        let engine: Arc<dyn Engine> = match engine {
86            StorageEngine::Redb => Arc::new(RedbEngine::open_file(store_file)?),
87            StorageEngine::Sqlite => {
88                Arc::new(super::engine::sqlite::SqliteEngine::open_file(store_file)?)
89            }
90        };
91        Ok(Self { engine })
92    }
93
94    pub(crate) fn begin_write(&self) -> Result<Box<dyn WriteTx + '_>, PortError> {
95        self.engine.begin_write()
96    }
97
98    pub(crate) fn begin_read(&self) -> Result<Box<dyn ReadTx + '_>, PortError> {
99        self.engine.begin_read()
100    }
101
102    /// Runs blocking engine work on the blocking thread pool so port calls
103    /// never stall the async executor on fsync.
104    pub(crate) async fn run<T, F>(&self, task: F) -> Result<T, PortError>
105    where
106        T: Send + 'static,
107        F: FnOnce(&EmbeddedKernelStore) -> Result<T, PortError> + Send + 'static,
108    {
109        let store = self.clone();
110        tokio::task::spawn_blocking(move || task(&store))
111            .await
112            .map_err(|error| {
113                PortError::Unavailable(format!("embedded store worker failed: {error}"))
114            })?
115    }
116
117    /// Number of events in the append-only log and the highest sequence —
118    /// audit surface used by recovery checks and operational tooling.
119    pub async fn event_log_stats(&self) -> Result<(u64, u64), PortError> {
120        self.run(|store| {
121            let tx = store.begin_read()?;
122            let count = tx.count(Table::EventLog)?;
123            let last_sequence = tx.last_u64(Table::EventLog)?.map_or(0, |(key, _)| key);
124            Ok((count, last_sequence))
125        })
126        .await
127    }
128
129    /// Compacts the store file in place, reclaiming free pages left by
130    /// past transactions (e.g. after a projection rebuild). Requires
131    /// exclusive access: call it with no other store handle open on the
132    /// same data directory.
133    pub fn compact_data_dir(data_dir: &Path) -> Result<bool, PortError> {
134        let engine = format_version::check_or_stamp(data_dir)?;
135        let store_file = format_version::store_file_path_for(data_dir, engine);
136        match engine {
137            StorageEngine::Redb => RedbEngine::compact_file(&store_file),
138            StorageEngine::Sqlite => super::engine::sqlite::SqliteEngine::compact_file(&store_file),
139        }
140    }
141}
142
143pub(crate) fn aggregate_key(root_node_id: &str, role: &str) -> String {
144    format!("{root_node_id}\u{1f}{role}")
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn the_public_api_cannot_create_a_fresh_redb_store() {
153        let data_dir = tempfile::tempdir().expect("temp data dir");
154        let error = EmbeddedKernelStore::open_with_engine(data_dir.path(), StorageEngine::Redb)
155            .expect_err("redb is legacy-only");
156        assert!(error.to_string().contains("legacy-only"), "{error}");
157        assert!(!format_version::format_version_path(data_dir.path()).exists());
158    }
159}