Skip to main content

recern_vector/
database.rs

1use crate::collection::{Collection, CollectionConfig};
2use crate::error::{Error, Result};
3use crate::{storage, wal};
4use std::collections::BTreeMap;
5use std::fs::{self, File, OpenOptions};
6use std::io::Write;
7use std::path::{Path, PathBuf};
8use std::sync::Mutex;
9
10/// An in-memory database backed by a snapshot and a checksummed `.wal` journal.
11/// Mutations become durable at `save()`. Use `checkpoint()` before copying a
12/// standalone file. Concurrent handles can read; stale writers fail on save.
13pub struct Database {
14    path: PathBuf,
15    collections: BTreeMap<String, Collection>,
16    persistence: Mutex<Vec<u8>>,
17    read_only: bool,
18}
19struct Lock(File);
20impl Lock {
21    fn write(path: &Path) -> Result<Self> {
22        let file = OpenOptions::new()
23            .create(true)
24            .truncate(false)
25            .read(true)
26            .write(true)
27            .open(wal::sidecar(path, ".lock"))?;
28        file.lock()?;
29        Ok(Self(file))
30    }
31    fn read(path: &Path) -> Result<Option<Self>> {
32        match File::open(wal::sidecar(path, ".lock")) {
33            Ok(file) => {
34                file.lock_shared()?;
35                Ok(Some(Self(file)))
36            }
37            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
38            Err(e) => Err(e.into()),
39        }
40    }
41}
42impl Drop for Lock {
43    fn drop(&mut self) {
44        let _ = self.0.unlock();
45    }
46}
47
48impl Database {
49    pub fn create(path: impl AsRef<Path>) -> Result<Self> {
50        let path = absolute(path.as_ref())?;
51        let _lock = Lock::write(&path)?;
52        if path.exists() {
53            return Err(Error::FileExists(path));
54        }
55        if wal::sidecar(&path, ".wal").exists() {
56            return Err(Error::InvalidArgument(
57                "orphan WAL exists; recover or remove it before creating a database".into(),
58            ));
59        }
60        let collections = BTreeMap::new();
61        let bytes = storage::encode(&collections, identity(), 0);
62        atomic_snapshot(&path, &bytes)?;
63        Ok(Self {
64            path,
65            collections,
66            persistence: Mutex::new(bytes),
67            read_only: false,
68        })
69    }
70    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
71        Self::load(path.as_ref(), false)
72    }
73    /// Loads a consistent snapshot including committed WAL records, without
74    /// creating sidecars or allowing mutations through this handle.
75    pub fn open_read_only(path: impl AsRef<Path>) -> Result<Self> {
76        Self::load(path.as_ref(), true)
77    }
78    fn load(path: &Path, read_only: bool) -> Result<Self> {
79        let path = fs::canonicalize(path)?;
80        let _lock = if read_only {
81            Lock::read(&path)?
82        } else {
83            Some(Lock::write(&path)?)
84        };
85        let loaded = wal::load(&path)?;
86        let collections = storage::decode(&loaded.bytes)?;
87        Ok(Self {
88            path,
89            collections,
90            persistence: Mutex::new(loaded.bytes),
91            read_only,
92        })
93    }
94    pub fn open_or_create(path: impl AsRef<Path>) -> Result<Self> {
95        match Self::open(&path) {
96            Err(Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
97                match Self::create(&path) {
98                    Err(Error::FileExists(_)) => Self::open(path),
99                    result => result,
100                }
101            }
102            result => result,
103        }
104    }
105    /// Format of this handle's last durable snapshot (including WAL).
106    pub fn format_version(&self) -> u32 {
107        let bytes = self
108            .persistence
109            .lock()
110            .unwrap_or_else(std::sync::PoisonError::into_inner);
111        u32::from_le_bytes(bytes[4..8].try_into().expect("validated header"))
112    }
113    pub fn is_read_only(&self) -> bool {
114        self.read_only
115    }
116
117    /// Persistent format-2 database identity. Legacy format-1 files return
118    /// zero until their first save upgrades them; checkpoints keep the ID.
119    pub fn identity(&self) -> u128 {
120        let bytes = self
121            .persistence
122            .lock()
123            .unwrap_or_else(std::sync::PoisonError::into_inner);
124        storage::header(&bytes).0
125    }
126
127    /// Last durable commit sequence, including committed WAL frames.
128    pub fn sequence(&self) -> u64 {
129        let bytes = self
130            .persistence
131            .lock()
132            .unwrap_or_else(std::sync::PoisonError::into_inner);
133        storage::header(&bytes).1
134    }
135    fn writable(&self) -> Result<()> {
136        if self.read_only {
137            Err(Error::InvalidArgument("database is read-only".into()))
138        } else {
139            Ok(())
140        }
141    }
142    pub fn path(&self) -> &Path {
143        &self.path
144    }
145
146    pub fn create_collection(
147        &mut self,
148        name: &str,
149        config: CollectionConfig,
150    ) -> Result<&mut Collection> {
151        self.writable()?;
152        if name.is_empty() || name.len() > 255 {
153            return Err(Error::InvalidArgument(
154                "collection name must be 1-255 bytes".into(),
155            ));
156        }
157        if self.collections.contains_key(name) {
158            return Err(Error::CollectionExists(name.to_owned()));
159        }
160        let collection = Collection::new(name, config)?;
161        Ok(self
162            .collections
163            .entry(name.to_owned())
164            .or_insert(collection))
165    }
166
167    pub fn collection(&self, name: &str) -> Result<&Collection> {
168        self.collections
169            .get(name)
170            .ok_or_else(|| Error::CollectionNotFound(name.to_owned()))
171    }
172
173    pub fn collection_mut(&mut self, name: &str) -> Result<&mut Collection> {
174        self.writable()?;
175        self.collections
176            .get_mut(name)
177            .ok_or_else(|| Error::CollectionNotFound(name.to_owned()))
178    }
179
180    pub fn drop_collection(&mut self, name: &str) -> Result<()> {
181        self.writable()?;
182        self.collections
183            .remove(name)
184            .map(|_| ())
185            .ok_or_else(|| Error::CollectionNotFound(name.to_owned()))
186    }
187
188    pub fn collections(&self) -> impl Iterator<Item = &Collection> {
189        self.collections.values()
190    }
191
192    /// Appends changed pages and fsyncs a commit. The base snapshot is unchanged
193    /// except when migrating a format-1 database. No-op saves do not grow WAL.
194    pub fn save(&self) -> Result<()> {
195        self.persist(false)
196    }
197    /// Atomically merges committed and pending changes into the base snapshot,
198    /// then resets WAL. A crash between those steps is safe to recover.
199    pub fn checkpoint(&self) -> Result<()> {
200        self.persist(true)
201    }
202    fn persist(&self, checkpoint: bool) -> Result<()> {
203        self.writable()?;
204        let mut previous = self
205            .persistence
206            .lock()
207            .map_err(|_| Error::InvalidArgument("persistence lock poisoned".into()))?;
208        let _lock = Lock::write(&self.path)?;
209        let disk = wal::load(&self.path)?;
210        if disk.bytes != *previous {
211            return Err(Error::InvalidArgument(
212                "database changed in another handle; reopen before writing".into(),
213            ));
214        }
215        let (mut id, seq) = storage::header(&previous);
216        let migrate = id == 0;
217        if migrate {
218            id = identity();
219        }
220        let unchanged = storage::encode(&self.collections, id, seq) == *previous;
221        if unchanged && !checkpoint {
222            return Ok(());
223        }
224        let next = if unchanged {
225            seq
226        } else {
227            seq.checked_add(1)
228                .ok_or_else(|| Error::InvalidArgument("commit sequence exhausted".into()))?
229        };
230        let bytes = storage::encode(&self.collections, id, next);
231        if checkpoint || migrate {
232            atomic_snapshot(&self.path, &bytes)?;
233            // Once the rename is durable, old WAL commits are already included.
234            // Update memory even if resetting the log fails; retry stays safe.
235            *previous = bytes;
236            atomic_snapshot(&wal::sidecar(&self.path, ".wal"), &wal::header(id))?;
237            sync_parent_dir(&self.path);
238        } else {
239            let frame = wal::frame(&previous, &bytes, next);
240            wal::append(&self.path, disk.wal_len, id, &frame)?;
241            *previous = bytes;
242        }
243        Ok(())
244    }
245}
246fn absolute(path: &Path) -> Result<PathBuf> {
247    if path.exists() {
248        return Ok(fs::canonicalize(path)?);
249    }
250    let parent = path
251        .parent()
252        .filter(|p| !p.as_os_str().is_empty())
253        .unwrap_or(Path::new("."));
254    Ok(fs::canonicalize(parent)?.join(
255        path.file_name()
256            .ok_or_else(|| Error::InvalidArgument("file name required".into()))?,
257    ))
258}
259fn identity() -> u128 {
260    use std::sync::atomic::{AtomicU64, Ordering};
261    static NEXT: AtomicU64 = AtomicU64::new(1);
262    let time = std::time::SystemTime::now()
263        .duration_since(std::time::UNIX_EPOCH)
264        .unwrap_or_default()
265        .as_nanos();
266    time ^ ((std::process::id() as u128) << 64) ^ NEXT.fetch_add(1, Ordering::Relaxed) as u128
267}
268fn atomic_snapshot(path: &Path, bytes: &[u8]) -> Result<()> {
269    let tmp = wal::sidecar(path, &format!(".{}.tmp", identity()));
270    let result = (|| {
271        let mut file = OpenOptions::new().create_new(true).write(true).open(&tmp)?;
272        file.write_all(bytes)?;
273        file.sync_all()?;
274        fs::rename(&tmp, path)?;
275        sync_parent_dir(path);
276        Ok(())
277    })();
278    if result.is_err() {
279        let _ = fs::remove_file(tmp);
280    }
281    result
282}
283pub(crate) fn sync_parent_dir(path: &Path) {
284    let dir = path
285        .parent()
286        .filter(|p| !p.as_os_str().is_empty())
287        .unwrap_or(Path::new("."));
288    if let Ok(dir) = File::open(dir) {
289        let _ = dir.sync_all();
290    }
291}