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}