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}