Skip to main content

marsdb_storage/
lib.rs

1//! Thin trait boundary over redb. `marsdb-graph` talks to this crate, never to
2//! `redb` directly, so the underlying embedded KV engine could be swapped
3//! without touching graph/query code. (A hand-rolled replacement engine was
4//! prototyped and abandoned -- redb stays; the boundary remains because it
5//! costs nothing and keeps the dependency surface honest.)
6
7pub mod tables;
8
9mod error;
10pub use error::StorageError;
11
12mod txn;
13pub use txn::{MultimapTableHandle, TableHandle, Txn};
14
15// Re-exported so callers can open transactions/tables without a direct redb
16// dependency of their own.
17pub use redb::{
18    MultimapTableDefinition, ReadTransaction, ReadableDatabase, ReadableMultimapTable,
19    ReadableTable, ReadableTableMetadata, TableDefinition, WriteTransaction,
20};
21
22use std::fs::OpenOptions;
23use std::path::Path;
24
25/// Version of the MarsDB-owned tables and record encodings. This is separate
26/// from redb's own file-format version.
27// v2 (2026-08): directory record format — interned u32 prop-id keys with
28// per-property offsets replace the v1 whole-blob postcard map (see
29// marsdb-graph/src/encode.rs). v1 files are rejected cleanly; the
30// documented path is export from a v1 build, reimport here.
31pub const CURRENT_FORMAT_VERSION: u64 = 2;
32pub const OLDEST_SUPPORTED_FORMAT_VERSION: u64 = 2;
33
34pub struct StorageEngine {
35    db: redb::Database,
36}
37
38impl StorageEngine {
39    /// Open (creating if absent) a single-file, on-disk database.
40    pub fn open_file(path: impl AsRef<Path>) -> Result<Self, StorageError> {
41        let db = redb::Database::create(path)?;
42        Self::from_db(db)
43    }
44
45    /// Open a purely in-memory database. Nothing is written to disk and all
46    /// data is lost when the `StorageEngine` is dropped.
47    pub fn open_memory() -> Result<Self, StorageError> {
48        let backend = redb::backends::InMemoryBackend::new();
49        let db = redb::Database::builder().create_with_backend(backend)?;
50        Self::from_db(db)
51    }
52
53    /// redb only creates a table on its first write-mode open; a table
54    /// nobody has ever written to doesn't exist yet, and reading from it
55    /// errors instead of returning empty. Eagerly open (and thus create)
56    /// every table up front so read paths never have to special-case "brand
57    /// new, still-empty database" as an error.
58    fn from_db(db: redb::Database) -> Result<Self, StorageError> {
59        let write_txn = db.begin_write()?;
60        // Distinguishes "brand-new file" (no tables at all -- `from_db`
61        // commits table setup and the version marker atomically, so a
62        // crash can't produce a half-initialized state) from a
63        // pre-versioning v1-era file (has data tables, but no
64        // `schema_version` key). The latter used to be silently adopted
65        // and stamped with the current version -- correct when the marker
66        // was introduced (the layouts were identical then), but wrong
67        // ever since format 2 changed the record encoding: stamping a
68        // real v1 file as 2 makes its records decode as garbage later
69        // instead of failing cleanly at open.
70        let is_fresh = write_txn.list_tables()?.next().is_none()
71            && write_txn.list_multimap_tables()?.next().is_none();
72        {
73            let mut meta = write_txn.open_table(tables::META)?;
74            let stored_version = meta.get("schema_version")?.map(|value| value.value());
75            match stored_version {
76                None if is_fresh => {
77                    meta.insert("schema_version", CURRENT_FORMAT_VERSION)?;
78                }
79                // An existing database with no version marker predates
80                // explicit versioning -- format 1 by construction (the
81                // marker shipped before format 2 existed, so every
82                // format-2 file has one).
83                None => {
84                    drop(meta);
85                    write_txn.abort()?;
86                    return Err(StorageError::UnsupportedFormat {
87                        found: 1,
88                        oldest_supported: OLDEST_SUPPORTED_FORMAT_VERSION,
89                        current: CURRENT_FORMAT_VERSION,
90                    });
91                }
92                Some(found)
93                    if !(OLDEST_SUPPORTED_FORMAT_VERSION..=CURRENT_FORMAT_VERSION)
94                        .contains(&found) =>
95                {
96                    drop(meta);
97                    write_txn.abort()?;
98                    return Err(StorageError::UnsupportedFormat {
99                        found,
100                        oldest_supported: OLDEST_SUPPORTED_FORMAT_VERSION,
101                        current: CURRENT_FORMAT_VERSION,
102                    });
103                }
104                Some(_) => {}
105            }
106            drop(meta);
107            write_txn.open_table(tables::LABEL_TO_ID)?;
108            write_txn.open_table(tables::ID_TO_LABEL)?;
109            write_txn.open_table(tables::NODES)?;
110            write_txn.open_table(tables::EDGES)?;
111            write_txn.open_table(tables::ADJ_OUT)?;
112            write_txn.open_table(tables::ADJ_IN)?;
113            write_txn.open_table(tables::REL_TYPE_COUNTS)?;
114            write_txn.open_multimap_table(tables::NODE_LABEL_INDEX)?;
115            write_txn.open_table(tables::PROP_TO_ID)?;
116            write_txn.open_table(tables::ID_TO_PROP)?;
117            write_txn.open_table(tables::INDEX_DEFS)?;
118            write_txn.open_multimap_table(tables::PROPERTY_INDEX)?;
119        }
120        write_txn.commit()?;
121        Ok(Self { db })
122    }
123
124    pub fn begin_write(&self) -> Result<WriteTransaction, StorageError> {
125        Ok(self.db.begin_write()?)
126    }
127
128    pub fn begin_read(&self) -> Result<ReadTransaction, StorageError> {
129        Ok(self.db.begin_read()?)
130    }
131
132    /// Run redb's physical checksum/allocation integrity check. A `false`
133    /// result means damage was found and repaired; an unrecoverable database
134    /// is returned as an error.
135    pub fn check_integrity(&mut self) -> Result<bool, StorageError> {
136        Ok(self.db.check_integrity()?)
137    }
138
139    /// Write a transactionally consistent copy of every MarsDB table to a
140    /// new database file. The destination is created exclusively so an
141    /// existing file is never silently overwritten.
142    pub fn backup_to(&self, path: impl AsRef<Path>) -> Result<(), StorageError> {
143        let path = path.as_ref();
144        let file = OpenOptions::new()
145            .read(true)
146            .write(true)
147            .create_new(true)
148            .open(path)?;
149
150        let result = (|| {
151            let source = self.db.begin_read()?;
152            let destination = redb::Database::builder().create_file(file)?;
153            let write = destination.begin_write()?;
154
155            macro_rules! copy_table {
156                ($definition:expr) => {{
157                    let source_table = source.open_table($definition)?;
158                    let mut destination_table = write.open_table($definition)?;
159                    for entry in source_table.iter()? {
160                        let (key, value) = entry?;
161                        destination_table.insert(key.value(), value.value())?;
162                    }
163                }};
164            }
165
166            macro_rules! copy_multimap {
167                ($definition:expr) => {{
168                    let source_table = source.open_multimap_table($definition)?;
169                    let mut destination_table = write.open_multimap_table($definition)?;
170                    for entry in source_table.iter()? {
171                        let (key, values) = entry?;
172                        for value in values {
173                            destination_table.insert(key.value(), value?.value())?;
174                        }
175                    }
176                }};
177            }
178
179            copy_table!(tables::META);
180            copy_table!(tables::LABEL_TO_ID);
181            copy_table!(tables::ID_TO_LABEL);
182            copy_table!(tables::NODES);
183            copy_table!(tables::EDGES);
184            copy_table!(tables::ADJ_OUT);
185            copy_table!(tables::ADJ_IN);
186            copy_table!(tables::REL_TYPE_COUNTS);
187            copy_multimap!(tables::NODE_LABEL_INDEX);
188            copy_table!(tables::PROP_TO_ID);
189            copy_table!(tables::ID_TO_PROP);
190            copy_table!(tables::INDEX_DEFS);
191            copy_multimap!(tables::PROPERTY_INDEX);
192
193            write.commit()?;
194            Ok::<(), StorageError>(())
195        })();
196
197        if result.is_err() {
198            // This file was created exclusively above, so removing an
199            // incomplete backup cannot affect pre-existing user data.
200            let _ = std::fs::remove_file(path);
201        }
202        result
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn fresh_database_records_current_format_version() {
212        let engine = StorageEngine::open_memory().unwrap();
213        let read = engine.begin_read().unwrap();
214        let meta = read.open_table(tables::META).unwrap();
215        assert_eq!(
216            meta.get("schema_version").unwrap().unwrap().value(),
217            CURRENT_FORMAT_VERSION
218        );
219    }
220
221    /// A pre-versioning v1-era file (data tables present, no
222    /// `schema_version` marker) must be rejected as format 1, not
223    /// silently stamped as the current version -- its records are in the
224    /// old whole-blob encoding and would decode as garbage.
225    #[test]
226    fn unversioned_v1_era_database_is_rejected_not_adopted() {
227        let dir = tempfile::tempdir().unwrap();
228        let path = dir.path().join("v1-era.redb");
229        {
230            let db = redb::Database::create(&path).unwrap();
231            let write = db.begin_write().unwrap();
232            {
233                // A v1-era file always has data tables; META may exist
234                // too (it held the id counters) -- just no
235                // schema_version key.
236                write
237                    .open_table(tables::NODES)
238                    .unwrap()
239                    .insert(1, &[0u8][..])
240                    .unwrap();
241                write
242                    .open_table(tables::META)
243                    .unwrap()
244                    .insert("next_node_id", 1)
245                    .unwrap();
246            }
247            write.commit().unwrap();
248        }
249
250        let err = match StorageEngine::open_file(&path) {
251            Ok(_) => panic!("unversioned v1-era database unexpectedly opened"),
252            Err(err) => err,
253        };
254        assert!(matches!(
255            err,
256            StorageError::UnsupportedFormat { found: 1, .. }
257        ));
258        // Rejection must not have stamped a version into the file.
259        let db = redb::Database::create(&path).unwrap();
260        let read = db.begin_read().unwrap();
261        assert!(read
262            .open_table(tables::META)
263            .unwrap()
264            .get("schema_version")
265            .unwrap()
266            .is_none());
267    }
268
269    #[test]
270    fn database_from_newer_marsdb_is_rejected() {
271        let dir = tempfile::tempdir().unwrap();
272        let path = dir.path().join("future.redb");
273        {
274            let db = redb::Database::create(&path).unwrap();
275            let write = db.begin_write().unwrap();
276            {
277                let mut meta = write.open_table(tables::META).unwrap();
278                meta.insert("schema_version", CURRENT_FORMAT_VERSION + 1)
279                    .unwrap();
280            }
281            write.commit().unwrap();
282        }
283
284        let err = match StorageEngine::open_file(&path) {
285            Ok(_) => panic!("newer database format unexpectedly opened"),
286            Err(err) => err,
287        };
288        assert!(matches!(
289            err,
290            StorageError::UnsupportedFormat {
291                found,
292                current: CURRENT_FORMAT_VERSION,
293                ..
294            } if found == CURRENT_FORMAT_VERSION + 1
295        ));
296    }
297
298    #[test]
299    fn backup_copies_all_tables_and_refuses_to_overwrite() {
300        let source = StorageEngine::open_memory().unwrap();
301        let write = source.begin_write().unwrap();
302        {
303            write
304                .open_table(tables::META)
305                .unwrap()
306                .insert("next_node_id", 7)
307                .unwrap();
308            write
309                .open_table(tables::LABEL_TO_ID)
310                .unwrap()
311                .insert("Person", 3)
312                .unwrap();
313            write
314                .open_table(tables::ID_TO_LABEL)
315                .unwrap()
316                .insert(3, "Person")
317                .unwrap();
318            write
319                .open_table(tables::NODES)
320                .unwrap()
321                .insert(6, &[1, 2, 3][..])
322                .unwrap();
323            write
324                .open_multimap_table(tables::NODE_LABEL_INDEX)
325                .unwrap()
326                .insert(3, 6)
327                .unwrap();
328        }
329        write.commit().unwrap();
330
331        let dir = tempfile::tempdir().unwrap();
332        let path = dir.path().join("backup.redb");
333        source.backup_to(&path).unwrap();
334
335        let backup = StorageEngine::open_file(&path).unwrap();
336        let read = backup.begin_read().unwrap();
337        assert_eq!(
338            read.open_table(tables::META)
339                .unwrap()
340                .get("next_node_id")
341                .unwrap()
342                .unwrap()
343                .value(),
344            7
345        );
346        assert_eq!(
347            read.open_table(tables::NODES)
348                .unwrap()
349                .get(6)
350                .unwrap()
351                .unwrap()
352                .value(),
353            &[1, 2, 3]
354        );
355        assert_eq!(
356            read.open_multimap_table(tables::NODE_LABEL_INDEX)
357                .unwrap()
358                .get(3)
359                .unwrap()
360                .next()
361                .unwrap()
362                .unwrap()
363                .value(),
364            6
365        );
366
367        assert!(matches!(source.backup_to(&path), Err(StorageError::Io(_))));
368    }
369
370    /// Guards against the exact bug this pair of methods once had recurring
371    /// the next time a table is added to `tables.rs`: rather than hardcoding
372    /// the current table list a second time, this asks redb itself what
373    /// tables exist on each side and compares, so a table added to
374    /// `from_db` but forgotten in `backup_to` (or vice versa) fails here
375    /// instead of silently losing data on the next real backup.
376    #[test]
377    fn backup_copies_every_table_that_exists_in_the_source() {
378        use redb::{MultimapTableHandle as _, TableHandle as _};
379        use std::collections::BTreeSet;
380
381        fn table_names(read: &ReadTransaction) -> BTreeSet<String> {
382            let mut names: BTreeSet<String> = read
383                .list_tables()
384                .unwrap()
385                .map(|t| t.name().to_string())
386                .collect();
387            names.extend(
388                read.list_multimap_tables()
389                    .unwrap()
390                    .map(|t| t.name().to_string()),
391            );
392            names
393        }
394
395        let source = StorageEngine::open_memory().unwrap();
396        // Touch every table explicitly, not just the ones `from_db` happens
397        // to eagerly create -- this must catch a missing `copy_table!` even
398        // if a future table is lazily created instead.
399        let write = source.begin_write().unwrap();
400        write
401            .open_table(tables::PROP_TO_ID)
402            .unwrap()
403            .insert("email", 1)
404            .unwrap();
405        write
406            .open_table(tables::ID_TO_PROP)
407            .unwrap()
408            .insert(1, "email")
409            .unwrap();
410        write
411            .open_table(tables::INDEX_DEFS)
412            .unwrap()
413            .insert(&[0u8, 0, 0, 0, 0, 0, 0, 1][..], &[0u8][..])
414            .unwrap();
415        write
416            .open_multimap_table(tables::PROPERTY_INDEX)
417            .unwrap()
418            .insert(&[0u8, 0, 0, 0, 0, 0, 0, 1][..], 6)
419            .unwrap();
420        write.commit().unwrap();
421
422        let dir = tempfile::tempdir().unwrap();
423        let path = dir.path().join("completeness.redb");
424        source.backup_to(&path).unwrap();
425        let backup = StorageEngine::open_file(&path).unwrap();
426
427        let source_tables = table_names(&source.begin_read().unwrap());
428        let backup_tables = table_names(&backup.begin_read().unwrap());
429        assert_eq!(source_tables, backup_tables);
430    }
431}