Skip to main content

link_cli/storage/
doublets_storage.rs

1//! A [`LinksStorage`] backed by a real `doublets` store.
2//!
3//! This is the doublets-backed storage the transactions layer composes
4//! over. Two shapes are supported:
5//!
6//! * [`DoubletsStorage::open`] and friends create a **file-mapped**
7//!   `doublets::unit::Store` — links live in a memory-mapped file and are
8//!   mutated *in place*, so the inode never changes and other processes
9//!   that already mapped the same file keep observing the same data.
10//! * [`DoubletsStorage::wrap`] adopts a store the caller already owns
11//!   (for example a `unit::Store<usize, _>` opened by an embedding
12//!   application), which is what makes the transactions layer reusable
13//!   without giving up ownership of the database.
14//!
15//! # Durability
16//!
17//! Writes go straight into the shared memory mapping, which *is* the
18//! page cache on Linux, so a **process** crash cannot lose them: the
19//! kernel writes the dirty pages back. Surviving a **machine** crash
20//! (power loss, kernel panic) additionally requires an `fsync`, which is
21//! what [`LinksStorage::flush`] performs. `FileMapped` also syncs on
22//! drop, so a clean shutdown is durable without an explicit `flush`.
23//!
24//! # Multi-process access
25//!
26//! A `doublets` store has no internal concurrency control, so concurrent
27//! writers to one file will corrupt it. Use [`DoubletsStorage::open_exclusive`]
28//! (single writer), [`DoubletsStorage::open_shared`] (concurrent readers)
29//! or the [`FileLock`] guards returned by [`DoubletsStorage::lock_shared`] /
30//! [`DoubletsStorage::lock_exclusive`] to serialise access, and
31//! [`LinksStorage::has_external_changes`] to find out cheaply whether
32//! somebody else has written since the last local write.
33
34use std::marker::PhantomData;
35use std::path::{Path, PathBuf};
36
37use doublets::data::{Flow, LinkReference};
38use doublets::unit::{LinkPart, Store as UnitStore};
39use doublets::Doublets;
40
41use crate::error::LinkError;
42use crate::link::GenericLink;
43use crate::storage::file_mem::PersistentFileMapped;
44use crate::storage::lock::{lock_file_path, FileLock, LockMode};
45use crate::storage::traits::{LinksStorage, StorageRevision};
46
47/// The file-mapped `doublets` store used by [`DoubletsStorage::open`].
48pub type FileMappedUnitStore<T> = UnitStore<T, PersistentFileMapped<LinkPart<T>>>;
49
50/// A [`LinksStorage`] over any `doublets` store.
51pub struct DoubletsStorage<T: LinkReference, S: Doublets<T>> {
52    store: S,
53    path: Option<PathBuf>,
54    known_revision: StorageRevision,
55    lock: Option<FileLock>,
56    address: PhantomData<T>,
57}
58
59impl<T: LinkReference> DoubletsStorage<T, FileMappedUnitStore<T>> {
60    /// Opens (or creates) a file-mapped doublets database at `path`
61    /// without taking an advisory lock.
62    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, LinkError> {
63        Self::open_internal(path, None)
64    }
65
66    /// Opens the database and holds a **shared** advisory lock for the
67    /// lifetime of the returned storage, excluding concurrent writers.
68    pub fn open_shared<P: AsRef<Path>>(path: P) -> Result<Self, LinkError> {
69        Self::open_internal(path, Some(LockMode::Shared))
70    }
71
72    /// Opens the database and holds an **exclusive** advisory lock for
73    /// the lifetime of the returned storage, excluding every other
74    /// reader and writer that honours the same protocol.
75    pub fn open_exclusive<P: AsRef<Path>>(path: P) -> Result<Self, LinkError> {
76        Self::open_internal(path, Some(LockMode::Exclusive))
77    }
78
79    /// Like [`Self::open_exclusive`] but returns `Ok(None)` instead of
80    /// blocking when another holder owns a conflicting lock.
81    pub fn try_open_exclusive<P: AsRef<Path>>(path: P) -> Result<Option<Self>, LinkError> {
82        let path = path.as_ref();
83        match FileLock::try_acquire(lock_file_path(path), LockMode::Exclusive)? {
84            Some(lock) => Ok(Some(Self::open_mapped(path, Some(lock))?)),
85            None => Ok(None),
86        }
87    }
88
89    fn open_internal<P: AsRef<Path>>(path: P, mode: Option<LockMode>) -> Result<Self, LinkError> {
90        let path = path.as_ref();
91        let lock = match mode {
92            Some(mode) => Some(FileLock::acquire(lock_file_path(path), mode)?),
93            None => None,
94        };
95        Self::open_mapped(path, lock)
96    }
97
98    fn open_mapped(path: &Path, lock: Option<FileLock>) -> Result<Self, LinkError> {
99        if let Some(parent) = path.parent() {
100            if !parent.as_os_str().is_empty() && !parent.exists() {
101                std::fs::create_dir_all(parent)?;
102            }
103        }
104        let mem = PersistentFileMapped::<LinkPart<T>>::from_path(path)?;
105        let store = FileMappedUnitStore::<T>::new(mem)?;
106        Ok(Self {
107            store,
108            path: Some(path.to_path_buf()),
109            known_revision: StorageRevision::of(path)?,
110            lock,
111            address: PhantomData,
112        })
113    }
114}
115
116impl<T: LinkReference, S: Doublets<T>> DoubletsStorage<T, S> {
117    /// Adopts a doublets store the caller already owns.
118    ///
119    /// Nothing about the store is assumed: no path, no locking and no
120    /// external-change detection. This is the entry point for embedding
121    /// applications that open their own `unit::Store<usize, _>` and only
122    /// want the transactions layer on top of it.
123    pub fn wrap(store: S) -> Self {
124        Self {
125            store,
126            path: None,
127            known_revision: StorageRevision::default(),
128            lock: None,
129            address: PhantomData,
130        }
131    }
132
133    /// Adopts a store the caller already owns while recording the path
134    /// it is backed by, enabling [`LinksStorage::flush`],
135    /// [`LinksStorage::has_external_changes`] and the lock helpers.
136    pub fn wrap_at<P: AsRef<Path>>(store: S, path: P) -> Result<Self, LinkError> {
137        let path = path.as_ref().to_path_buf();
138        let known_revision = StorageRevision::of(&path)?;
139        Ok(Self {
140            store,
141            path: Some(path),
142            known_revision,
143            lock: None,
144            address: PhantomData,
145        })
146    }
147
148    /// The database file backing this storage, when known.
149    pub fn path(&self) -> Option<&Path> {
150        self.path.as_deref()
151    }
152
153    /// Borrows the underlying doublets store.
154    pub fn store(&self) -> &S {
155        &self.store
156    }
157
158    /// Mutably borrows the underlying doublets store.
159    pub fn store_mut(&mut self) -> &mut S {
160        &mut self.store
161    }
162
163    /// Returns the underlying doublets store, dropping any held lock.
164    pub fn into_store(self) -> S {
165        self.store
166    }
167
168    /// Acquires a shared advisory lock on this database's sidecar lock file.
169    pub fn lock_shared(&self) -> Result<FileLock, LinkError> {
170        FileLock::acquire(self.require_lock_path()?, LockMode::Shared)
171    }
172
173    /// Acquires an exclusive advisory lock on this database's sidecar lock file.
174    pub fn lock_exclusive(&self) -> Result<FileLock, LinkError> {
175        FileLock::acquire(self.require_lock_path()?, LockMode::Exclusive)
176    }
177
178    /// The advisory lock held for the lifetime of this storage, if any.
179    pub fn held_lock(&self) -> Option<&FileLock> {
180        self.lock.as_ref()
181    }
182
183    fn require_lock_path(&self) -> Result<PathBuf, LinkError> {
184        self.path.as_ref().map(lock_file_path).ok_or_else(|| {
185            LinkError::Lock("this doublets storage is not backed by a known file".to_string())
186        })
187    }
188
189    fn refresh_revision(&mut self) -> Result<(), LinkError> {
190        if let Some(path) = self.path.as_ref() {
191            self.known_revision = StorageRevision::of(path)?;
192        }
193        Ok(())
194    }
195}
196
197impl<T: LinkReference, S: Doublets<T>> LinksStorage<T> for DoubletsStorage<T, S> {
198    fn create_link(&mut self, source: T, target: T) -> Result<T, LinkError> {
199        Ok(Doublets::create_link(&mut self.store, source, target)?)
200    }
201
202    fn ensure_link_created(&mut self, index: T) -> Result<T, LinkError> {
203        if self.link_exists(index) {
204            return Ok(index);
205        }
206        // `unit::Store` hands out the lowest free address, reusing the
207        // slots of deleted links first, so repeatedly creating empty
208        // links walks up to (and reuses) `index`.
209        loop {
210            let created = Doublets::create(&mut self.store)?;
211            match created.cmp(&index) {
212                std::cmp::Ordering::Equal => return Ok(index),
213                std::cmp::Ordering::Less => continue,
214                std::cmp::Ordering::Greater => {
215                    return Err(LinkError::StorageError(format!(
216                    "could not reserve link address {index}: the store allocated {created} instead"
217                )))
218                }
219            }
220        }
221    }
222
223    fn get_link(&self, index: T) -> Option<GenericLink<T>> {
224        Doublets::get_link(&self.store, index).map(GenericLink::from)
225    }
226
227    fn link_exists(&self, index: T) -> bool {
228        Doublets::get_link(&self.store, index).is_some()
229    }
230
231    fn update_link(&mut self, index: T, source: T, target: T) -> Result<GenericLink<T>, LinkError> {
232        let before = self
233            .get_link(index)
234            .ok_or_else(|| LinkError::not_found(index))?;
235        Doublets::update(&mut self.store, index, source, target)?;
236        Ok(before)
237    }
238
239    fn delete_link(&mut self, index: T) -> Result<GenericLink<T>, LinkError> {
240        let before = self
241            .get_link(index)
242            .ok_or_else(|| LinkError::not_found(index))?;
243        Doublets::delete(&mut self.store, index)?;
244        Ok(before)
245    }
246
247    fn all_links(&self) -> Vec<GenericLink<T>> {
248        let mut links = Vec::new();
249        Doublets::each(&self.store, |link| {
250            links.push(GenericLink::from(link));
251            Flow::Continue
252        });
253        links
254    }
255
256    fn query_links(
257        &self,
258        index: Option<T>,
259        source: Option<T>,
260        target: Option<T>,
261    ) -> Vec<GenericLink<T>> {
262        let any = self.store.constants().any;
263        let query = [
264            index.unwrap_or(any),
265            source.unwrap_or(any),
266            target.unwrap_or(any),
267        ];
268        let mut links = Vec::new();
269        Doublets::each_by(&self.store, query, |link| {
270            links.push(GenericLink::from(link));
271            Flow::Continue
272        });
273        links
274    }
275
276    fn search_link(&self, source: T, target: T) -> Option<T> {
277        Doublets::search(&self.store, source, target)
278    }
279
280    fn get_or_create_link(&mut self, source: T, target: T) -> Result<T, LinkError> {
281        Ok(Doublets::get_or_create(&mut self.store, source, target)?)
282    }
283
284    fn links_count(&self) -> usize {
285        TryInto::<usize>::try_into(Doublets::count(&self.store)).unwrap_or(usize::MAX)
286    }
287
288    /// `fsync`s the backing file so the mapped writes survive a machine
289    /// crash, and publishes them to other processes by advancing the
290    /// file's modification time. A no-op for stores adopted without a
291    /// known path.
292    ///
293    /// Bumping the timestamp is deliberate: the kernel only refreshes
294    /// `mtime` when a *clean* page of a shared mapping is first written
295    /// to, so a long-lived writer that keeps touching already-dirty
296    /// pages would otherwise stay invisible to
297    /// [`LinksStorage::has_external_changes`].
298    fn flush(&mut self) -> Result<(), LinkError> {
299        if let Some(path) = self.path.clone() {
300            let file = std::fs::File::options().write(true).open(&path)?;
301            file.sync_all()?;
302            file.set_modified(std::time::SystemTime::now())?;
303            self.refresh_revision()?;
304        }
305        Ok(())
306    }
307
308    /// Compares the database file's size and mtime against the values
309    /// observed when this storage was opened, reloaded or flushed.
310    ///
311    /// The granularity is a **published** write: writers publish by
312    /// calling [`LinksStorage::flush`], which `fsync`s and advances the
313    /// file's modification time. Writes that a peer has made but not yet
314    /// flushed are already visible through the shared mapping, but are
315    /// not reported here — take [`DoubletsStorage::lock_shared`] when an
316    /// exact answer is required.
317    fn has_external_changes(&self) -> Result<bool, LinkError> {
318        match self.path.as_ref() {
319            Some(path) => Ok(StorageRevision::of(path)? != self.known_revision),
320            None => Ok(false),
321        }
322    }
323
324    /// Memory-mapped stores always read through to the mapping, so this
325    /// only refreshes the change-detection fingerprint.
326    fn reload(&mut self) -> Result<(), LinkError> {
327        self.refresh_revision()
328    }
329}