Skip to main content

silk/
store.rs

1use std::path::Path;
2
3use redb::{Database, ReadableTable, TableDefinition};
4
5use crate::entry::Entry;
6use crate::oplog::{OpLog, OpLogError};
7
8/// redb table: entry hash (32 bytes) → msgpack-serialized Entry.
9const ENTRIES_TABLE: TableDefinition<&[u8], &[u8]> = TableDefinition::new("entries");
10
11/// redb table: "heads" → msgpack-serialized Vec<Hash>.
12const META_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("meta");
13
14/// Flush mode controls when entries are persisted to disk.
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub enum FlushMode {
17    /// Persist every write immediately (safe, slow — ~1000x overhead).
18    /// Each `append()` does a redb commit with fsync.
19    Immediate,
20    /// Buffer writes in memory, persist on explicit `flush()` (fast, deferred durability).
21    /// Entries are in the oplog immediately (read-your-writes) but not on disk until flush.
22    /// On crash: entries since last flush are lost. Peers restore them on next sync.
23    Deferred,
24}
25
26/// Persistent graph store backed by redb + in-memory OpLog.
27///
28/// On open: loads all entries from redb into the OpLog.
29/// On append: writes to OpLog (in-memory) immediately. Persistence depends on `flush_mode`:
30/// - `Immediate`: each write persists to redb (safe, slow).
31/// - `Deferred`: writes buffer until `flush()` is called (fast, one fsync for N writes).
32pub struct Store {
33    db: Database,
34    pub oplog: OpLog,
35    flush_mode: FlushMode,
36    /// Entries appended since last flush (Deferred mode only).
37    pending: Vec<Entry>,
38}
39
40impl Store {
41    /// Open or create a store at the given path.
42    ///
43    /// If the database already exists, all entries are loaded into the OpLog.
44    /// If the database is new, a genesis entry must be provided.
45    pub fn open(path: &Path, genesis: Option<Entry>) -> Result<Self, StoreError> {
46        let db = Database::create(path).map_err(|e| StoreError::Io(e.to_string()))?;
47
48        // S-09: restrict file permissions to owner-only on Unix
49        #[cfg(unix)]
50        {
51            use std::os::unix::fs::PermissionsExt;
52            let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
53        }
54
55        // Ensure tables exist.
56        {
57            let txn = db
58                .begin_write()
59                .map_err(|e| StoreError::Io(e.to_string()))?;
60            {
61                let _t = txn
62                    .open_table(ENTRIES_TABLE)
63                    .map_err(|e| StoreError::Io(e.to_string()))?;
64                let _m = txn
65                    .open_table(META_TABLE)
66                    .map_err(|e| StoreError::Io(e.to_string()))?;
67            }
68            txn.commit().map_err(|e| StoreError::Io(e.to_string()))?;
69        }
70
71        // Try to load existing entries.
72        let existing_entries = Self::load_entries(&db)?;
73
74        if !existing_entries.is_empty() {
75            // Reconstruct OpLog from stored entries.
76            let oplog = Self::reconstruct_oplog(existing_entries)?;
77            return Ok(Self {
78                db,
79                oplog,
80                flush_mode: FlushMode::Immediate,
81                pending: Vec::new(),
82            });
83        }
84
85        // No existing entries — need genesis.
86        let genesis = genesis.ok_or(StoreError::NoGenesis)?;
87        let oplog = OpLog::new(genesis.clone());
88
89        // Persist genesis (single transaction).
90        let store = Self {
91            db,
92            oplog,
93            flush_mode: FlushMode::Immediate,
94            pending: Vec::new(),
95        };
96        store.persist_entry_and_heads(&genesis)?;
97
98        Ok(store)
99    }
100
101    /// Append an entry — writes to OpLog immediately, persists based on flush_mode.
102    pub fn append(&mut self, entry: Entry) -> Result<bool, StoreError> {
103        let inserted = self
104            .oplog
105            .append(entry.clone())
106            .map_err(StoreError::OpLog)?;
107        if inserted {
108            match self.flush_mode {
109                FlushMode::Immediate => self.persist_entry_and_heads(&entry)?,
110                FlushMode::Deferred => self.pending.push(entry),
111            }
112        }
113        Ok(inserted)
114    }
115
116    /// Set the flush mode.
117    pub fn set_flush_mode(&mut self, mode: FlushMode) {
118        self.flush_mode = mode;
119    }
120
121    /// Flush all pending entries to redb in a single transaction.
122    /// No-op if no pending entries or if flush_mode is Immediate.
123    pub fn flush(&mut self) -> Result<usize, StoreError> {
124        if self.pending.is_empty() {
125            return Ok(0);
126        }
127        let count = self.pending.len();
128        let entries: Vec<Entry> = self.pending.drain(..).collect();
129        self.persist_entries_and_heads(&entries)?;
130        Ok(count)
131    }
132
133    /// Number of entries pending flush (0 in Immediate mode).
134    pub fn pending_count(&self) -> usize {
135        self.pending.len()
136    }
137
138    /// Merge a batch of remote entries — writes each to OpLog and redb.
139    ///
140    /// Handles out-of-order entries by retrying those with missing parents.
141    /// Returns the number of new entries merged.
142    /// Review 4 fix: batches all entry writes + heads into fewer transactions.
143    pub fn merge(&mut self, entries: &[Entry]) -> Result<usize, StoreError> {
144        let mut inserted = 0;
145        let mut new_entries: Vec<Entry> = Vec::new();
146        let mut remaining: Vec<&Entry> = entries.iter().collect();
147        let mut max_passes = remaining.len() + 1;
148
149        while !remaining.is_empty() && max_passes > 0 {
150            let mut next_remaining = Vec::new();
151            for entry in &remaining {
152                match self.oplog.append((*entry).clone()) {
153                    Ok(true) => {
154                        new_entries.push((*entry).clone());
155                        inserted += 1;
156                    }
157                    Ok(false) => {
158                        // Duplicate — already have it.
159                    }
160                    Err(crate::oplog::OpLogError::MissingParent(_)) => {
161                        next_remaining.push(*entry);
162                    }
163                    Err(crate::oplog::OpLogError::InvalidHash) => {
164                        return Err(StoreError::Io(format!(
165                            "invalid hash for entry {}",
166                            hex::encode(entry.hash)
167                        )));
168                    }
169                }
170            }
171            if next_remaining.len() == remaining.len() {
172                return Err(StoreError::Io(format!(
173                    "{} entries have unresolvable parents",
174                    remaining.len()
175                )));
176            }
177            remaining = next_remaining;
178            max_passes -= 1;
179        }
180
181        if !new_entries.is_empty() {
182            match self.flush_mode {
183                FlushMode::Immediate => self.persist_entries_and_heads(&new_entries)?,
184                FlushMode::Deferred => self.pending.extend(new_entries),
185            }
186        }
187
188        Ok(inserted)
189    }
190
191    /// R-08: Replace entire store with a single checkpoint entry.
192    pub fn replace_with_checkpoint(&mut self, checkpoint: Entry) -> Result<(), StoreError> {
193        let txn = self
194            .db
195            .begin_write()
196            .map_err(|e| StoreError::Io(e.to_string()))?;
197        {
198            let mut table = txn
199                .open_table(ENTRIES_TABLE)
200                .map_err(|e| StoreError::Io(e.to_string()))?;
201            // Collect all existing keys
202            let keys: Vec<Vec<u8>> = table
203                .iter()
204                .map_err(|e| StoreError::Io(e.to_string()))?
205                .filter_map(|r| r.ok().map(|(k, _)| k.value().to_vec()))
206                .collect();
207            for key in keys {
208                table
209                    .remove(key.as_slice())
210                    .map_err(|e| StoreError::Io(e.to_string()))?;
211            }
212            // Insert checkpoint
213            let entry_bytes = checkpoint.to_bytes();
214            table
215                .insert(checkpoint.hash.as_slice(), entry_bytes.as_slice())
216                .map_err(|e| StoreError::Io(e.to_string()))?;
217        }
218        {
219            let mut meta = txn
220                .open_table(META_TABLE)
221                .map_err(|e| StoreError::Io(e.to_string()))?;
222            let heads_bytes = rmp_serde::to_vec(&vec![checkpoint.hash])
223                .map_err(|e| StoreError::Io(e.to_string()))?;
224            meta.insert("heads", heads_bytes.as_slice())
225                .map_err(|e| StoreError::Io(e.to_string()))?;
226        }
227        txn.commit().map_err(|e| StoreError::Io(e.to_string()))?;
228
229        // Update in-memory oplog
230        self.oplog.replace_with_checkpoint(checkpoint);
231
232        Ok(())
233    }
234
235    /// Shrink the database file by reclaiming free pages (redb compaction).
236    /// The file otherwise stays at its high-water mark after `replace_with_checkpoint`.
237    /// Returns true if the file shrank.
238    pub fn reclaim_disk(&mut self) -> Result<bool, StoreError> {
239        self.db.compact().map_err(|e| StoreError::Io(e.to_string()))
240    }
241
242    /// Persist a single entry + updated heads in one redb transaction.
243    fn persist_entry_and_heads(&self, entry: &Entry) -> Result<(), StoreError> {
244        let txn = self
245            .db
246            .begin_write()
247            .map_err(|e| StoreError::Io(e.to_string()))?;
248        {
249            let mut entries_table = txn
250                .open_table(ENTRIES_TABLE)
251                .map_err(|e| StoreError::Io(e.to_string()))?;
252            let bytes = entry.to_bytes();
253            entries_table
254                .insert(entry.hash.as_slice(), bytes.as_slice())
255                .map_err(|e| StoreError::Io(e.to_string()))?;
256        }
257        {
258            let mut meta_table = txn
259                .open_table(META_TABLE)
260                .map_err(|e| StoreError::Io(e.to_string()))?;
261            let heads = self.oplog.heads();
262            let heads_bytes =
263                rmp_serde::to_vec(&heads).map_err(|e| StoreError::Io(e.to_string()))?;
264            meta_table
265                .insert("heads", heads_bytes.as_slice())
266                .map_err(|e| StoreError::Io(e.to_string()))?;
267        }
268        txn.commit().map_err(|e| StoreError::Io(e.to_string()))?;
269        Ok(())
270    }
271
272    /// Persist multiple entries + updated heads in one redb transaction.
273    fn persist_entries_and_heads(&self, entries: &[Entry]) -> Result<(), StoreError> {
274        let txn = self
275            .db
276            .begin_write()
277            .map_err(|e| StoreError::Io(e.to_string()))?;
278        {
279            let mut entries_table = txn
280                .open_table(ENTRIES_TABLE)
281                .map_err(|e| StoreError::Io(e.to_string()))?;
282            for entry in entries {
283                let bytes = entry.to_bytes();
284                entries_table
285                    .insert(entry.hash.as_slice(), bytes.as_slice())
286                    .map_err(|e| StoreError::Io(e.to_string()))?;
287            }
288        }
289        {
290            let mut meta_table = txn
291                .open_table(META_TABLE)
292                .map_err(|e| StoreError::Io(e.to_string()))?;
293            let heads = self.oplog.heads();
294            let heads_bytes =
295                rmp_serde::to_vec(&heads).map_err(|e| StoreError::Io(e.to_string()))?;
296            meta_table
297                .insert("heads", heads_bytes.as_slice())
298                .map_err(|e| StoreError::Io(e.to_string()))?;
299        }
300        txn.commit().map_err(|e| StoreError::Io(e.to_string()))?;
301        Ok(())
302    }
303
304    /// Load all entries from redb.
305    fn load_entries(db: &Database) -> Result<Vec<Entry>, StoreError> {
306        let txn = db.begin_read().map_err(|e| StoreError::Io(e.to_string()))?;
307        let table = match txn.open_table(ENTRIES_TABLE) {
308            Ok(t) => t,
309            Err(_) => return Ok(vec![]),
310        };
311
312        let mut entries = Vec::new();
313        let iter = table.iter().map_err(|e| StoreError::Io(e.to_string()))?;
314        for result in iter {
315            let (_, value) = result.map_err(|e| StoreError::Io(e.to_string()))?;
316            let entry = Entry::from_bytes(value.value())
317                .map_err(|e| StoreError::Io(format!("corrupt entry: {e}")))?;
318            entries.push(entry);
319        }
320        Ok(entries)
321    }
322
323    /// Reconstruct an OpLog from a flat list of entries.
324    ///
325    /// Finds the genesis (entry with empty `next`), topologically sorts
326    /// remaining entries by their `next` links, then appends in order.
327    /// Review 4 fix: O(n) via topo sort instead of O(n²) retry loop.
328    fn reconstruct_oplog(entries: Vec<Entry>) -> Result<OpLog, StoreError> {
329        use std::collections::{HashMap, HashSet, VecDeque};
330
331        if entries.is_empty() {
332            return Err(StoreError::Io("no entries to reconstruct".into()));
333        }
334
335        // Index entries by hash, find all roots (entries with next=[])
336        let mut by_hash: HashMap<crate::entry::Hash, Entry> = HashMap::new();
337        let mut roots: Vec<Entry> = Vec::new();
338        for entry in entries {
339            if entry.next.is_empty() {
340                roots.push(entry.clone());
341            }
342            by_hash.insert(entry.hash, entry);
343        }
344
345        if roots.is_empty() {
346            return Err(StoreError::Io("no genesis entry found".into()));
347        }
348
349        // Use the first root as genesis for the OpLog
350        // (multi-peer stores may have multiple roots after sync)
351        let genesis = roots[0].clone();
352        let genesis_hash = genesis.hash;
353        let mut oplog = OpLog::new(genesis);
354
355        // Track all root hashes as "resolved"
356        let mut resolved: HashSet<crate::entry::Hash> = HashSet::new();
357        resolved.insert(genesis_hash);
358
359        // Append additional roots (other peers' genesis entries)
360        // These have next=[] and are handled by oplog.append() as Checkpoint entries
361        // or accepted as additional roots.
362        for root in &roots[1..] {
363            resolved.insert(root.hash);
364            // These are already handled by the oplog (Checkpoint replace or duplicate skip)
365            let _ = oplog.append(root.clone());
366        }
367
368        // Build reverse index: parent_hash → children that depend on it
369        let mut children_of: HashMap<crate::entry::Hash, Vec<crate::entry::Hash>> = HashMap::new();
370        let mut pending_parents: HashMap<crate::entry::Hash, HashSet<crate::entry::Hash>> =
371            HashMap::new();
372
373        for (hash, entry) in &by_hash {
374            if resolved.contains(hash) {
375                continue;
376            }
377            let parents: HashSet<_> = entry.next.iter().copied().collect();
378            pending_parents.insert(*hash, parents.clone());
379            for parent in &parents {
380                children_of.entry(*parent).or_default().push(*hash);
381            }
382        }
383
384        // BFS from all resolved roots: process entries whose parents are all resolved
385        let mut ready: VecDeque<crate::entry::Hash> = VecDeque::new();
386
387        for root_hash in &resolved {
388            if let Some(kids) = children_of.get(root_hash) {
389                for kid in kids {
390                    if let Some(pp) = pending_parents.get_mut(kid) {
391                        pp.remove(root_hash);
392                        if pp.is_empty() {
393                            ready.push_back(*kid);
394                        }
395                    }
396                }
397            }
398        }
399
400        while let Some(hash) = ready.pop_front() {
401            if let Some(entry) = by_hash.get(&hash) {
402                match oplog.append(entry.clone()) {
403                    Ok(_) => {}
404                    Err(e) => {
405                        return Err(StoreError::Io(format!("reconstruct failed: {e}")));
406                    }
407                }
408                // Unblock children that depended on this entry
409                if let Some(kids) = children_of.get(&hash) {
410                    for kid in kids {
411                        if let Some(pp) = pending_parents.get_mut(kid) {
412                            pp.remove(&hash);
413                            if pp.is_empty() {
414                                ready.push_back(*kid);
415                            }
416                        }
417                    }
418                }
419            }
420        }
421
422        // Check for unresolvable entries
423        let unresolved: Vec<_> = pending_parents
424            .iter()
425            .filter(|(_, parents)| !parents.is_empty())
426            .collect();
427        if !unresolved.is_empty() {
428            return Err(StoreError::Io(format!(
429                "could not reconstruct oplog: {} entries with unresolvable parents",
430                unresolved.len()
431            )));
432        }
433
434        Ok(oplog)
435    }
436}
437
438#[derive(Debug)]
439pub enum StoreError {
440    Io(String),
441    NoGenesis,
442    OpLog(OpLogError),
443}
444
445impl std::fmt::Display for StoreError {
446    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
447        match self {
448            StoreError::Io(msg) => write!(f, "store I/O error: {msg}"),
449            StoreError::NoGenesis => write!(f, "no genesis entry provided for new store"),
450            StoreError::OpLog(e) => write!(f, "oplog error: {e}"),
451        }
452    }
453}
454
455impl std::error::Error for StoreError {}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use crate::clock::LamportClock;
461    use crate::entry::{GraphOp, Hash};
462    use crate::ontology::{NodeTypeDef, Ontology};
463    use std::collections::BTreeMap;
464
465    fn test_ontology() -> Ontology {
466        Ontology {
467            node_types: BTreeMap::from([(
468                "entity".into(),
469                NodeTypeDef {
470                    description: None,
471                    properties: BTreeMap::new(),
472                    subtypes: None,
473                    parent_type: None,
474                },
475            )]),
476            edge_types: BTreeMap::new(),
477        }
478    }
479
480    fn genesis() -> Entry {
481        Entry::new(
482            GraphOp::DefineOntology {
483                ontology: test_ontology(),
484            },
485            vec![],
486            vec![],
487            LamportClock::new("test"),
488            "test",
489        )
490    }
491
492    fn add_node_op(id: &str) -> GraphOp {
493        GraphOp::AddNode {
494            node_id: id.into(),
495            node_type: "entity".into(),
496            label: id.into(),
497            properties: BTreeMap::new(),
498            subtype: None,
499        }
500    }
501
502    fn make_entry(op: GraphOp, next: Vec<Hash>, clock_time: u64) -> Entry {
503        Entry::new(
504            op,
505            next,
506            vec![],
507            LamportClock::with_values("test", clock_time, 0),
508            "test",
509        )
510    }
511
512    #[test]
513    fn open_creates_file() {
514        let dir = tempfile::tempdir().unwrap();
515        let path = dir.path().join("test.redb");
516        assert!(!path.exists());
517
518        let store = Store::open(&path, Some(genesis())).unwrap();
519        assert!(path.exists());
520        assert_eq!(store.oplog.len(), 1);
521    }
522
523    #[test]
524    fn open_existing_loads_state() {
525        let dir = tempfile::tempdir().unwrap();
526        let path = dir.path().join("test.redb");
527        let g = genesis();
528
529        // Create store, append entries.
530        {
531            let mut store = Store::open(&path, Some(g.clone())).unwrap();
532            let e1 = make_entry(add_node_op("n1"), vec![g.hash], 2);
533            let e2 = make_entry(add_node_op("n2"), vec![e1.hash], 3);
534            store.append(e1).unwrap();
535            store.append(e2).unwrap();
536            assert_eq!(store.oplog.len(), 3);
537        }
538
539        // Reopen — should have the same state.
540        {
541            let store = Store::open(&path, None).unwrap();
542            assert_eq!(store.oplog.len(), 3);
543            let heads = store.oplog.heads();
544            assert_eq!(heads.len(), 1);
545        }
546    }
547
548    #[test]
549    fn new_store_without_genesis_fails() {
550        let dir = tempfile::tempdir().unwrap();
551        let path = dir.path().join("test.redb");
552        match Store::open(&path, None) {
553            Err(StoreError::NoGenesis) => {} // expected
554            Ok(_) => panic!("expected NoGenesis error, got Ok"),
555            Err(e) => panic!("expected NoGenesis, got {e}"),
556        }
557    }
558
559    #[test]
560    fn append_persists_across_reopen() {
561        let dir = tempfile::tempdir().unwrap();
562        let path = dir.path().join("test.redb");
563        let g = genesis();
564
565        let e1 = make_entry(add_node_op("n1"), vec![g.hash], 2);
566        let e1_hash = e1.hash;
567
568        {
569            let mut store = Store::open(&path, Some(g.clone())).unwrap();
570            store.append(e1).unwrap();
571        }
572
573        {
574            let store = Store::open(&path, None).unwrap();
575            assert_eq!(store.oplog.len(), 2);
576            assert!(store.oplog.get(&e1_hash).is_some());
577        }
578    }
579
580    #[test]
581    fn concurrent_readers_ok() {
582        use std::thread;
583
584        let dir = tempfile::tempdir().unwrap();
585        let path = dir.path().join("test.redb");
586        let g = genesis();
587
588        let mut store = Store::open(&path, Some(g.clone())).unwrap();
589        for i in 0..10 {
590            let next = store.oplog.heads();
591            let e = make_entry(add_node_op(&format!("n{i}")), next, (i + 2) as u64);
592            store.append(e).unwrap();
593        }
594
595        // Multiple scoped threads reading via begin_read() on the shared Database.
596        thread::scope(|s| {
597            for _ in 0..4 {
598                s.spawn(|| {
599                    let txn = store.db.begin_read().unwrap();
600                    let table = txn.open_table(ENTRIES_TABLE).unwrap();
601                    let count = table.iter().unwrap().count();
602                    assert_eq!(count, 11); // genesis + 10
603                });
604            }
605        });
606    }
607}