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::decorators::{AutomaticUniquenessAndUsagesResolution, DecoratorsExt};
39use doublets::unit::{LinkPart, Store as UnitStore};
40use doublets::Doublets;
41
42use crate::error::LinkError;
43use crate::link::GenericLink;
44use crate::storage::file_mem::PersistentFileMapped;
45use crate::storage::lock::{lock_file_path, FileLock, LockMode};
46use crate::storage::traits::{LinksStorage, StorageRevision};
47
48/// The file-mapped `doublets` store used by [`DoubletsStorage::open`].
49pub type FileMappedUnitStore<T> = UnitStore<T, PersistentFileMapped<LinkPart<T>>>;
50
51/// The file-mapped store wrapped in the upstream decorator stack that C#
52/// applies by default, produced by
53/// [`DoubletsStorage::with_automatic_uniqueness_and_usages_resolution`].
54///
55/// This is the Rust spelling of the C# type produced by
56/// `ILinksExtensions.DecorateWithAutomaticUniquenessAndUsagesResolution`,
57/// which `Foundation.Data.Doublets.Cli.Library` applies to every
58/// `UnitedMemoryLinks<TLinkAddress>` it opens.
59pub type ResolvedFileMappedUnitStore<T> =
60    AutomaticUniquenessAndUsagesResolution<T, FileMappedUnitStore<T>>;
61
62/// A [`LinksStorage`] over any `doublets` store.
63pub struct DoubletsStorage<T: LinkReference, S: Doublets<T>> {
64    store: S,
65    path: Option<PathBuf>,
66    known_revision: StorageRevision,
67    lock: Option<FileLock>,
68    address: PhantomData<T>,
69}
70
71impl<T: LinkReference> DoubletsStorage<T, FileMappedUnitStore<T>> {
72    /// Opens (or creates) a file-mapped doublets database at `path`
73    /// without taking an advisory lock.
74    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, LinkError> {
75        Self::open_internal(path, None)
76    }
77
78    /// Opens the database and holds a **shared** advisory lock for the
79    /// lifetime of the returned storage, excluding concurrent writers.
80    pub fn open_shared<P: AsRef<Path>>(path: P) -> Result<Self, LinkError> {
81        Self::open_internal(path, Some(LockMode::Shared))
82    }
83
84    /// Opens the database and holds an **exclusive** advisory lock for
85    /// the lifetime of the returned storage, excluding every other
86    /// reader and writer that honours the same protocol.
87    pub fn open_exclusive<P: AsRef<Path>>(path: P) -> Result<Self, LinkError> {
88        Self::open_internal(path, Some(LockMode::Exclusive))
89    }
90
91    /// Like [`Self::open_exclusive`] but returns `Ok(None)` instead of
92    /// blocking when another holder owns a conflicting lock.
93    pub fn try_open_exclusive<P: AsRef<Path>>(path: P) -> Result<Option<Self>, LinkError> {
94        let path = path.as_ref();
95        match FileLock::try_acquire(lock_file_path(path), LockMode::Exclusive)? {
96            Some(lock) => Ok(Some(Self::open_mapped(path, Some(lock))?)),
97            None => Ok(None),
98        }
99    }
100
101    fn open_internal<P: AsRef<Path>>(path: P, mode: Option<LockMode>) -> Result<Self, LinkError> {
102        let path = path.as_ref();
103        let lock = match mode {
104            Some(mode) => Some(FileLock::acquire(lock_file_path(path), mode)?),
105            None => None,
106        };
107        Self::open_mapped(path, lock)
108    }
109
110    fn open_mapped(path: &Path, lock: Option<FileLock>) -> Result<Self, LinkError> {
111        if let Some(parent) = path.parent() {
112            if !parent.as_os_str().is_empty() && !parent.exists() {
113                std::fs::create_dir_all(parent)?;
114            }
115        }
116        let mem = PersistentFileMapped::<LinkPart<T>>::from_path(path)?;
117        let store = FileMappedUnitStore::<T>::new(mem)?;
118        Ok(Self {
119            store,
120            path: Some(path.to_path_buf()),
121            known_revision: StorageRevision::of(path)?,
122            lock,
123            address: PhantomData,
124        })
125    }
126}
127
128impl<T: LinkReference, S: Doublets<T>> DoubletsStorage<T, S> {
129    /// Adopts a doublets store the caller already owns.
130    ///
131    /// Nothing about the store is assumed: no path, no locking and no
132    /// external-change detection. This is the entry point for embedding
133    /// applications that open their own `unit::Store<usize, _>` and only
134    /// want the transactions layer on top of it.
135    pub fn wrap(store: S) -> Self {
136        Self {
137            store,
138            path: None,
139            known_revision: StorageRevision::default(),
140            lock: None,
141            address: PhantomData,
142        }
143    }
144
145    /// Adopts a store the caller already owns while recording the path
146    /// it is backed by, enabling [`LinksStorage::flush`],
147    /// [`LinksStorage::has_external_changes`] and the lock helpers.
148    pub fn wrap_at<P: AsRef<Path>>(store: S, path: P) -> Result<Self, LinkError> {
149        let path = path.as_ref().to_path_buf();
150        let known_revision = StorageRevision::of(&path)?;
151        Ok(Self {
152            store,
153            path: Some(path),
154            known_revision,
155            lock: None,
156            address: PhantomData,
157        })
158    }
159
160    /// Replaces the underlying store with `map(store)`, keeping the path,
161    /// advisory lock and change-detection fingerprint of this storage.
162    ///
163    /// This is the extension point for stacking any `doublets` decorator
164    /// (or a custom one) under the transactions and version control
165    /// layers:
166    ///
167    /// ```no_run
168    /// use doublets::decorators::DecoratorsExt;
169    /// use link_cli::storage::DoubletsStorage;
170    ///
171    /// # fn main() -> Result<(), link_cli::LinkError> {
172    /// let storage = DoubletsStorage::<u32, _>::open("links.data")?
173    ///     .map_store(|store| store.with_inner_reference_existence_validation());
174    /// # Ok(()) }
175    /// ```
176    pub fn map_store<S2, F>(self, map: F) -> DoubletsStorage<T, S2>
177    where
178        S2: Doublets<T>,
179        F: FnOnce(S) -> S2,
180    {
181        DoubletsStorage {
182            store: map(self.store),
183            path: self.path,
184            known_revision: self.known_revision,
185            lock: self.lock,
186            address: PhantomData,
187        }
188    }
189
190    /// Wraps the underlying store in the same decorator stack C# applies
191    /// through `ILinksExtensions.DecorateWithAutomaticUniquenessAndUsagesResolution`.
192    ///
193    /// After this call `(source, target)` pairs are unique: creating or
194    /// updating a link into a pair that already exists resolves to the
195    /// existing link, re-points every usage of the redundant link at the
196    /// survivor and deletes the redundant link. Deleting a link cascades
197    /// to its usages and resets its contents first.
198    ///
199    /// ```no_run
200    /// use link_cli::storage::{DoubletsStorage, LinksStorage};
201    ///
202    /// # fn main() -> Result<(), link_cli::LinkError> {
203    /// let mut storage = DoubletsStorage::<u32, _>::open("links.data")?
204    ///     .with_automatic_uniqueness_and_usages_resolution();
205    /// let first = storage.create_link(1, 1)?;
206    /// let second = storage.create_link(1, 1)?;
207    /// assert_eq!(first, second);
208    /// # Ok(()) }
209    /// ```
210    pub fn with_automatic_uniqueness_and_usages_resolution(
211        self,
212    ) -> DoubletsStorage<T, AutomaticUniquenessAndUsagesResolution<T, S>> {
213        self.map_store(DecoratorsExt::with_automatic_uniqueness_and_usages_resolution)
214    }
215
216    /// The database file backing this storage, when known.
217    pub fn path(&self) -> Option<&Path> {
218        self.path.as_deref()
219    }
220
221    /// Borrows the underlying doublets store.
222    pub fn store(&self) -> &S {
223        &self.store
224    }
225
226    /// Mutably borrows the underlying doublets store.
227    pub fn store_mut(&mut self) -> &mut S {
228        &mut self.store
229    }
230
231    /// Returns the underlying doublets store, dropping any held lock.
232    pub fn into_store(self) -> S {
233        self.store
234    }
235
236    /// Acquires a shared advisory lock on this database's sidecar lock file.
237    pub fn lock_shared(&self) -> Result<FileLock, LinkError> {
238        FileLock::acquire(self.require_lock_path()?, LockMode::Shared)
239    }
240
241    /// Acquires an exclusive advisory lock on this database's sidecar lock file.
242    pub fn lock_exclusive(&self) -> Result<FileLock, LinkError> {
243        FileLock::acquire(self.require_lock_path()?, LockMode::Exclusive)
244    }
245
246    /// The advisory lock held for the lifetime of this storage, if any.
247    pub fn held_lock(&self) -> Option<&FileLock> {
248        self.lock.as_ref()
249    }
250
251    fn require_lock_path(&self) -> Result<PathBuf, LinkError> {
252        self.path.as_ref().map(lock_file_path).ok_or_else(|| {
253            LinkError::Lock("this doublets storage is not backed by a known file".to_string())
254        })
255    }
256
257    fn refresh_revision(&mut self) -> Result<(), LinkError> {
258        if let Some(path) = self.path.as_ref() {
259            self.known_revision = StorageRevision::of(path)?;
260        }
261        Ok(())
262    }
263}
264
265impl<T: LinkReference, S: Doublets<T>> LinksStorage<T> for DoubletsStorage<T, S> {
266    fn create_link(&mut self, source: T, target: T) -> Result<T, LinkError> {
267        Ok(Doublets::create_link(&mut self.store, source, target)?)
268    }
269
270    fn ensure_link_created(&mut self, index: T) -> Result<T, LinkError> {
271        if self.link_exists(index) {
272            return Ok(index);
273        }
274        // `unit::Store` hands out the lowest free address, reusing the
275        // slots of deleted links first, so repeatedly creating empty
276        // links walks up to (and reuses) `index`.
277        loop {
278            let created = Doublets::create(&mut self.store)?;
279            match created.cmp(&index) {
280                std::cmp::Ordering::Equal => return Ok(index),
281                std::cmp::Ordering::Less => continue,
282                std::cmp::Ordering::Greater => {
283                    return Err(LinkError::StorageError(format!(
284                    "could not reserve link address {index}: the store allocated {created} instead"
285                )))
286                }
287            }
288        }
289    }
290
291    fn get_link(&self, index: T) -> Option<GenericLink<T>> {
292        Doublets::get_link(&self.store, index).map(GenericLink::from)
293    }
294
295    fn link_exists(&self, index: T) -> bool {
296        Doublets::get_link(&self.store, index).is_some()
297    }
298
299    fn update_link(&mut self, index: T, source: T, target: T) -> Result<GenericLink<T>, LinkError> {
300        let before = self
301            .get_link(index)
302            .ok_or_else(|| LinkError::not_found(index))?;
303        Doublets::update(&mut self.store, index, source, target)?;
304        Ok(before)
305    }
306
307    fn delete_link(&mut self, index: T) -> Result<GenericLink<T>, LinkError> {
308        let before = self
309            .get_link(index)
310            .ok_or_else(|| LinkError::not_found(index))?;
311        Doublets::delete(&mut self.store, index)?;
312        Ok(before)
313    }
314
315    fn all_links(&self) -> Vec<GenericLink<T>> {
316        let mut links = Vec::new();
317        Doublets::each(&self.store, |link| {
318            links.push(GenericLink::from(link));
319            Flow::Continue
320        });
321        links
322    }
323
324    fn query_links(
325        &self,
326        index: Option<T>,
327        source: Option<T>,
328        target: Option<T>,
329    ) -> Vec<GenericLink<T>> {
330        let any = self.store.constants().any;
331        let query = [
332            index.unwrap_or(any),
333            source.unwrap_or(any),
334            target.unwrap_or(any),
335        ];
336        let mut links = Vec::new();
337        Doublets::each_by(&self.store, query, |link| {
338            links.push(GenericLink::from(link));
339            Flow::Continue
340        });
341        links
342    }
343
344    fn search_link(&self, source: T, target: T) -> Option<T> {
345        Doublets::search(&self.store, source, target)
346    }
347
348    fn get_or_create_link(&mut self, source: T, target: T) -> Result<T, LinkError> {
349        Ok(Doublets::get_or_create(&mut self.store, source, target)?)
350    }
351
352    fn links_count(&self) -> usize {
353        TryInto::<usize>::try_into(Doublets::count(&self.store)).unwrap_or(usize::MAX)
354    }
355
356    /// `fsync`s the backing file so the mapped writes survive a machine
357    /// crash, and publishes them to other processes by advancing the
358    /// file's modification time. A no-op for stores adopted without a
359    /// known path.
360    ///
361    /// Bumping the timestamp is deliberate: the kernel only refreshes
362    /// `mtime` when a *clean* page of a shared mapping is first written
363    /// to, so a long-lived writer that keeps touching already-dirty
364    /// pages would otherwise stay invisible to
365    /// [`LinksStorage::has_external_changes`].
366    fn flush(&mut self) -> Result<(), LinkError> {
367        if let Some(path) = self.path.clone() {
368            let file = std::fs::File::options().write(true).open(&path)?;
369            file.sync_all()?;
370            file.set_modified(std::time::SystemTime::now())?;
371            self.refresh_revision()?;
372        }
373        Ok(())
374    }
375
376    /// Compares the database file's size and mtime against the values
377    /// observed when this storage was opened, reloaded or flushed.
378    ///
379    /// The granularity is a **published** write: writers publish by
380    /// calling [`LinksStorage::flush`], which `fsync`s and advances the
381    /// file's modification time. Writes that a peer has made but not yet
382    /// flushed are already visible through the shared mapping, but are
383    /// not reported here — take [`DoubletsStorage::lock_shared`] when an
384    /// exact answer is required.
385    fn has_external_changes(&self) -> Result<bool, LinkError> {
386        match self.path.as_ref() {
387            Some(path) => Ok(StorageRevision::of(path)? != self.known_revision),
388            None => Ok(false),
389        }
390    }
391
392    /// Memory-mapped stores always read through to the mapping, so this
393    /// only refreshes the change-detection fingerprint.
394    fn reload(&mut self) -> Result<(), LinkError> {
395        self.refresh_revision()
396    }
397}