Skip to main content

doublets/decorators/transactions/
mod.rs

1//! Transactions with reversible writes and optional durable recovery.
2//!
3//! The store's callbacks run *after* mutation. A journal of callbacks alone
4//! therefore cannot recover a crash between mutation and callback. This layer
5//! persists a complete before snapshot at begin and an after snapshot at commit,
6//! as well as every callback transition. Recovery restores the last committed
7//! snapshot, or the before snapshot of an interrupted transaction.
8//!
9//! Snapshots use O(number of links) space per begin/commit and grow the journal;
10//! restore uses O(number of links + highest address) store operations, plus sorting
11//! and backend index costs. There is no isolation from readers
12//! outside the exclusively owned store, no nesting, and no asynchronous commit.
13//! Recovery assumes the underlying store remains structurally readable; repairing
14//! torn tree-index writes requires support from the storage backend itself.
15//!
16//! Put validation/cascade policies around the transaction *handle*, with a raw
17//! store underneath this layer. This records all policy writes and lets recovery
18//! restore arbitrary addresses without validation rejecting intermediate states.
19
20mod log;
21mod types;
22
23pub use log::{FileTransitionLog, MemoryTransitionLog, TransitionLog};
24pub use types::{JournalEntry, Transition, TransitionKind};
25
26use std::{
27    collections::{BTreeMap, BTreeSet},
28    io,
29    ops::{Deref, DerefMut},
30};
31
32use data::{Flow, LinkReference, LinksConstants};
33
34use super::macros::forward;
35use crate::{Doublets, Error, Fuse, Link, Links, ReadHandler, WriteHandler};
36
37struct Pending<T: LinkReference> {
38    id: u64,
39    before: Vec<Link<T>>,
40    start: usize,
41    failed: bool,
42}
43
44/// Adds transactions to any raw [`Doublets`] store.
45///
46/// Writes outside an explicit transaction are committed individually. Use a
47/// handle to group compound helpers such as `create_point` or `create_link`.
48/// A successful file-backed commit is recoverable even if the store has not
49/// flushed, because the journal contains its complete committed state.
50///
51/// ```
52/// use doublets::{decorators::{DecoratorsExt, MemoryTransitionLog}, mem, unit, Doublets};
53/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
54/// let mut store = unit::Store::<usize, _>::new(mem::Global::new())?
55///     .with_transactions(MemoryTransitionLog::default())?;
56/// let mut tx = store.begin_transaction()?;
57/// let point = tx.create_point()?;
58/// tx.commit()?;
59/// assert!(store.exist(point));
60/// # Ok(())
61/// # }
62/// ```
63pub struct TransactionsDecorator<T: LinkReference, L: Doublets<T>, J: TransitionLog<T>> {
64    links: L,
65    journal: J,
66    transitions: Vec<Transition<T>>,
67    pending: Option<Pending<T>>,
68    next_id: u64,
69    next_sequence: u64,
70    poisoned: bool,
71    tracing: bool,
72    rollback_error: Option<String>,
73}
74
75impl<T: LinkReference, L: Doublets<T>, J: TransitionLog<T>> TransactionsDecorator<T, L, J> {
76    /// Wraps the store and recovers its journal before returning.
77    ///
78    /// An empty journal adopts the current graph. A nonempty journal belongs to
79    /// its original store and overrides the graph with its last recovery state.
80    pub fn new(links: L, journal: J) -> Result<Self, Error<T>> {
81        let mut store = Self {
82            links,
83            journal,
84            transitions: Vec::new(),
85            pending: None,
86            next_id: 1,
87            next_sequence: 1,
88            poisoned: false,
89            tracing: false,
90            rollback_error: None,
91        };
92        store.recover()?;
93        Ok(store)
94    }
95
96    /// Starts a transaction, persisting its before snapshot first.
97    ///
98    /// Nested transactions are rejected. The handle exclusively borrows this
99    /// decorator; dropping it without completion rolls back, including on unwind.
100    pub fn begin_transaction(&mut self) -> Result<Transaction<'_, T, L, J>, Error<T>> {
101        self.begin()?;
102        Ok(Transaction { store: self })
103    }
104
105    /// Returns the wrapped store for read-only inspection.
106    #[must_use]
107    pub const fn inner(&self) -> &L {
108        &self.links
109    }
110
111    /// Returns the journal for read-only inspection.
112    #[must_use]
113    pub const fn journal(&self) -> &J {
114        &self.journal
115    }
116
117    /// All recorded transitions, including writes subsequently rolled back.
118    #[must_use]
119    pub fn transitions(&self) -> &[Transition<T>] {
120        &self.transitions
121    }
122
123    /// Whether an I/O or restoration failure has blocked further writes.
124    #[must_use]
125    pub const fn is_poisoned(&self) -> bool {
126        self.poisoned
127    }
128
129    /// Enables diagnostic transaction and transition messages on stderr.
130    /// Disabled by default.
131    pub fn set_tracing(&mut self, enabled: bool) {
132        self.tracing = enabled;
133    }
134
135    /// The last rollback failure, including one encountered by a handle's drop.
136    ///
137    /// Drop cannot return an error. A failed rollback poisons the decorator and
138    /// leaves the begin record available for recovery when the store is reopened.
139    #[must_use]
140    pub fn rollback_error(&self) -> Option<&str> {
141        self.rollback_error.as_deref()
142    }
143
144    /// Unwraps the decorator after rolling back any forgotten transaction.
145    pub fn into_parts(mut self) -> Result<(L, J), Error<T>> {
146        self.rollback()?;
147        Ok((self.links, self.journal))
148    }
149
150    fn begin(&mut self) -> Result<(), Error<T>> {
151        if self.poisoned {
152            return Err(failure(
153                "transaction journal is poisoned; reopen for recovery",
154            ));
155        }
156        if self.pending.is_some() {
157            return Err(failure("nested transactions are not supported"));
158        }
159        let id = self.next_id;
160        self.next_id = id
161            .checked_add(1)
162            .ok_or_else(|| failure("transaction id exhausted"))?;
163        let before = snapshot(&self.links);
164        self.append(&JournalEntry::Begin {
165            transaction_id: id,
166            snapshot: before.clone(),
167        })?;
168        self.pending = Some(Pending {
169            id,
170            before,
171            start: self.transitions.len(),
172            failed: false,
173        });
174        if self.tracing {
175            eprintln!("[doublets transactions] begin {id}");
176        }
177        Ok(())
178    }
179
180    fn append(&mut self, entry: &JournalEntry<T>) -> Result<(), Error<T>> {
181        if let Err(error) = self.journal.append(entry) {
182            self.poisoned = true;
183            return Err(error.into());
184        }
185        Ok(())
186    }
187
188    fn commit(&mut self) -> Result<(), Error<T>> {
189        let Some(pending) = &self.pending else {
190            return Ok(());
191        };
192        if pending.failed || self.poisoned {
193            return Err(failure("failed transaction cannot be committed"));
194        }
195        let id = pending.id;
196        let entry = JournalEntry::Commit {
197            transaction_id: id,
198            snapshot: snapshot(&self.links),
199        };
200        self.append(&entry)?;
201        if self.tracing {
202            eprintln!("[doublets transactions] commit {id}");
203        }
204        self.pending = None;
205        Ok(())
206    }
207
208    fn rollback(&mut self) -> Result<(), Error<T>> {
209        let Some(pending) = &self.pending else {
210            return Ok(());
211        };
212        let id = pending.id;
213        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
214            restore(&mut self.links, &pending.before)
215        }))
216        .unwrap_or_else(|_| {
217            Err(failure(
218                "wrapped store panicked while restoring transaction",
219            ))
220        });
221        if let Err(error) = result {
222            self.poisoned = true;
223            self.rollback_error = Some(error.to_string());
224            return Err(error);
225        }
226        self.pending = None;
227        if self.poisoned {
228            return Ok(());
229        }
230        if self.tracing {
231            eprintln!("[doublets transactions] rollback {id}");
232        }
233        if let Err(error) = self.append(&JournalEntry::Rollback { transaction_id: id }) {
234            self.rollback_error = Some(error.to_string());
235            return Err(error);
236        }
237        Ok(())
238    }
239
240    fn recover(&mut self) -> Result<(), Error<T>> {
241        let entries = self.journal.read_entries()?;
242        let mut pending = None;
243        let mut desired = None;
244        for entry in entries {
245            match entry {
246                JournalEntry::Begin {
247                    transaction_id,
248                    snapshot,
249                } => {
250                    if pending.is_some() || transaction_id < self.next_id {
251                        return Err(failure("invalid transaction begin order"));
252                    }
253                    validate(&self.links, &snapshot)?;
254                    self.next_id = transaction_id
255                        .checked_add(1)
256                        .ok_or_else(|| failure("transaction id exhausted"))?;
257                    pending = Some(transaction_id);
258                    desired = Some(snapshot);
259                }
260                JournalEntry::Transition(transition) => {
261                    transition.validate(&self.links)?;
262                    if pending != Some(transition.transaction_id)
263                        || transition.sequence < self.next_sequence
264                    {
265                        return Err(failure("invalid transition order"));
266                    }
267                    self.next_sequence = transition
268                        .sequence
269                        .checked_add(1)
270                        .ok_or_else(|| failure("transition sequence exhausted"))?;
271                    self.transitions.push(transition);
272                }
273                JournalEntry::Commit {
274                    transaction_id,
275                    snapshot,
276                } => {
277                    if pending != Some(transaction_id) {
278                        return Err(failure("commit without matching begin"));
279                    }
280                    validate(&self.links, &snapshot)?;
281                    desired = Some(snapshot);
282                    pending = None;
283                }
284                JournalEntry::Rollback { transaction_id } => {
285                    if pending != Some(transaction_id) {
286                        return Err(failure("rollback without matching begin"));
287                    }
288                    pending = None;
289                }
290            }
291        }
292        if let Some(desired) = desired {
293            restore(&mut self.links, &desired)?;
294        }
295        if let Some(transaction_id) = pending {
296            self.append(&JournalEntry::Rollback { transaction_id })?;
297        }
298        Ok(())
299    }
300
301    fn write<F>(&mut self, handler: WriteHandler<'_, T>, operation: F) -> Result<Flow, Error<T>>
302    where
303        F: FnOnce(&mut L, WriteHandler<'_, T>) -> Result<Flow, Error<T>>,
304    {
305        if self.poisoned {
306            return Err(failure(
307                "transaction journal is poisoned; reopen for recovery",
308            ));
309        }
310        let automatic = self.pending.is_none();
311        if automatic {
312            self.begin()?;
313        }
314        let pending = self
315            .pending
316            .as_mut()
317            .ok_or_else(|| failure("no active transaction"))?;
318        if pending.failed {
319            return Err(failure("transaction has a failed write; roll it back"));
320        }
321        let id = pending.id;
322        let mut handler = Fuse::new(handler);
323        let mut log_error = None;
324        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
325            operation(&mut self.links, &mut |before, after| {
326                if log_error.is_some() {
327                    return Flow::Break;
328                }
329                if before != after {
330                    let Some(next) = self.next_sequence.checked_add(1) else {
331                        log_error = Some(io::Error::other("transition sequence exhausted"));
332                        return Flow::Break;
333                    };
334                    let transition =
335                        Transition::new(id, self.next_sequence, before.clone(), after.clone());
336                    if self.tracing {
337                        eprintln!("[doublets transactions] {transition:?}");
338                    }
339                    // Keep the record even if append fails: the mutation already happened.
340                    self.transitions.push(transition.clone());
341                    self.next_sequence = next;
342                    if let Err(error) = self.journal.append(&JournalEntry::Transition(transition)) {
343                        log_error = Some(error);
344                        return Flow::Break;
345                    }
346                }
347                handler.call(before, after)
348            })
349        }));
350        let result = match result {
351            Ok(result) => result,
352            Err(panic) => {
353                pending.failed = true;
354                if automatic {
355                    let _ = self.rollback();
356                }
357                std::panic::resume_unwind(panic);
358            }
359        };
360        let result = if let Some(error) = log_error {
361            self.poisoned = true;
362            Err(error.into())
363        } else {
364            result
365        };
366        if result.is_err() {
367            pending.failed = true;
368        }
369        if automatic {
370            match result {
371                Ok(flow) => {
372                    if let Err(error) = self.commit() {
373                        self.rollback()?;
374                        return Err(error);
375                    }
376                    Ok(flow)
377                }
378                Err(error) => {
379                    self.rollback()?;
380                    Err(error)
381                }
382            }
383        } else {
384            result
385        }
386    }
387}
388
389impl<T: LinkReference, L: Doublets<T>, J: TransitionLog<T>> Links<T>
390    for TransactionsDecorator<T, L, J>
391{
392    forward!(count_links, each_links);
393
394    fn create_links(
395        &mut self,
396        query: &[T],
397        handler: WriteHandler<'_, T>,
398    ) -> Result<Flow, Error<T>> {
399        self.write(handler, |links, handler| links.create_links(query, handler))
400    }
401    fn update_links(
402        &mut self,
403        query: &[T],
404        change: &[T],
405        handler: WriteHandler<'_, T>,
406    ) -> Result<Flow, Error<T>> {
407        self.write(handler, |links, handler| {
408            links.update_links(query, change, handler)
409        })
410    }
411    fn delete_links(
412        &mut self,
413        query: &[T],
414        handler: WriteHandler<'_, T>,
415    ) -> Result<Flow, Error<T>> {
416        self.write(handler, |links, handler| links.delete_links(query, handler))
417    }
418}
419
420impl<T: LinkReference, L: Doublets<T>, J: TransitionLog<T>> Doublets<T>
421    for TransactionsDecorator<T, L, J>
422{
423    fn get_link(&self, index: T) -> Option<Link<T>> {
424        self.links.get_link(index)
425    }
426}
427
428/// An exclusively borrowed transaction; unfinished handles roll back on drop.
429///
430/// `commit` and `rollback` consume the handle. If commit returns an I/O error,
431/// its outcome is indeterminate until journal recovery: an append may have reached
432/// disk despite reporting failure. Further writes are blocked on that decorator.
433/// Call explicit rollback to receive restoration errors; drop records them in
434/// [`TransactionsDecorator::rollback_error`] and never panics intentionally.
435#[must_use = "commit or rollback the transaction; dropping it rolls back"]
436pub struct Transaction<'a, T: LinkReference, L: Doublets<T>, J: TransitionLog<T>> {
437    store: &'a mut TransactionsDecorator<T, L, J>,
438}
439
440impl<T: LinkReference, L: Doublets<T>, J: TransitionLog<T>> Transaction<'_, T, L, J> {
441    /// Commits the transaction after persisting the complete resulting graph.
442    pub fn commit(self) -> Result<(), Error<T>> {
443        self.store.commit()
444    }
445    /// Restores the original graph and records the rollback.
446    pub fn rollback(self) -> Result<(), Error<T>> {
447        self.store.rollback()
448    }
449    /// Transitions recorded by this transaction so far.
450    #[must_use]
451    pub fn transitions(&self) -> &[Transition<T>] {
452        let start = self
453            .store
454            .pending
455            .as_ref()
456            .map_or(self.store.transitions.len(), |p| p.start);
457        &self.store.transitions[start..]
458    }
459}
460
461impl<T: LinkReference, L: Doublets<T>, J: TransitionLog<T>> Drop for Transaction<'_, T, L, J> {
462    fn drop(&mut self) {
463        let _ = self.store.rollback();
464    }
465}
466
467impl<T: LinkReference, L: Doublets<T>, J: TransitionLog<T>> Deref for Transaction<'_, T, L, J> {
468    type Target = TransactionsDecorator<T, L, J>;
469    fn deref(&self) -> &Self::Target {
470        self.store
471    }
472}
473impl<T: LinkReference, L: Doublets<T>, J: TransitionLog<T>> DerefMut for Transaction<'_, T, L, J> {
474    fn deref_mut(&mut self) -> &mut Self::Target {
475        self.store
476    }
477}
478
479// Implement Links on the handle itself so it composes with DecoratorsExt.
480impl<T: LinkReference, L: Doublets<T>, J: TransitionLog<T>> Links<T> for Transaction<'_, T, L, J> {
481    fn constants(&self) -> &LinksConstants<T> {
482        self.store.constants()
483    }
484    fn count_links(&self, query: &[T]) -> T {
485        self.store.count_links(query)
486    }
487    fn each_links(&self, query: &[T], handler: ReadHandler<'_, T>) -> Flow {
488        self.store.each_links(query, handler)
489    }
490    fn create_links(
491        &mut self,
492        query: &[T],
493        handler: WriteHandler<'_, T>,
494    ) -> Result<Flow, Error<T>> {
495        self.store.create_links(query, handler)
496    }
497    fn update_links(
498        &mut self,
499        query: &[T],
500        change: &[T],
501        handler: WriteHandler<'_, T>,
502    ) -> Result<Flow, Error<T>> {
503        self.store.update_links(query, change, handler)
504    }
505    fn delete_links(
506        &mut self,
507        query: &[T],
508        handler: WriteHandler<'_, T>,
509    ) -> Result<Flow, Error<T>> {
510        self.store.delete_links(query, handler)
511    }
512}
513impl<T: LinkReference, L: Doublets<T>, J: TransitionLog<T>> Doublets<T>
514    for Transaction<'_, T, L, J>
515{
516    fn get_link(&self, index: T) -> Option<Link<T>> {
517        self.store.get_link(index)
518    }
519}
520
521fn failure<T: LinkReference>(message: &str) -> Error<T> {
522    io::Error::other(message).into()
523}
524
525fn snapshot<T: LinkReference, L: Doublets<T>>(links: &L) -> Vec<Link<T>> {
526    let mut snapshot = Vec::new();
527    links.each_links(&[], &mut |link| {
528        snapshot.push(link);
529        Flow::Continue
530    });
531    snapshot.sort_by_key(|link| link.index);
532    snapshot
533}
534
535fn validate<T: LinkReference, L: Doublets<T>>(
536    links: &L,
537    desired: &[Link<T>],
538) -> Result<(), Error<T>> {
539    let mut indexes = BTreeSet::new();
540    for link in desired {
541        if !links.constants().is_internal(link.index) || !indexes.insert(link.index) {
542            return Err(failure(
543                "snapshot contains an invalid or duplicate link address",
544            ));
545        }
546    }
547    Ok(())
548}
549
550fn restore<T: LinkReference, L: Doublets<T>>(
551    links: &mut L,
552    desired: &[Link<T>],
553) -> Result<(), Error<T>> {
554    validate(links, desired)?;
555    let current = snapshot(links);
556    let desired: BTreeMap<_, _> = desired.iter().map(|link| (link.index, link)).collect();
557    if current.iter().eq(desired.values().copied()) {
558        return Ok(());
559    }
560    // Clear indexed contents first: applying snapshots one link at a time can
561    // transiently duplicate pairs (e.g. swapping two doublets) and damage trees.
562    let null = T::from_byte(0);
563    for link in &current {
564        if link.source != null || link.target != null {
565            links.update(link.index, null, null)?;
566        }
567    }
568    let mut allocated: BTreeSet<_> = current.iter().map(|link| link.index).collect();
569    let mut missing: BTreeSet<_> = desired
570        .keys()
571        .copied()
572        .filter(|index| !allocated.contains(index))
573        .collect();
574    let limit = current
575        .iter()
576        .map(|link| link.index)
577        .chain(desired.keys().copied())
578        .max()
579        .unwrap_or(null);
580    while !missing.is_empty() {
581        let index = links.create()?;
582        if !allocated.insert(index) {
583            return Err(failure("store did not allocate a fresh link address"));
584        }
585        if index > limit {
586            return Err(failure("store cannot restore requested link addresses"));
587        }
588        missing.remove(&index);
589    }
590    for index in allocated.into_iter().rev() {
591        if !desired.contains_key(&index) {
592            links.delete(index)?;
593        }
594    }
595    for link in desired.values() {
596        if link.source != null || link.target != null {
597            links.update(link.index, link.source, link.target)?;
598        }
599    }
600    Ok(())
601}