macrame/temporal/snapshot.rs
1use std::fs;
2use std::io::{Read, Write};
3use std::path::{Path, PathBuf};
4
5use crate::error::{DbError, Result};
6use crate::temporal::replay::MaterializedState;
7
8/// Header magic. Also the marker that separates a 0.5.5 snapshot from the
9/// headerless files 0.5.4 and earlier wrote, whose first bytes are zstd's own
10/// magic (`28 B5 2F FD`) and therefore never match this.
11const SNAP_MAGIC: [u8; 4] = *b"MACR";
12
13/// On-disk layout version for the snapshot container (D-043).
14///
15/// Bumped whenever the *shape* of [`MaterializedState`] changes, independently
16/// of the database schema. `bincode` is not self-describing: adding a field
17/// does not make an old file fail to parse, it makes it parse into the wrong
18/// values — and a snapshot is the first thing a restart reaches for, so the
19/// wrong values arrive labelled as the newest state anyone believed.
20/// **v2 (0.5.5)** adds the snapshot's own instant to the header (D-054).
21const SNAP_FORMAT_VERSION: u16 = 2;
22
23/// `magic (4) + format_version (2) + schema_version (4) + taken_at_micros (8)`,
24/// little-endian.
25const SNAP_HEADER_LEN: usize = 18;
26
27/// Microseconds since the Unix epoch, from the snapshot's own `timestamp`.
28///
29/// The instant is already in the payload — this is a *copy* in the header, which
30/// is the kind of second description this codebase usually refuses. It earns the
31/// exception by what reads it: retention has to bucket every snapshot by day, and
32/// the alternative is decompressing and deserializing a full `MaterializedState`
33/// per file on every pass, which would make the cadence's own maintenance cost
34/// more than the work it exists to save. Eighteen bytes read without touching
35/// zstd is the whole point of having a header at all (D-043).
36///
37/// It cannot drift from the payload because both are written from the same value
38/// in the same statement, and nothing rewrites a snapshot in place.
39fn taken_at_micros(state: &MaterializedState) -> u64 {
40 crate::util::timestamp::parse(&state.timestamp)
41 .ok()
42 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
43 .map(|d| d.as_micros() as u64)
44 .unwrap_or(0)
45}
46
47fn snapshot_header(schema_version: u32, taken_at: u64) -> [u8; SNAP_HEADER_LEN] {
48 let mut h = [0u8; SNAP_HEADER_LEN];
49 h[0..4].copy_from_slice(&SNAP_MAGIC);
50 h[4..6].copy_from_slice(&SNAP_FORMAT_VERSION.to_le_bytes());
51 h[6..10].copy_from_slice(&schema_version.to_le_bytes());
52 h[10..18].copy_from_slice(&taken_at.to_le_bytes());
53 h
54}
55
56/// The instant a snapshot reflects, read from its header alone.
57///
58/// `None` for anything this build would refuse to load anyway — a foreign file,
59/// an older container, a truncated one. Retention treats that as "no date" and
60/// falls back to the newest-N rule for it rather than guessing.
61fn header_taken_at(path: &Path) -> Option<u64> {
62 let mut file = fs::File::open(path).ok()?;
63 let mut head = [0u8; SNAP_HEADER_LEN];
64 file.read_exact(&mut head).ok()?;
65 if head[0..4] != SNAP_MAGIC {
66 return None;
67 }
68 if u16::from_le_bytes([head[4], head[5]]) != SNAP_FORMAT_VERSION {
69 return None;
70 }
71 let micros = u64::from_le_bytes(head[10..18].try_into().ok()?);
72 (micros > 0).then_some(micros)
73}
74
75/// Zero-padding width for the `seq_id` in a snapshot filename.
76///
77/// `seq_id` is an `INTEGER PRIMARY KEY AUTOINCREMENT`, so its ceiling is
78/// `i64::MAX` — 19 digits. The previous `{:08}` produced names that stopped
79/// sorting in `seq_id` order the moment the ledger passed 10^8 entries, which is
80/// the same fixed-width failure D-029 describes, deferred rather than avoided.
81/// Retention no longer *depends* on this (see [`cleanup_expired_snapshots`]),
82/// but a directory listing should still read in order.
83const SEQ_WIDTH: usize = 19;
84
85/// The snapshot file for a given anchor.
86fn snapshot_filename(seq_anchor: i64) -> String {
87 format!("{seq_anchor:0SEQ_WIDTH$}.snap.zst")
88}
89
90/// Recover the anchor a snapshot filename encodes.
91pub(crate) fn seq_from_filename(path: &Path) -> Option<i64> {
92 path.file_name()?
93 .to_str()?
94 .strip_suffix(".snap.zst")?
95 .parse()
96 .ok()
97}
98
99/// Save a bincode-serialized, zstd-compressed snapshot file (.snap.zst) (§5.5).
100///
101/// Written to a temporary file, flushed to disk, and renamed into place. A
102/// snapshot is read back with no integrity check beyond what zstd and bincode
103/// happen to notice, so a half-written file at the final name is a file that
104/// looks loadable and is not — and it would be the *newest* one, which is
105/// exactly the one a restart reaches for. Rename within a directory is atomic,
106/// so a crash leaves either the old snapshot or the new one, never a splice.
107pub fn save_snapshot(snapshots_dir: &Path, state: &MaterializedState) -> Result<PathBuf> {
108 let fail = |what: &str, e: std::io::Error| DbError::ReplayCorrupt {
109 seq: state.seq_anchor,
110 reason: format!("{what}: {e}"),
111 };
112
113 fs::create_dir_all(snapshots_dir)
114 .map_err(|e| fail("failed to create snapshot directory", e))?;
115
116 let path = snapshots_dir.join(snapshot_filename(state.seq_anchor));
117 let tmp_path = path.with_extension("tmp");
118
119 let serialized = bincode::serialize(state).map_err(|e| DbError::ReplayCorrupt {
120 seq: state.seq_anchor,
121 reason: format!("failed to serialize snapshot: {e}"),
122 })?;
123
124 let compressed =
125 zstd::encode_all(&serialized[..], 3).map_err(|e| fail("failed to compress snapshot", e))?;
126
127 let mut file =
128 fs::File::create(&tmp_path).map_err(|e| fail("failed to create snapshot temp file", e))?;
129 // Header first, uncompressed: it has to be readable without committing to
130 // decompressing a payload this build may not understand (D-043).
131 file.write_all(&snapshot_header(
132 crate::schema::migrations::SCHEMA_VERSION,
133 taken_at_micros(state),
134 ))
135 .map_err(|e| fail("failed to write snapshot header", e))?;
136 file.write_all(&compressed)
137 .map_err(|e| fail("failed to write snapshot bytes", e))?;
138 // Before the rename, or the rename can land ahead of the data.
139 file.sync_all()
140 .map_err(|e| fail("failed to flush snapshot to disk", e))?;
141 drop(file);
142
143 fs::rename(&tmp_path, &path).map_err(|e| {
144 let _ = fs::remove_file(&tmp_path);
145 fail("failed to publish snapshot", e)
146 })?;
147
148 Ok(path)
149}
150
151/// Load a snapshot, refusing anything this build cannot read (§5.5, D-043).
152///
153/// The header is checked *before* the payload is decompressed, and a mismatch
154/// is [`DbError::SnapshotIncompatible`] rather than a corruption error, because
155/// the two want opposite responses: corruption is a fault to report, an
156/// incompatible snapshot is an ordinary consequence of upgrading and the right
157/// answer is to discard it and cold-fold. Distinguishing them is the whole
158/// point of the header — `bincode` is not self-describing, so without one an
159/// old file does not reliably fail to parse, it parses into wrong values.
160///
161/// Headerless files written by 0.5.4 and earlier are rejected by the same path:
162/// their first four bytes are zstd's magic, which is not `MACR`.
163pub fn load_snapshot(path: &Path) -> Result<MaterializedState> {
164 let mut file = fs::File::open(path).map_err(|e| DbError::ReplayCorrupt {
165 seq: 0,
166 reason: format!("Failed to open snapshot file {:?}: {e}", path),
167 })?;
168
169 let mut raw = Vec::new();
170 file.read_to_end(&mut raw)
171 .map_err(|e| DbError::ReplayCorrupt {
172 seq: 0,
173 reason: format!("Failed to read snapshot file {:?}: {e}", path),
174 })?;
175
176 if raw.len() < SNAP_HEADER_LEN || raw[0..4] != SNAP_MAGIC {
177 return Err(DbError::SnapshotIncompatible {
178 path: path.display().to_string(),
179 reason: "not a macrame snapshot, or written before the versioned \
180 container existed (0.5.4 and earlier)"
181 .to_string(),
182 });
183 }
184
185 let format = u16::from_le_bytes([raw[4], raw[5]]);
186 let schema = u32::from_le_bytes([raw[6], raw[7], raw[8], raw[9]]);
187 let expected_schema = crate::schema::migrations::SCHEMA_VERSION;
188 if format != SNAP_FORMAT_VERSION || schema != expected_schema {
189 return Err(DbError::SnapshotIncompatible {
190 path: path.display().to_string(),
191 reason: format!(
192 "snapshot is format v{format}/schema v{schema}; this build reads \
193 format v{SNAP_FORMAT_VERSION}/schema v{expected_schema}"
194 ),
195 });
196 }
197
198 let compressed = &raw[SNAP_HEADER_LEN..];
199 let decompressed = zstd::decode_all(compressed).map_err(|e| DbError::ReplayCorrupt {
200 seq: 0,
201 reason: format!("Failed to decompress snapshot {:?}: {e}", path),
202 })?;
203
204 let state: MaterializedState =
205 bincode::deserialize(&decompressed).map_err(|e| DbError::ReplayCorrupt {
206 seq: 0,
207 reason: format!("Failed to deserialize snapshot {:?}: {e}", path),
208 })?;
209
210 Ok(state)
211}
212
213/// Write the final snapshot on clean shutdown (§5.1.7).
214///
215/// Called after the Write Actor has stopped, so the state it folds is quiescent
216/// — nothing can commit between the fold and the write. Returns the snapshot's
217/// path so a caller can log or verify it.
218///
219/// This was a `Ok(())` stub that `close()` never called, which meant every
220/// restart replayed the log from whatever snapshot happened to be lying around
221/// rather than from the shutdown anchor.
222pub async fn write_final(
223 conn: &libsql::Connection,
224 snapshots_dir: &Path,
225 ts: &str,
226 archive_path: Option<&Path>,
227) -> Result<PathBuf> {
228 let state =
229 crate::temporal::replay::reconstruct(conn, ts, archive_path, Some(snapshots_dir)).await?;
230 let path = save_snapshot(snapshots_dir, &state)?;
231 cleanup_expired_snapshots(snapshots_dir)?;
232 Ok(path)
233}
234
235/// Snapshots kept unconditionally, newest first, by [`cleanup_expired_snapshots`] (§5.5).
236const RETAIN: usize = 5;
237
238/// Days for which one snapshot each is kept beyond [`RETAIN`] (§5.5, D-054).
239const RETAIN_DAYS: i64 = 30;
240
241const MICROS_PER_DAY: u64 = 86_400_000_000;
242
243/// Retention: the newest [`RETAIN`], **plus one per day for [`RETAIN_DAYS`]**
244/// (§5.5, D-054).
245///
246/// **Why the daily tier exists, and why it did not matter until now.** Through
247/// 0.5.4 a snapshot was written once per clean shutdown, so "newest five" was
248/// five shutdowns — days or weeks of coverage, and the daily rule §5.5 specifies
249/// bought nothing. The cadence ([D-053](../../docs/architecture/s13-decision-register.md))
250/// writes one every 10,000 log entries, so under load five anchors can span
251/// minutes: every instant older than that falls back to folding the whole log,
252/// which is the cost snapshots exist to avoid. The flat rule went from harmless
253/// to actively defeating the feature that had just been added.
254///
255/// Ordered by the `seq_id` parsed out of each filename, not by the filename
256/// itself. A lexicographic sort over names is only `seq_id` order while every
257/// name is the same width, and "delete the oldest" reading from a mis-sorted
258/// list deletes the wrong files — quietly, and preferentially the newest ones.
259/// Parsing removes the dependency on [`SEQ_WIDTH`] entirely.
260///
261/// A snapshot whose header carries no readable instant survives only under the
262/// newest-[`RETAIN`] rule. That is deliberate: it is a file this build would
263/// refuse to *load* anyway, so keeping it for its date would be keeping it for a
264/// date nothing will ever use.
265pub fn cleanup_expired_snapshots(snapshots_dir: &Path) -> Result<usize> {
266 if !snapshots_dir.exists() {
267 return Ok(0);
268 }
269
270 let read_dir = fs::read_dir(snapshots_dir).map_err(|e| DbError::ReplayCorrupt {
271 seq: 0,
272 reason: format!("failed to read snapshot dir: {e}"),
273 })?;
274
275 // (seq_id, path, day since epoch — None when the header carries no instant)
276 let mut snapshots: Vec<(i64, PathBuf, Option<i64>)> = Vec::new();
277 for entry in read_dir.flatten() {
278 let path = entry.path();
279 match path.extension().and_then(|e| e.to_str()) {
280 // A leftover from an interrupted save. It was never renamed into
281 // place, so nothing can be reading it, and left alone these
282 // accumulate forever.
283 Some("tmp") => {
284 let _ = fs::remove_file(&path);
285 }
286 Some("zst") => match seq_from_filename(&path) {
287 Some(seq) => {
288 let day = header_taken_at(&path).map(|micros| (micros / MICROS_PER_DAY) as i64);
289 snapshots.push((seq, path, day));
290 }
291 // Not ours, or a name we cannot order. Deleting on a guess is
292 // how retention turns into data loss.
293 None => tracing::warn!("snapshot cleanup: unparseable filename {path:?}, skipping"),
294 },
295 _ => {}
296 }
297 }
298
299 snapshots.sort_by_key(|(seq, _, _)| *seq);
300
301 let mut keep: std::collections::HashSet<&PathBuf> = snapshots
302 .iter()
303 .rev()
304 .take(RETAIN)
305 .map(|(_, path, _)| path)
306 .collect();
307
308 // One per day, for the last RETAIN_DAYS days. "Today" is the newest
309 // snapshot's own day rather than the wall clock: retention is then a
310 // function of the directory's contents and nothing else, so it is
311 // deterministic and testable — and a database left untouched for a year does
312 // not have its entire history deleted by the first write after it wakes up.
313 if let Some(today) = snapshots.iter().filter_map(|(_, _, day)| *day).max() {
314 let horizon = today - (RETAIN_DAYS - 1);
315 let mut newest_of_day: std::collections::BTreeMap<i64, &PathBuf> =
316 std::collections::BTreeMap::new();
317 // Ascending by seq, so the last write for a day wins its slot.
318 for (_, path, day) in &snapshots {
319 if let Some(day) = *day {
320 if day >= horizon {
321 newest_of_day.insert(day, path);
322 }
323 }
324 }
325 keep.extend(newest_of_day.into_values());
326 }
327
328 let doomed: Vec<PathBuf> = snapshots
329 .iter()
330 .filter(|(_, path, _)| !keep.contains(path))
331 .map(|(_, path, _)| path.clone())
332 .collect();
333
334 let mut removed = 0;
335 for path in doomed {
336 if let Err(e) = fs::remove_file(&path) {
337 tracing::warn!("failed to remove expired snapshot {path:?}: {e}");
338 } else {
339 removed += 1;
340 }
341 }
342
343 Ok(removed)
344}
345
346// ---------------------------------------------------------------------------
347// The maintenance cadence (§5.5, D-053)
348// ---------------------------------------------------------------------------
349
350/// How often the maintenance task writes an anchor (§5.5).
351///
352/// §5.5 specifies "every 10,000 log entries", which is a *distance* rather than
353/// a schedule — the point is to bound how much delta a reconstruction has to
354/// fold, and delta is measured in log entries, not seconds. An idle database
355/// therefore writes nothing at all, however long it stays open.
356///
357/// `poll_interval` is how often that distance is checked, and it is the part
358/// §5.5 does not specify because it is an implementation cost rather than a
359/// property: the check is `SELECT MAX(seq_id)`, an index lookup on an integer
360/// primary key, so the interval trades a negligible read against how promptly a
361/// burst of writes is noticed.
362#[derive(Debug, Clone, Copy, PartialEq, Eq)]
363pub struct SnapshotCadence {
364 /// Write an anchor once the log has grown this many entries past the last.
365 pub every_entries: i64,
366 /// How often to compare the log's head against the last anchor.
367 pub poll_interval: std::time::Duration,
368}
369
370impl Default for SnapshotCadence {
371 fn default() -> Self {
372 Self {
373 every_entries: 10_000,
374 poll_interval: std::time::Duration::from_secs(5),
375 }
376 }
377}
378
379/// The newest anchor already on disk, as a `seq_id`, or 0 if there is none.
380///
381/// Read from the filenames rather than remembered across runs: a process that
382/// starts against a database someone else has been writing should not re-anchor
383/// immediately, and the files are the only record of what has been anchored.
384fn newest_anchor_on_disk(snapshots_dir: &Path) -> i64 {
385 let Ok(entries) = fs::read_dir(snapshots_dir) else {
386 return 0;
387 };
388 entries
389 .flatten()
390 .map(|e| e.path())
391 .filter_map(|p| seq_from_filename(&p))
392 .max()
393 .unwrap_or(0)
394}
395
396async fn log_head(conn: &libsql::Connection) -> Result<Option<(i64, String)>> {
397 let mut rows = conn
398 .query(
399 "SELECT MAX(seq_id), MAX(recorded_at) FROM transaction_log",
400 (),
401 )
402 .await?;
403 let Some(row) = rows.next().await? else {
404 return Ok(None);
405 };
406 match (row.get::<i64>(0), row.get::<String>(1)) {
407 (Ok(seq), Ok(ts)) => Ok(Some((seq, ts))),
408 // An empty log yields one row of NULLs, not zero rows.
409 _ => Ok(None),
410 }
411}
412
413/// The read-side maintenance task §5.5 specifies (D-053).
414///
415/// Everything it does is a read plus a file write, so it never touches the write
416/// connection and cannot lengthen the actor's loop — which is the whole reason
417/// §5.5 puts snapshotting on the read side, since §5.1.5's latency bound is a
418/// property of how long that loop can take.
419///
420/// It anchors at `MAX(recorded_at)` rather than at the clock's `now()`. The two
421/// differ by however long it has been since the last write, and anchoring at a
422/// timestamp *after* the newest entry would produce a snapshot whose contents
423/// are identical but whose name and header claim a later instant than anything
424/// it reflects. Anchoring at the newest belief keeps the file honest about what
425/// it is a snapshot *of*.
426///
427/// Failures are logged and retried on the next tick rather than ending the task.
428/// A snapshot is a cache: failing to write one costs a slower reconstruction and
429/// nothing else, and a maintenance task that exits on its first transient error
430/// is indistinguishable from one that was never spawned.
431pub(crate) async fn run_cadence(
432 conn: libsql::Connection,
433 snapshots_dir: PathBuf,
434 archive_path: PathBuf,
435 cadence: SnapshotCadence,
436 mut stop: tokio::sync::watch::Receiver<bool>,
437) {
438 let mut anchored = newest_anchor_on_disk(&snapshots_dir);
439
440 loop {
441 tokio::select! {
442 biased;
443 // Dropped sender counts as a stop, so a `Database` that is dropped
444 // rather than closed does not leave this running against a
445 // connection whose database is going away.
446 _ = stop.changed() => return,
447 _ = tokio::time::sleep(cadence.poll_interval) => {}
448 }
449
450 let head = match log_head(&conn).await {
451 Ok(Some(head)) => head,
452 Ok(None) => continue,
453 Err(e) => {
454 tracing::warn!("snapshot cadence: could not read the log head: {e}");
455 continue;
456 }
457 };
458 let (max_seq, ts) = head;
459
460 if max_seq - anchored < cadence.every_entries {
461 continue;
462 }
463
464 let archive = archive_path.exists().then_some(archive_path.as_path());
465 match write_final(&conn, &snapshots_dir, &ts, archive).await {
466 Ok(path) => {
467 anchored = seq_from_filename(&path).unwrap_or(max_seq);
468 tracing::debug!("snapshot cadence: anchored at seq {anchored} ({path:?})");
469 }
470 Err(e) => {
471 // Deliberately does not advance `anchored`: the next tick
472 // retries rather than waiting another whole interval's worth of
473 // entries after a failure.
474 tracing::warn!("snapshot cadence: failed to write an anchor: {e}");
475 }
476 }
477 }
478}