Skip to main content

link_cli/transactions/
mod.rs

1//! Optional transactions layer for the Rust link-cli.
2//!
3//! Mirrors the C# `TransactionsDecorator` in
4//! `csharp/Foundation.Data.Doublets.Cli.Library/TransactionsDecorator.cs`.
5//!
6//! The decorator records every `create` / `update` / `delete` as a
7//! reversible [`GenericTransition`] in a sidecar log. It supports
8//! explicit transactions, sync commits, three retention policies, and
9//! crash recovery (R1-R7, R10).
10//!
11//! Optional — when not opted in, the bare
12//! [`NamedTypesDecorator`](crate::NamedTypesDecorator) behaves
13//! identically (R8, R9, R17).
14//!
15//! # Reuse outside the CLI
16//!
17//! [`GenericTransactionsDecorator`] is generic over three things:
18//!
19//! * `T` — the doublets address type (`u32`, `u64`, `usize`, ...);
20//! * `S` — the wrapped store, any [`LinksStorage<T>`] implementation,
21//!   including [`DoubletsStorage`](crate::DoubletsStorage) over a
22//!   file-mapped or caller-owned `doublets::unit::Store`;
23//! * `L` — the transitions log, any [`TransitionLogStore`].
24//!
25//! [`TransactionsDecorator`] is the `u32` + `NamedTypesDecorator`
26//! specialisation used by `clink` itself.
27//!
28//! ```no_run
29//! use link_cli::transactions::{
30//!     CommitMode, FileTransitionLog, GenericTransactionsDecorator, LogRetentionPolicy,
31//! };
32//! use link_cli::DoubletsStorage;
33//!
34//! # fn main() -> Result<(), link_cli::LinkError> {
35//! let store = DoubletsStorage::<usize, _>::open_exclusive("db.links")?;
36//! let log = FileTransitionLog::open("db.transitions.log")?;
37//! let mut tx = GenericTransactionsDecorator::new(
38//!     store,
39//!     log,
40//!     LogRetentionPolicy::default(),
41//!     CommitMode::default(),
42//!     false,
43//! )?;
44//!
45//! tx.begin_transaction()?;
46//! let link = tx.create(0, 0)?;
47//! tx.commit()?;
48//! tx.save()?;
49//! # let _ = link;
50//! # Ok(())
51//! # }
52//! ```
53//!
54//! # Durability
55//!
56//! Transitions are appended to the log *before* the write they describe
57//! is reported as committed, and [`FileTransitionLog`] `fsync`s each
58//! append by default, so a crash can lose at most the transition that
59//! was in flight. The data store itself is made durable by
60//! [`GenericTransactionsDecorator::save`], which calls
61//! [`LinksStorage::flush`] — for a memory-mapped store that is the
62//! `fsync` of the mapping; for the CLI's in-memory store it is the
63//! rewrite of the database file. Recovery, run by
64//! [`GenericTransactionsDecorator::new`], replays committed-but-unapplied
65//! transitions and rolls back transitions that were never committed, so
66//! a store that lost unflushed writes is brought back in line with the
67//! log.
68
69mod log;
70mod types;
71
72use std::collections::HashSet;
73use std::path::{Path, PathBuf};
74use std::sync::atomic::{AtomicU64, Ordering};
75use std::time::{SystemTime, UNIX_EPOCH};
76
77use doublets::data::LinkReference;
78
79use crate::error::LinkError;
80use crate::link::GenericLink;
81use crate::named_types::NamedTypesDecorator;
82use crate::storage::{LinksStorage, LinksStorageRef};
83
84pub use log::{FileTransitionLog, TransitionLogStore};
85pub use types::{
86    CommitMode, DoubletLink, GenericDoubletLink, GenericTransition, LogRetentionPolicy, Transition,
87    TransitionKind,
88};
89use types::{
90    APPLIED_MARKER_PREFIX, COMMIT_MARKER_PREFIX, ROLLBACK_MARKER_PREFIX, TRANSITION_NAME_PREFIX,
91};
92
93/// Pending state of a transaction (used by the explicit transaction
94/// handle and by per-write auto-transactions).
95struct PendingTransaction<T> {
96    id: u128,
97    transitions: Vec<GenericTransition<T>>,
98    auto_commit: bool,
99    started_ms: i64,
100}
101
102/// Snapshot of an open transaction (returned by [`GenericTransactionsDecorator::begin_transaction`]).
103#[derive(Debug, Clone)]
104pub struct TransactionHandle {
105    pub id: u128,
106    pub started_ms: i64,
107}
108
109/// The transactions decorator wraps any [`LinksStorage`] and records
110/// every write as a reversible [`GenericTransition`] in `log_store`.
111pub struct GenericTransactionsDecorator<T, S, L>
112where
113    T: LinkReference,
114    S: LinksStorage<T>,
115    L: TransitionLogStore,
116{
117    inner: S,
118    log_store: L,
119    log: Vec<GenericTransition<T>>,
120    committed: HashSet<u128>,
121    rolled_back: HashSet<u128>,
122    applied: HashSet<i64>,
123    current: Option<PendingTransaction<T>>,
124    sequence_counter: i64,
125    applied_sequence: i64,
126    retention_policy: LogRetentionPolicy,
127    commit_mode: CommitMode,
128    replaying: bool,
129    trace: bool,
130}
131
132/// The `u32` + [`NamedTypesDecorator`] specialisation used by `clink`.
133pub type TransactionsDecorator =
134    GenericTransactionsDecorator<u32, NamedTypesDecorator, NamedTypesDecorator>;
135
136impl<T, S, L> GenericTransactionsDecorator<T, S, L>
137where
138    T: LinkReference,
139    S: LinksStorage<T>,
140    L: TransitionLogStore,
141{
142    /// Creates a new transactions decorator wrapping `inner`, using
143    /// `log_store` as the sidecar log. Runs crash recovery before
144    /// returning.
145    pub fn new(
146        inner: S,
147        log_store: L,
148        retention_policy: LogRetentionPolicy,
149        commit_mode: CommitMode,
150        trace: bool,
151    ) -> Result<Self, LinkError> {
152        let mut decorator = Self {
153            inner,
154            log_store,
155            log: Vec::new(),
156            committed: HashSet::new(),
157            rolled_back: HashSet::new(),
158            applied: HashSet::new(),
159            current: None,
160            sequence_counter: 0,
161            applied_sequence: 0,
162            retention_policy,
163            commit_mode,
164            replaying: false,
165            trace,
166        };
167        decorator.recover()?;
168        Ok(decorator)
169    }
170
171    /// Conventional sidecar filename for the transitions log.
172    pub fn make_transitions_database_filename<P: AsRef<Path>>(database_filename: P) -> PathBuf {
173        let path = database_filename.as_ref();
174        let stem = path
175            .file_stem()
176            .and_then(|s| s.to_str())
177            .unwrap_or_default();
178        let name = format!("{stem}.transitions.links");
179        match path.parent() {
180            Some(parent) if !parent.as_os_str().is_empty() => parent.join(name),
181            _ => PathBuf::from(name),
182        }
183    }
184
185    pub fn retention_policy(&self) -> &LogRetentionPolicy {
186        &self.retention_policy
187    }
188
189    pub fn set_retention_policy(&mut self, policy: LogRetentionPolicy) {
190        self.retention_policy = policy;
191    }
192
193    pub fn commit_mode(&self) -> CommitMode {
194        self.commit_mode
195    }
196
197    pub fn set_commit_mode(&mut self, mode: CommitMode) {
198        self.commit_mode = mode;
199    }
200
201    pub fn applied_sequence(&self) -> i64 {
202        self.applied_sequence
203    }
204
205    pub fn last_logged_sequence(&self) -> i64 {
206        self.sequence_counter
207    }
208
209    /// Returns a snapshot of the transitions log in sequence order.
210    pub fn log(&self) -> Vec<GenericTransition<T>> {
211        self.log.clone()
212    }
213
214    pub fn inner(&self) -> &S {
215        &self.inner
216    }
217
218    pub fn inner_mut(&mut self) -> &mut S {
219        &mut self.inner
220    }
221
222    pub fn log_store(&self) -> &L {
223        &self.log_store
224    }
225
226    pub fn log_store_mut(&mut self) -> &mut L {
227        &mut self.log_store
228    }
229
230    pub fn into_inner(self) -> (S, L) {
231        (self.inner, self.log_store)
232    }
233
234    /// Makes both the data store and the transitions log durable.
235    pub fn flush(&mut self) -> Result<(), LinkError> {
236        self.inner.flush()?;
237        self.log_store.flush_log()?;
238        Ok(())
239    }
240
241    /// Alias of [`flush`](Self::flush), kept for parity with the other
242    /// decorators and with the C# API.
243    pub fn save(&mut self) -> Result<(), LinkError> {
244        self.flush()
245    }
246
247    /// Cheap check for "has another process written to the wrapped
248    /// store since we last read or wrote it?".
249    pub fn has_external_changes(&self) -> Result<bool, LinkError> {
250        self.inner.has_external_changes()
251    }
252
253    /// Re-reads the wrapped store and rebuilds the transactions state
254    /// from the log — the recovery path after another process wrote.
255    pub fn reload(&mut self) -> Result<(), LinkError> {
256        if self.current.is_some() {
257            return Err(LinkError::Transaction(
258                "Cannot reload while a transaction is open.".to_string(),
259            ));
260        }
261        self.inner.reload()?;
262        self.recover()
263    }
264
265    // ----- Write API ------------------------------------------------------
266
267    pub fn create(&mut self, source: T, target: T) -> Result<T, LinkError> {
268        if self.replaying {
269            return self.inner.create_link(source, target);
270        }
271        let owns = self.ensure_open_transaction();
272        let id = self.inner.create_link(source, target)?;
273        let after = self
274            .inner
275            .get_link(id)
276            .map(|link| GenericDoubletLink::from_link(&link))
277            .unwrap_or_else(|| GenericDoubletLink::new(id, source, target));
278        self.record_transition(TransitionKind::Create, GenericDoubletLink::empty(), after)?;
279        if owns {
280            self.commit_current()?;
281        }
282        Ok(id)
283    }
284
285    pub fn update(&mut self, id: T, source: T, target: T) -> Result<GenericLink<T>, LinkError> {
286        if self.replaying {
287            return self.inner.update_link(id, source, target);
288        }
289        let before = self.snapshot(id);
290        let owns = self.ensure_open_transaction();
291        let prev = match self.inner.update_link(id, source, target) {
292            Ok(prev) => prev,
293            Err(err) => {
294                if owns {
295                    self.current = None;
296                }
297                return Err(err);
298            }
299        };
300        let after = self
301            .inner
302            .get_link(id)
303            .map(|link| GenericDoubletLink::from_link(&link))
304            .unwrap_or_else(|| GenericDoubletLink::new(id, source, target));
305        self.record_transition(TransitionKind::Update, before, after)?;
306        if owns {
307            self.commit_current()?;
308        }
309        Ok(prev)
310    }
311
312    pub fn delete(&mut self, id: T) -> Result<GenericLink<T>, LinkError> {
313        if self.replaying {
314            return self.inner.delete_link(id);
315        }
316        let before = self.snapshot(id);
317        let owns = self.ensure_open_transaction();
318        let deleted = match self.inner.delete_link(id) {
319            Ok(d) => d,
320            Err(err) => {
321                if owns {
322                    self.current = None;
323                }
324                return Err(err);
325            }
326        };
327        self.record_transition(TransitionKind::Delete, before, GenericDoubletLink::empty())?;
328        if owns {
329            self.commit_current()?;
330        }
331        Ok(deleted)
332    }
333
334    /// Composite create-and-update used by callers that want a link
335    /// initialised with source/target in a single pair of transitions
336    /// (matches the C# `CreateAndUpdate` extension semantics, which
337    /// always emits a Create followed by an Update transition).
338    pub fn create_and_update(&mut self, source: T, target: T) -> Result<T, LinkError> {
339        let owns = self.ensure_open_transaction();
340        let zero = T::from_byte(0);
341        let id = self.create(zero, zero)?;
342        self.update(id, source, target)?;
343        if owns {
344            self.commit_current()?;
345        }
346        Ok(id)
347    }
348
349    pub fn exists(&self, id: T) -> bool {
350        self.inner.link_exists(id)
351    }
352
353    pub fn search(&self, source: T, target: T) -> Option<T> {
354        self.inner.search_link(source, target)
355    }
356
357    pub fn get_or_create(&mut self, source: T, target: T) -> Result<T, LinkError> {
358        if let Some(existing) = self.inner.search_link(source, target) {
359            return Ok(existing);
360        }
361        self.create(source, target)
362    }
363
364    pub fn ensure_created(&mut self, id: T) -> Result<T, LinkError> {
365        // ensure_created is used by recovery/replay only and is not
366        // itself a logical write; bypass transition recording.
367        self.inner.ensure_link_created(id)
368    }
369
370    /// Current state of `id` as a doublet, or an empty one at `id`.
371    fn snapshot(&self, id: T) -> GenericDoubletLink<T> {
372        let zero = T::from_byte(0);
373        self.inner
374            .get_link(id)
375            .map(|link| GenericDoubletLink::from_link(&link))
376            .unwrap_or_else(|| GenericDoubletLink::new(id, zero, zero))
377    }
378
379    fn ensure_open_transaction(&mut self) -> bool {
380        if self.current.is_none() {
381            self.current = Some(PendingTransaction {
382                id: new_transaction_id(),
383                transitions: Vec::new(),
384                auto_commit: true,
385                started_ms: now_unix_ms(),
386            });
387            true
388        } else {
389            false
390        }
391    }
392
393    fn record_transition(
394        &mut self,
395        kind: TransitionKind,
396        before: GenericDoubletLink<T>,
397        after: GenericDoubletLink<T>,
398    ) -> Result<(), LinkError> {
399        self.sequence_counter += 1;
400        let sequence = self.sequence_counter;
401        let timestamp_ms = now_unix_ms();
402        let transaction_id = self.current.as_ref().map(|tx| tx.id).ok_or_else(|| {
403            LinkError::Transaction(
404                "internal: missing open transaction while recording transition".to_string(),
405            )
406        })?;
407        let transition = GenericTransition {
408            transaction_id,
409            sequence,
410            timestamp_ms,
411            kind,
412            before,
413            after,
414        };
415        if let Some(current) = self.current.as_mut() {
416            current.transitions.push(transition);
417        }
418        self.log.push(transition);
419        self.write_transition_to_log(&transition)?;
420        if self.trace {
421            eprintln!(
422                "[Transactions] Recorded {:?} seq={} tx={:032x}: ({},{},{}) -> ({},{},{}).",
423                kind,
424                sequence,
425                transaction_id,
426                before.index,
427                before.source,
428                before.target,
429                after.index,
430                after.source,
431                after.target,
432            );
433        }
434        Ok(())
435    }
436
437    fn write_transition_to_log(
438        &mut self,
439        transition: &GenericTransition<T>,
440    ) -> Result<(), LinkError> {
441        self.log_store.append_log_entry(&format!(
442            "{TRANSITION_NAME_PREFIX}{}",
443            transition.serialize()
444        ))
445    }
446
447    fn write_marker(&mut self, name: &str) -> Result<(), LinkError> {
448        self.log_store.append_log_entry(name)
449    }
450
451    // ----- Transaction handle --------------------------------------------
452
453    pub fn begin_transaction(&mut self) -> Result<TransactionHandle, LinkError> {
454        if self.current.is_some() {
455            return Err(LinkError::Transaction(
456                "Nested transactions are not supported.".to_string(),
457            ));
458        }
459        let id = new_transaction_id();
460        let started_ms = now_unix_ms();
461        self.current = Some(PendingTransaction {
462            id,
463            transitions: Vec::new(),
464            auto_commit: false,
465            started_ms,
466        });
467        Ok(TransactionHandle { id, started_ms })
468    }
469
470    pub fn commit(&mut self) -> Result<(), LinkError> {
471        if self.current.is_none() {
472            return Ok(());
473        }
474        self.commit_current()
475    }
476
477    fn commit_current(&mut self) -> Result<(), LinkError> {
478        let pending = match self.current.take() {
479            Some(p) => p,
480            None => return Ok(()),
481        };
482        self.committed.insert(pending.id);
483        self.write_marker(&format!("{COMMIT_MARKER_PREFIX}{:032x}", pending.id))?;
484        if self.trace {
485            eprintln!(
486                "[Transactions] Committed tx {:032x} (mode={:?}, transitions={}).",
487                pending.id,
488                self.commit_mode,
489                pending.transitions.len()
490            );
491        }
492        for transition in &pending.transitions {
493            self.mark_applied(transition)?;
494        }
495        let _ = pending.auto_commit;
496        let _ = pending.started_ms;
497        self.enforce_retention()?;
498        Ok(())
499    }
500
501    pub fn rollback(&mut self) -> Result<(), LinkError> {
502        let pending = match self.current.take() {
503            Some(p) => p,
504            None => return Ok(()),
505        };
506        self.rolled_back.insert(pending.id);
507        self.replaying = true;
508        for transition in pending.transitions.iter().rev() {
509            self.try_revert_transition(transition);
510        }
511        self.replaying = false;
512        self.write_marker(&format!("{ROLLBACK_MARKER_PREFIX}{:032x}", pending.id))?;
513        if self.trace {
514            eprintln!(
515                "[Transactions] Rolled back tx {:032x} ({} transitions).",
516                pending.id,
517                pending.transitions.len(),
518            );
519        }
520        self.enforce_retention()?;
521        Ok(())
522    }
523
524    /// Public helper for higher-level decorators (e.g. version control)
525    /// — applies a single transition without writing a new log entry.
526    pub fn apply_transition(&mut self, transition: &GenericTransition<T>) {
527        self.replaying = true;
528        self.try_apply_transition(transition, false);
529        self.replaying = false;
530    }
531
532    /// Public helper for higher-level decorators (e.g. version control)
533    /// — reverts a single transition without writing a new log entry.
534    pub fn revert_transition(&mut self, transition: &GenericTransition<T>) {
535        self.replaying = true;
536        self.try_revert_transition(transition);
537        self.replaying = false;
538    }
539
540    fn try_apply_transition(&mut self, transition: &GenericTransition<T>, record_applied: bool) {
541        let zero = T::from_byte(0);
542        let result: Result<(), LinkError> = match transition.kind {
543            TransitionKind::Create => {
544                if transition.after.index != zero && !self.inner.link_exists(transition.after.index)
545                {
546                    self.inner
547                        .ensure_link_created(transition.after.index)
548                        .and_then(|_| {
549                            self.inner
550                                .update_link(
551                                    transition.after.index,
552                                    transition.after.source,
553                                    transition.after.target,
554                                )
555                                .map(|_| ())
556                        })
557                } else {
558                    Ok(())
559                }
560            }
561            TransitionKind::Update => {
562                if transition.after.index != zero && self.inner.link_exists(transition.after.index)
563                {
564                    self.inner
565                        .update_link(
566                            transition.after.index,
567                            transition.after.source,
568                            transition.after.target,
569                        )
570                        .map(|_| ())
571                } else {
572                    Ok(())
573                }
574            }
575            TransitionKind::Delete => {
576                if transition.before.index != zero
577                    && self.inner.link_exists(transition.before.index)
578                {
579                    self.inner.delete_link(transition.before.index).map(|_| ())
580                } else {
581                    Ok(())
582                }
583            }
584        };
585        if let Err(e) = result {
586            if self.trace {
587                eprintln!(
588                    "[Transactions] Failed to apply transition seq={}: {e}",
589                    transition.sequence
590                );
591            }
592        }
593        if record_applied {
594            let _ = self.mark_applied(transition);
595        }
596    }
597
598    fn try_revert_transition(&mut self, transition: &GenericTransition<T>) {
599        let zero = T::from_byte(0);
600        let result: Result<(), LinkError> = match transition.kind {
601            TransitionKind::Create => {
602                if transition.after.index != zero && self.inner.link_exists(transition.after.index)
603                {
604                    self.inner.delete_link(transition.after.index).map(|_| ())
605                } else {
606                    Ok(())
607                }
608            }
609            TransitionKind::Update => {
610                if transition.before.index != zero
611                    && self.inner.link_exists(transition.before.index)
612                {
613                    self.inner
614                        .update_link(
615                            transition.before.index,
616                            transition.before.source,
617                            transition.before.target,
618                        )
619                        .map(|_| ())
620                } else {
621                    Ok(())
622                }
623            }
624            TransitionKind::Delete => {
625                if transition.before.index != zero
626                    && !self.inner.link_exists(transition.before.index)
627                {
628                    self.inner
629                        .ensure_link_created(transition.before.index)
630                        .and_then(|_| {
631                            self.inner
632                                .update_link(
633                                    transition.before.index,
634                                    transition.before.source,
635                                    transition.before.target,
636                                )
637                                .map(|_| ())
638                        })
639                } else {
640                    Ok(())
641                }
642            }
643        };
644        if let Err(e) = result {
645            if self.trace {
646                eprintln!(
647                    "[Transactions] Failed to revert transition seq={}: {e}",
648                    transition.sequence
649                );
650            }
651        }
652    }
653
654    fn mark_applied(&mut self, transition: &GenericTransition<T>) -> Result<(), LinkError> {
655        if self.applied.insert(transition.sequence) {
656            self.write_marker(&format!("{APPLIED_MARKER_PREFIX}{}", transition.sequence))?;
657            if transition.sequence > self.applied_sequence {
658                self.applied_sequence = transition.sequence;
659            }
660        }
661        Ok(())
662    }
663
664    // ----- Recovery -------------------------------------------------------
665
666    /// Rebuilds the in-memory log and marker tables from the sidecar
667    /// log store and re-applies committed-but-unapplied side-effects.
668    ///
669    /// Entries that cannot be parsed are skipped: an append-only log
670    /// can end in the partial entry of a crashed write, and the
671    /// links-backed log can hold names that belong to other features.
672    /// An entry whose addresses do not fit into `T` is *not* skipped —
673    /// that means the log was written by a wider address type and
674    /// silently dropping it would corrupt the recovered state.
675    pub fn recover(&mut self) -> Result<(), LinkError> {
676        self.log.clear();
677        self.committed.clear();
678        self.rolled_back.clear();
679        self.applied.clear();
680        self.sequence_counter = 0;
681        self.applied_sequence = 0;
682
683        for entry in self.log_store.read_log_entries()? {
684            if let Some(payload) = entry.strip_prefix(TRANSITION_NAME_PREFIX) {
685                match GenericTransition::<T>::parse(payload) {
686                    Ok(transition) => {
687                        insert_ordered(&mut self.log, transition);
688                        if transition.sequence > self.sequence_counter {
689                            self.sequence_counter = transition.sequence;
690                        }
691                    }
692                    Err(LinkError::AddressOutOfRange(value)) => {
693                        return Err(LinkError::AddressOutOfRange(value))
694                    }
695                    Err(error) => {
696                        if self.trace {
697                            eprintln!("[Transactions] Skipping unreadable log entry: {error}");
698                        }
699                    }
700                }
701            } else if let Some(rest) = entry.strip_prefix(COMMIT_MARKER_PREFIX) {
702                if let Ok(tx_id) = u128::from_str_radix(rest, 16) {
703                    self.committed.insert(tx_id);
704                }
705            } else if let Some(rest) = entry.strip_prefix(ROLLBACK_MARKER_PREFIX) {
706                if let Ok(tx_id) = u128::from_str_radix(rest, 16) {
707                    self.rolled_back.insert(tx_id);
708                }
709            } else if let Some(rest) = entry.strip_prefix(APPLIED_MARKER_PREFIX) {
710                if let Ok(seq) = rest.parse::<i64>() {
711                    self.applied.insert(seq);
712                    if seq > self.applied_sequence {
713                        self.applied_sequence = seq;
714                    }
715                }
716            }
717        }
718
719        // Re-apply committed-but-not-applied transitions (crash mid-async).
720        let log_snapshot: Vec<GenericTransition<T>> = self.log.clone();
721        self.replaying = true;
722        for transition in &log_snapshot {
723            if !self.committed.contains(&transition.transaction_id) {
724                continue;
725            }
726            if self.applied.contains(&transition.sequence) {
727                continue;
728            }
729            self.try_apply_transition(transition, true);
730        }
731        // Auto-rollback transitions written but never committed and never rolled back (R10).
732        let mut pending_tx_ids: Vec<u128> = Vec::new();
733        for transition in log_snapshot.iter().rev() {
734            if self.committed.contains(&transition.transaction_id) {
735                continue;
736            }
737            if self.rolled_back.contains(&transition.transaction_id) {
738                continue;
739            }
740            self.try_revert_transition(transition);
741            if !pending_tx_ids.contains(&transition.transaction_id) {
742                pending_tx_ids.push(transition.transaction_id);
743            }
744        }
745        self.replaying = false;
746        for tx_id in pending_tx_ids {
747            self.rolled_back.insert(tx_id);
748            self.write_marker(&format!("{ROLLBACK_MARKER_PREFIX}{tx_id:032x}"))?;
749        }
750        Ok(())
751    }
752
753    fn enforce_retention(&mut self) -> Result<(), LinkError> {
754        match self.retention_policy.clone() {
755            LogRetentionPolicy::Infinite => Ok(()),
756            LogRetentionPolicy::Sized { max_transitions } => self.enforce_sized(max_transitions),
757            LogRetentionPolicy::Chunked {
758                chunk_size,
759                archive_directory,
760            } => self.enforce_chunked(chunk_size, &archive_directory),
761        }
762    }
763
764    fn enforce_sized(&mut self, max_transitions: u64) -> Result<(), LinkError> {
765        if max_transitions == 0 {
766            return Ok(());
767        }
768        while self.log.len() as u64 > max_transitions {
769            let head = self.log[0];
770            if !self.applied.contains(&head.sequence) {
771                self.replaying = true;
772                self.try_apply_transition(&head, true);
773                self.replaying = false;
774                if !self.applied.contains(&head.sequence) {
775                    break; // R7: never drop an un-applied transition.
776                }
777            }
778            self.log.remove(0);
779            if self.trace {
780                eprintln!(
781                    "[Transactions] Dropped applied transition seq={} per sized retention.",
782                    head.sequence
783                );
784            }
785        }
786        Ok(())
787    }
788
789    fn enforce_chunked(
790        &mut self,
791        chunk_size: u64,
792        archive_directory: &Path,
793    ) -> Result<(), LinkError> {
794        if chunk_size == 0 {
795            return Ok(());
796        }
797        if (self.log.len() as u64) < chunk_size {
798            return Ok(());
799        }
800        let chunk: Vec<GenericTransition<T>> =
801            self.log.iter().take(chunk_size as usize).copied().collect();
802        for transition in &chunk {
803            if !self.applied.contains(&transition.sequence) {
804                self.replaying = true;
805                self.try_apply_transition(transition, true);
806                self.replaying = false;
807                if !self.applied.contains(&transition.sequence) {
808                    return Ok(()); // never drop un-applied
809                }
810            }
811        }
812        std::fs::create_dir_all(archive_directory).map_err(|error| {
813            LinkError::StorageError(format!(
814                "failed to create archive dir {}: {error}",
815                archive_directory.display()
816            ))
817        })?;
818        let timestamp = now_unix_ms();
819        let file_name = format!(
820            "transitions-chunk-{timestamp}-{:032x}.log",
821            new_transaction_id()
822        );
823        let path = archive_directory.join(file_name);
824        use std::io::Write;
825        let mut file = std::fs::File::create(&path).map_err(|error| {
826            LinkError::StorageError(format!(
827                "failed to create archive file {}: {error}",
828                path.display()
829            ))
830        })?;
831        for transition in &chunk {
832            writeln!(file, "{}", transition.serialize())?;
833        }
834        file.flush()?;
835        if self.trace {
836            eprintln!(
837                "[Transactions] Archived {} transitions to {}.",
838                chunk.len(),
839                path.display()
840            );
841        }
842        self.log.drain(0..chunk.len());
843        Ok(())
844    }
845}
846
847/// Read paths that lend out references, available whenever the wrapped
848/// store keeps its links resident in memory.
849impl<T, S, L> GenericTransactionsDecorator<T, S, L>
850where
851    T: LinkReference,
852    S: LinksStorageRef<T>,
853    L: TransitionLogStore,
854{
855    pub fn get(&self, id: T) -> Option<&GenericLink<T>> {
856        self.inner.get_link_ref(id)
857    }
858
859    pub fn all(&self) -> Vec<&GenericLink<T>> {
860        self.inner.all_link_refs()
861    }
862
863    pub fn query(
864        &self,
865        index: Option<T>,
866        source: Option<T>,
867        target: Option<T>,
868    ) -> Vec<&GenericLink<T>> {
869        self.inner.query_link_refs(index, source, target)
870    }
871}
872
873// ----- Helpers ----------------------------------------------------------
874
875fn insert_ordered<T: LinkReference>(
876    list: &mut Vec<GenericTransition<T>>,
877    transition: GenericTransition<T>,
878) {
879    let mut lo = 0usize;
880    let mut hi = list.len();
881    while lo < hi {
882        let mid = (lo + hi) / 2;
883        if list[mid].sequence < transition.sequence {
884            lo = mid + 1;
885        } else {
886            hi = mid;
887        }
888    }
889    list.insert(lo, transition);
890}
891
892static TX_COUNTER: AtomicU64 = AtomicU64::new(0);
893
894fn new_transaction_id() -> u128 {
895    // Combine a per-process counter with the current timestamp to
896    // approximate a Guid without pulling in the `uuid` crate.
897    let count = TX_COUNTER.fetch_add(1, Ordering::Relaxed) as u128;
898    let now = now_unix_ms() as u128;
899    (now << 64) | count
900}
901
902fn now_unix_ms() -> i64 {
903    SystemTime::now()
904        .duration_since(UNIX_EPOCH)
905        .map(|d| d.as_millis() as i64)
906        .unwrap_or(0)
907}
908
909#[cfg(test)]
910mod tests {
911    use super::*;
912
913    #[test]
914    fn retention_policy_parses_specs() {
915        assert!(matches!(
916            LogRetentionPolicy::parse("infinite").unwrap(),
917            LogRetentionPolicy::Infinite
918        ));
919        assert!(matches!(
920            LogRetentionPolicy::parse("sized:1000").unwrap(),
921            LogRetentionPolicy::Sized {
922                max_transitions: 1000
923            }
924        ));
925        match LogRetentionPolicy::parse("chunked:500:/tmp/x").unwrap() {
926            LogRetentionPolicy::Chunked {
927                chunk_size,
928                archive_directory,
929            } => {
930                assert_eq!(chunk_size, 500);
931                assert_eq!(archive_directory, PathBuf::from("/tmp/x"));
932            }
933            _ => panic!("expected Chunked"),
934        }
935        assert!(LogRetentionPolicy::parse("garbage").is_err());
936    }
937
938    #[test]
939    fn transition_round_trips_through_serialize() {
940        let t = Transition {
941            transaction_id: 0xabcdef1234567890u128,
942            sequence: 42,
943            timestamp_ms: 1234567890,
944            kind: TransitionKind::Update,
945            before: DoubletLink::new(1, 2, 3),
946            after: DoubletLink::new(1, 4, 5),
947        };
948        let parsed = Transition::try_parse(&t.serialize()).unwrap();
949        assert_eq!(t, parsed);
950    }
951
952    #[test]
953    fn wide_transition_is_rejected_by_a_narrow_address_type() {
954        let wide = GenericTransition::<u64> {
955            transaction_id: 7,
956            sequence: 1,
957            timestamp_ms: 0,
958            kind: TransitionKind::Create,
959            before: GenericDoubletLink::empty(),
960            after: GenericDoubletLink::new(u32::MAX as u64 + 1, 0, 0),
961        };
962        assert!(matches!(
963            GenericTransition::<u32>::parse(&wide.serialize()),
964            Err(LinkError::AddressOutOfRange(_))
965        ));
966        assert!(GenericTransition::<u64>::parse(&wide.serialize()).is_ok());
967    }
968}