use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use crate::error::{DbError, Result};
use crate::temporal::replay::MaterializedState;
const SNAP_MAGIC: [u8; 4] = *b"MACR";
const SNAP_FORMAT_VERSION: u16 = 2;
const SNAP_HEADER_LEN: usize = 18;
fn taken_at_micros(state: &MaterializedState) -> u64 {
crate::util::timestamp::parse(&state.timestamp)
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_micros() as u64)
.unwrap_or(0)
}
fn snapshot_header(schema_version: u32, taken_at: u64) -> [u8; SNAP_HEADER_LEN] {
let mut h = [0u8; SNAP_HEADER_LEN];
h[0..4].copy_from_slice(&SNAP_MAGIC);
h[4..6].copy_from_slice(&SNAP_FORMAT_VERSION.to_le_bytes());
h[6..10].copy_from_slice(&schema_version.to_le_bytes());
h[10..18].copy_from_slice(&taken_at.to_le_bytes());
h
}
fn header_taken_at(path: &Path) -> Option<u64> {
let mut file = fs::File::open(path).ok()?;
let mut head = [0u8; SNAP_HEADER_LEN];
file.read_exact(&mut head).ok()?;
if head[0..4] != SNAP_MAGIC {
return None;
}
if u16::from_le_bytes([head[4], head[5]]) != SNAP_FORMAT_VERSION {
return None;
}
let micros = u64::from_le_bytes(head[10..18].try_into().ok()?);
(micros > 0).then_some(micros)
}
const SEQ_WIDTH: usize = 19;
fn snapshot_filename(seq_anchor: i64) -> String {
format!("{seq_anchor:0SEQ_WIDTH$}.snap.zst")
}
pub(crate) fn seq_from_filename(path: &Path) -> Option<i64> {
path.file_name()?
.to_str()?
.strip_suffix(".snap.zst")?
.parse()
.ok()
}
pub fn save_snapshot(snapshots_dir: &Path, state: &MaterializedState) -> Result<PathBuf> {
let fail = |what: &str, e: std::io::Error| DbError::ReplayCorrupt {
seq: state.seq_anchor,
reason: format!("{what}: {e}"),
};
fs::create_dir_all(snapshots_dir)
.map_err(|e| fail("failed to create snapshot directory", e))?;
let path = snapshots_dir.join(snapshot_filename(state.seq_anchor));
let tmp_path = path.with_extension("tmp");
let serialized = bincode::serialize(state).map_err(|e| DbError::ReplayCorrupt {
seq: state.seq_anchor,
reason: format!("failed to serialize snapshot: {e}"),
})?;
let compressed =
zstd::encode_all(&serialized[..], 3).map_err(|e| fail("failed to compress snapshot", e))?;
let mut file =
fs::File::create(&tmp_path).map_err(|e| fail("failed to create snapshot temp file", e))?;
file.write_all(&snapshot_header(
crate::schema::migrations::SCHEMA_VERSION,
taken_at_micros(state),
))
.map_err(|e| fail("failed to write snapshot header", e))?;
file.write_all(&compressed)
.map_err(|e| fail("failed to write snapshot bytes", e))?;
file.sync_all()
.map_err(|e| fail("failed to flush snapshot to disk", e))?;
drop(file);
fs::rename(&tmp_path, &path).map_err(|e| {
let _ = fs::remove_file(&tmp_path);
fail("failed to publish snapshot", e)
})?;
Ok(path)
}
pub fn load_snapshot(path: &Path) -> Result<MaterializedState> {
let mut file = fs::File::open(path).map_err(|e| DbError::ReplayCorrupt {
seq: 0,
reason: format!("Failed to open snapshot file {:?}: {e}", path),
})?;
let mut raw = Vec::new();
file.read_to_end(&mut raw)
.map_err(|e| DbError::ReplayCorrupt {
seq: 0,
reason: format!("Failed to read snapshot file {:?}: {e}", path),
})?;
if raw.len() < SNAP_HEADER_LEN || raw[0..4] != SNAP_MAGIC {
return Err(DbError::SnapshotIncompatible {
path: path.display().to_string(),
reason: "not a macrame snapshot, or written before the versioned \
container existed (0.5.4 and earlier)"
.to_string(),
});
}
let format = u16::from_le_bytes([raw[4], raw[5]]);
let schema = u32::from_le_bytes([raw[6], raw[7], raw[8], raw[9]]);
let expected_schema = crate::schema::migrations::SCHEMA_VERSION;
if format != SNAP_FORMAT_VERSION || schema != expected_schema {
return Err(DbError::SnapshotIncompatible {
path: path.display().to_string(),
reason: format!(
"snapshot is format v{format}/schema v{schema}; this build reads \
format v{SNAP_FORMAT_VERSION}/schema v{expected_schema}"
),
});
}
let compressed = &raw[SNAP_HEADER_LEN..];
let decompressed = zstd::decode_all(compressed).map_err(|e| DbError::ReplayCorrupt {
seq: 0,
reason: format!("Failed to decompress snapshot {:?}: {e}", path),
})?;
let state: MaterializedState =
bincode::deserialize(&decompressed).map_err(|e| DbError::ReplayCorrupt {
seq: 0,
reason: format!("Failed to deserialize snapshot {:?}: {e}", path),
})?;
Ok(state)
}
pub async fn write_final(
conn: &libsql::Connection,
snapshots_dir: &Path,
ts: &str,
archive_path: Option<&Path>,
) -> Result<PathBuf> {
let state =
crate::temporal::replay::reconstruct(conn, ts, archive_path, Some(snapshots_dir)).await?;
let path = save_snapshot(snapshots_dir, &state)?;
cleanup_expired_snapshots(snapshots_dir)?;
Ok(path)
}
const RETAIN: usize = 5;
const RETAIN_DAYS: i64 = 30;
const MICROS_PER_DAY: u64 = 86_400_000_000;
pub fn cleanup_expired_snapshots(snapshots_dir: &Path) -> Result<usize> {
if !snapshots_dir.exists() {
return Ok(0);
}
let read_dir = fs::read_dir(snapshots_dir).map_err(|e| DbError::ReplayCorrupt {
seq: 0,
reason: format!("failed to read snapshot dir: {e}"),
})?;
let mut snapshots: Vec<(i64, PathBuf, Option<i64>)> = Vec::new();
for entry in read_dir.flatten() {
let path = entry.path();
match path.extension().and_then(|e| e.to_str()) {
Some("tmp") => {
let _ = fs::remove_file(&path);
}
Some("zst") => match seq_from_filename(&path) {
Some(seq) => {
let day = header_taken_at(&path).map(|micros| (micros / MICROS_PER_DAY) as i64);
snapshots.push((seq, path, day));
}
None => tracing::warn!("snapshot cleanup: unparseable filename {path:?}, skipping"),
},
_ => {}
}
}
snapshots.sort_by_key(|(seq, _, _)| *seq);
let mut keep: std::collections::HashSet<&PathBuf> = snapshots
.iter()
.rev()
.take(RETAIN)
.map(|(_, path, _)| path)
.collect();
if let Some(today) = snapshots.iter().filter_map(|(_, _, day)| *day).max() {
let horizon = today - (RETAIN_DAYS - 1);
let mut newest_of_day: std::collections::BTreeMap<i64, &PathBuf> =
std::collections::BTreeMap::new();
for (_, path, day) in &snapshots {
if let Some(day) = *day {
if day >= horizon {
newest_of_day.insert(day, path);
}
}
}
keep.extend(newest_of_day.into_values());
}
let doomed: Vec<PathBuf> = snapshots
.iter()
.filter(|(_, path, _)| !keep.contains(path))
.map(|(_, path, _)| path.clone())
.collect();
let mut removed = 0;
for path in doomed {
if let Err(e) = fs::remove_file(&path) {
tracing::warn!("failed to remove expired snapshot {path:?}: {e}");
} else {
removed += 1;
}
}
Ok(removed)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SnapshotCadence {
pub every_entries: i64,
pub poll_interval: std::time::Duration,
}
impl Default for SnapshotCadence {
fn default() -> Self {
Self {
every_entries: 10_000,
poll_interval: std::time::Duration::from_secs(5),
}
}
}
fn newest_anchor_on_disk(snapshots_dir: &Path) -> i64 {
let Ok(entries) = fs::read_dir(snapshots_dir) else {
return 0;
};
entries
.flatten()
.map(|e| e.path())
.filter_map(|p| seq_from_filename(&p))
.max()
.unwrap_or(0)
}
async fn log_head(conn: &libsql::Connection) -> Result<Option<(i64, String)>> {
let mut rows = conn
.query(
"SELECT MAX(seq_id), MAX(recorded_at) FROM transaction_log",
(),
)
.await?;
let Some(row) = rows.next().await? else {
return Ok(None);
};
match (row.get::<i64>(0), row.get::<String>(1)) {
(Ok(seq), Ok(ts)) => Ok(Some((seq, ts))),
_ => Ok(None),
}
}
pub(crate) async fn run_cadence(
conn: libsql::Connection,
snapshots_dir: PathBuf,
archive_path: PathBuf,
cadence: SnapshotCadence,
mut stop: tokio::sync::watch::Receiver<bool>,
) {
let mut anchored = newest_anchor_on_disk(&snapshots_dir);
loop {
tokio::select! {
biased;
_ = stop.changed() => return,
_ = tokio::time::sleep(cadence.poll_interval) => {}
}
let head = match log_head(&conn).await {
Ok(Some(head)) => head,
Ok(None) => continue,
Err(e) => {
tracing::warn!("snapshot cadence: could not read the log head: {e}");
continue;
}
};
let (max_seq, ts) = head;
if max_seq - anchored < cadence.every_entries {
continue;
}
let archive = archive_path.exists().then_some(archive_path.as_path());
match write_final(&conn, &snapshots_dir, &ts, archive).await {
Ok(path) => {
anchored = seq_from_filename(&path).unwrap_or(max_seq);
tracing::debug!("snapshot cadence: anchored at seq {anchored} ({path:?})");
}
Err(e) => {
tracing::warn!("snapshot cadence: failed to write an anchor: {e}");
}
}
}
}