Skip to main content

link_cli/transactions/
log.rs

1//! Storage backends for the transitions log.
2//!
3//! The transactions layer only needs three things from its log: append
4//! one entry, read every entry back in write order, and make what has
5//! been appended durable. [`TransitionLogStore`] captures exactly that,
6//! so a consumer can plug in whichever log it already has.
7//!
8//! Two implementations ship with the library:
9//!
10//! * [`NamedTypesDecorator`] — the original sidecar *links* log, where
11//!   each entry is the name of a freshly created link. Keeps the log in
12//!   the same format the C# port uses.
13//! * [`FileTransitionLog`] — a plain append-only text file, one entry
14//!   per line, `fsync`ed on every append by default. Cheaper than a
15//!   links database and crash-safe by construction: a torn write can
16//!   only ever damage the final line, which [`FileTransitionLog`]
17//!   discards on read.
18//!
19//! Reading takes `&mut self` because the links-backed log resolves
20//! names through [`NamedTypes::get_name`], which needs mutable access
21//! to its own caches.
22
23use std::fs::{File, OpenOptions};
24use std::io::{Read, Seek, SeekFrom, Write};
25use std::path::{Path, PathBuf};
26
27use crate::error::LinkError;
28use crate::named_types::{NamedTypes, NamedTypesDecorator};
29
30/// Append-only store of serialized transitions and recovery markers.
31pub trait TransitionLogStore {
32    /// Appends one entry. Entries never contain newlines.
33    fn append_log_entry(&mut self, entry: &str) -> Result<(), LinkError>;
34
35    /// Reads every entry back, oldest first.
36    fn read_log_entries(&mut self) -> Result<Vec<String>, LinkError>;
37
38    /// Makes every appended entry durable.
39    fn flush_log(&mut self) -> Result<(), LinkError>;
40}
41
42fn storage_error(error: anyhow::Error) -> LinkError {
43    LinkError::StorageError(format!("{error:#}"))
44}
45
46impl TransitionLogStore for NamedTypesDecorator {
47    fn append_log_entry(&mut self, entry: &str) -> Result<(), LinkError> {
48        // Always allocate a fresh link so entries never overwrite one
49        // another (mirrors C# `CreateAndUpdate(Null, Null)`).
50        let link = self.create(0, 0);
51        self.set_name(link, entry).map_err(storage_error)?;
52        Ok(())
53    }
54
55    fn read_log_entries(&mut self) -> Result<Vec<String>, LinkError> {
56        let mut addresses: Vec<u32> = self.all().into_iter().map(|link| link.index).collect();
57        addresses.sort_unstable();
58        let mut entries = Vec::with_capacity(addresses.len());
59        for address in addresses {
60            if let Some(name) = NamedTypes::get_name(self, address).map_err(storage_error)? {
61                entries.push(name);
62            }
63        }
64        Ok(entries)
65    }
66
67    fn flush_log(&mut self) -> Result<(), LinkError> {
68        NamedTypesDecorator::save(self).map_err(storage_error)
69    }
70}
71
72/// Append-only, line-oriented transitions log backed by a single file.
73///
74/// # Durability
75///
76/// With `sync_on_append` enabled (the default) every
77/// [`append_log_entry`](TransitionLogStore::append_log_entry) returns
78/// only after the entry has reached stable storage, which is what makes
79/// the write-ahead ordering of the transactions layer meaningful: a
80/// transition is always durable before the data-store write it
81/// describes is reported as committed. Disabling it trades that
82/// guarantee for throughput — the log is then durable only as far as
83/// the last [`flush_log`](TransitionLogStore::flush_log).
84///
85/// A crash can therefore only ever truncate the file mid-line.
86/// [`open`](FileTransitionLog::open) discards such a torn tail before
87/// the log is used again — otherwise the next append would be glued
88/// onto the fragment and lost with it — and
89/// [`read_log_entries`](TransitionLogStore::read_log_entries) ignores
90/// one defensively. A torn write costs at most the single transition
91/// that was in flight.
92#[derive(Debug)]
93pub struct FileTransitionLog {
94    path: PathBuf,
95    file: File,
96    sync_on_append: bool,
97}
98
99impl FileTransitionLog {
100    /// Opens (creating if needed) the log at `path`, discarding a
101    /// trailing entry left half-written by a crash.
102    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, LinkError> {
103        let path = path.as_ref().to_path_buf();
104        if let Some(parent) = path.parent() {
105            if !parent.as_os_str().is_empty() && !parent.exists() {
106                std::fs::create_dir_all(parent)?;
107            }
108        }
109        // The torn tail is trimmed through a dedicated read/write handle:
110        // Windows grants an append-only handle `FILE_APPEND_DATA` without
111        // `FILE_WRITE_DATA`, so `set_len` on it fails with `ERROR_ACCESS_DENIED`.
112        let repair = OpenOptions::new()
113            .read(true)
114            .write(true)
115            .create(true)
116            .truncate(false)
117            .open(&path)?;
118        truncate_torn_tail(&repair)?;
119        drop(repair);
120        let file = OpenOptions::new().read(true).append(true).open(&path)?;
121        Ok(Self {
122            path,
123            file,
124            sync_on_append: true,
125        })
126    }
127
128    /// Path of the backing file.
129    pub fn path(&self) -> &Path {
130        &self.path
131    }
132
133    /// Whether every append is `fsync`ed. Enabled by default.
134    pub fn sync_on_append(&self) -> bool {
135        self.sync_on_append
136    }
137
138    /// Turns per-append `fsync` on or off — see the durability notes.
139    pub fn set_sync_on_append(&mut self, value: bool) {
140        self.sync_on_append = value;
141    }
142}
143
144impl TransitionLogStore for FileTransitionLog {
145    fn append_log_entry(&mut self, entry: &str) -> Result<(), LinkError> {
146        if entry.contains('\n') || entry.contains('\r') {
147            return Err(LinkError::InvalidFormat(
148                "transition log entries must not contain line breaks".to_string(),
149            ));
150        }
151        writeln!(self.file, "{entry}")?;
152        self.file.flush()?;
153        if self.sync_on_append {
154            self.file.sync_data()?;
155        }
156        Ok(())
157    }
158
159    fn read_log_entries(&mut self) -> Result<Vec<String>, LinkError> {
160        let mut contents = String::new();
161        File::open(&self.path)?.read_to_string(&mut contents)?;
162        // Only newline-terminated lines were fully written; a trailing
163        // fragment is the torn tail of a crashed append.
164        Ok(contents
165            .lines()
166            .take(contents.matches('\n').count())
167            .filter(|line| !line.is_empty())
168            .map(|line| line.to_string())
169            .collect())
170    }
171
172    fn flush_log(&mut self) -> Result<(), LinkError> {
173        self.file.flush()?;
174        self.file.sync_data()?;
175        Ok(())
176    }
177}
178
179/// Shrinks `file` to its last complete (newline-terminated) line.
180///
181/// A crash can leave the log ending in a fragment of an entry. Appending
182/// after that fragment would concatenate the next entry onto it, turning
183/// one lost transition into two, so the fragment is dropped when the log
184/// is opened.
185fn truncate_torn_tail(file: &File) -> Result<(), LinkError> {
186    let len = file.metadata()?.len();
187    if len == 0 {
188        return Ok(());
189    }
190    let mut file = file;
191    let mut end = len;
192    let mut buffer = [0u8; 8192];
193    while end > 0 {
194        let chunk = std::cmp::min(end, buffer.len() as u64);
195        let start = end - chunk;
196        file.seek(SeekFrom::Start(start))?;
197        let slice = &mut buffer[..chunk as usize];
198        file.read_exact(slice)?;
199        if let Some(offset) = slice.iter().rposition(|byte| *byte == b'\n') {
200            let complete = start + offset as u64 + 1;
201            if complete != len {
202                file.set_len(complete)?;
203                file.sync_all()?;
204            }
205            return Ok(());
206        }
207        end = start;
208    }
209    // No newline anywhere: the whole file is one torn entry.
210    file.set_len(0)?;
211    file.sync_all()?;
212    Ok(())
213}