Skip to main content

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}