use std::{
io,
path::{
Path,
PathBuf,
},
};
use core::fmt;
use crate::{
position::Position,
storage::{
manifest::{
MANIFEST_FILE,
MANIFEST_SIZE,
SLOT_SIZE,
SLOT_STRIDE,
SlotRecord,
decode_slot,
encode_slot,
},
vfs::Vfs,
},
};
const TEMP_FILE: &str = "snap.tmp";
const SNAPSHOT_PREFIX: &str = "snap-";
const SNAPSHOT_SUFFIX: &str = ".bin";
fn snapshot_file_name(id: u64) -> String {
format!("{SNAPSHOT_PREFIX}{id}{SNAPSHOT_SUFFIX}")
}
fn snapshot_id_from_path(path: &Path) -> Option<u64> {
let name = path.file_name()?.to_str()?;
let id: u64 = name
.strip_prefix(SNAPSHOT_PREFIX)?
.strip_suffix(SNAPSHOT_SUFFIX)?
.parse()
.ok()?;
(snapshot_file_name(id) == name).then_some(id)
}
fn manifest_is_uninitialized(bytes: &[u8]) -> bool {
bytes.len() < SLOT_SIZE * 2 || bytes.iter().all(|byte| *byte == 0)
}
fn missing_ok<T>(result: io::Result<T>) -> io::Result<Option<T>> {
match result {
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
other => other.map(Some),
}
}
#[derive(Debug, Clone)]
pub struct StoreConfig {
pub dir: PathBuf,
pub retain: usize,
}
#[derive(Debug)]
pub struct Recovered {
pub snapshot: Vec<u8>,
pub cursor: Option<Position>,
}
#[derive(Debug)]
pub enum StoreError {
Io(io::Error),
CorruptManifest,
MissingSnapshot {
id: u64,
},
RetainZero,
Poisoned,
}
impl fmt::Display for StoreError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(error) => write!(f, "snapshot store io error: {error}"),
Self::CorruptManifest => {
write!(f, "manifest exists but neither slot passes its checksum")
}
Self::MissingSnapshot { id } => {
write!(f, "manifest points at missing snapshot {id}")
}
Self::RetainZero => write!(f, "retain must keep at least one snapshot"),
Self::Poisoned => {
write!(f, "store refuses commits after an earlier commit failed")
}
}
}
}
impl core::error::Error for StoreError {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::Io(error) => Some(error),
Self::CorruptManifest
| Self::MissingSnapshot { .. }
| Self::RetainZero
| Self::Poisoned => None,
}
}
}
impl From<io::Error> for StoreError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
pub struct SnapshotStore<V: Vfs> {
dir: PathBuf,
vfs: V,
retain: usize,
slots: [Option<SlotRecord>; 2],
active: usize,
poisoned: bool,
}
impl<V: Vfs> SnapshotStore<V> {
pub fn open(
config: StoreConfig,
mut vfs: V,
) -> Result<(Self, Option<Recovered>), StoreError> {
if config.retain == 0 {
return Err(StoreError::RetainZero);
}
vfs.create_dir_all(&config.dir)?;
let temp_path = config.dir.join(TEMP_FILE);
if missing_ok(vfs.remove(&temp_path))?.is_some() {
vfs.fsync_dir(&config.dir)?;
}
let manifest_path = config.dir.join(MANIFEST_FILE);
let slots = match missing_ok(vfs.read(&manifest_path))? {
Some(bytes) => {
let left = bytes.get(0..SLOT_SIZE).and_then(decode_slot);
let right = bytes
.get(SLOT_STRIDE..SLOT_STRIDE + SLOT_SIZE)
.and_then(decode_slot);
if left.is_none() && right.is_none() && !manifest_is_uninitialized(&bytes)
{
return Err(StoreError::CorruptManifest);
}
[left, right]
}
None => {
vfs.write(&manifest_path, &[0u8; MANIFEST_SIZE])?;
[None, None]
}
};
let active = match (slots[0], slots[1]) {
(Some(left), Some(right)) if right.version > left.version => 1,
(None, Some(_)) => 1,
_ => 0,
};
let recovered = match slots[active] {
None => None,
Some(record) => {
let snapshot_path =
config.dir.join(snapshot_file_name(record.snapshot_id));
let snapshot = missing_ok(vfs.read(&snapshot_path))?.ok_or(
StoreError::MissingSnapshot {
id: record.snapshot_id,
},
)?;
Some(Recovered {
snapshot,
cursor: record.cursor,
})
}
};
let mut store = Self {
dir: config.dir,
vfs,
retain: config.retain,
slots,
active,
poisoned: false,
};
let _ = store.prune();
Ok((store, recovered))
}
pub fn commit(
&mut self,
snapshot: &[u8],
cursor: Option<Position>,
) -> Result<(), StoreError> {
if self.poisoned {
return Err(StoreError::Poisoned);
}
let result = self.commit_once(snapshot, cursor);
if result.is_err() {
self.poisoned = true;
}
result
}
fn commit_once(
&mut self,
snapshot: &[u8],
cursor: Option<Position>,
) -> Result<(), StoreError> {
let current_version = self.slots[self.active].map_or(0, |slot| slot.version);
let next_version = current_version
.checked_add(1)
.expect("snapshot version counter overflow");
let older = 1 - self.active;
let temp_path = self.dir.join(TEMP_FILE);
let snapshot_path = self.dir.join(snapshot_file_name(next_version));
self.vfs.write(&temp_path, snapshot)?;
self.vfs.fsync_file(&temp_path)?;
self.vfs.rename(&temp_path, &snapshot_path)?;
self.vfs.fsync_dir(&self.dir)?;
let record = SlotRecord {
version: next_version,
cursor,
snapshot_id: next_version,
};
let slot_bytes = encode_slot(&record);
let slot_offset =
u64::try_from(older * SLOT_STRIDE).expect("slot offset fits in a u64");
let manifest_path = self.dir.join(MANIFEST_FILE);
self.vfs
.write_at(&manifest_path, slot_offset, &slot_bytes)?;
self.vfs.fsync_file(&manifest_path)?;
self.slots[older] = Some(record);
self.active = older;
let _ = self.prune();
Ok(())
}
pub fn durable_cursor(&self) -> Option<Position> {
self.slots[self.active].and_then(|slot| slot.cursor)
}
pub fn into_vfs(self) -> V {
self.vfs
}
fn prune(&mut self) -> Result<(), StoreError> {
let referenced = self.slots.map(|slot| slot.map(|slot| slot.snapshot_id));
let mut unreferenced: Vec<u64> = self
.vfs
.list(&self.dir)?
.iter()
.filter_map(|path| snapshot_id_from_path(path))
.filter(|id| !referenced.contains(&Some(*id)))
.collect();
unreferenced.sort_unstable();
let remove_count = unreferenced.len().saturating_sub(self.retain);
for id in &unreferenced[..remove_count] {
missing_ok(self.vfs.remove(&self.dir.join(snapshot_file_name(*id))))?;
}
if remove_count > 0 {
self.vfs.fsync_dir(&self.dir)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
storage::vfs::RealVfs,
test_util::CrashVfs,
};
fn config(dir: PathBuf, retain: usize) -> StoreConfig {
StoreConfig { dir, retain }
}
#[test]
fn fresh_dir_opens_empty() {
let vfs = CrashVfs::new();
let (_, recovered) =
SnapshotStore::open(config(PathBuf::from("/store"), 1), vfs).unwrap();
assert!(recovered.is_none());
}
#[test]
fn commit_then_open_recovers_bytes_and_cursor() {
let dir = PathBuf::from("/store");
let (mut store, _) =
SnapshotStore::open(config(dir.clone(), 1), CrashVfs::new()).unwrap();
let cursor = Some(Position::new(10, 2));
store.commit(b"snapshot-bytes", cursor).unwrap();
let vfs = store.into_vfs();
let (_, recovered) = SnapshotStore::open(config(dir, 1), vfs).unwrap();
let recovered = recovered.unwrap();
assert_eq!(recovered.snapshot.as_slice(), b"snapshot-bytes".as_slice());
assert_eq!(recovered.cursor, cursor);
}
#[test]
fn second_commit_supersedes_first() {
let dir = PathBuf::from("/store");
let (mut store, _) =
SnapshotStore::open(config(dir.clone(), 1), CrashVfs::new()).unwrap();
store.commit(b"first", Some(Position::new(1, 0))).unwrap();
store.commit(b"second", Some(Position::new(2, 0))).unwrap();
let durable = store.durable_cursor();
let vfs = store.into_vfs();
let (_, recovered) = SnapshotStore::open(config(dir, 1), vfs).unwrap();
let recovered = recovered.unwrap();
assert_eq!(recovered.snapshot.as_slice(), b"second".as_slice());
assert_eq!(durable, Some(Position::new(2, 0)));
}
#[test]
fn crash_at_every_op_of_a_commit_recovers_a_valid_state() {
let dir = PathBuf::from("/store");
let torn_len = 8;
let (mut measure_first, _) =
SnapshotStore::open(config(dir.clone(), 1), CrashVfs::new()).unwrap();
measure_first
.commit(b"first", Some(Position::new(1, 0)))
.unwrap();
let after_first = measure_first.into_vfs().op_count();
let (mut measure_second, _) =
SnapshotStore::open(config(dir.clone(), 1), CrashVfs::new()).unwrap();
measure_second
.commit(b"first", Some(Position::new(1, 0)))
.unwrap();
measure_second
.commit(b"second", Some(Position::new(2, 0)))
.unwrap();
let after_second = measure_second.into_vfs().op_count();
let second_commit_ops = after_second - after_first;
for budget in 0..=second_commit_ops {
let vfs = CrashVfs::with_crash_budget(after_first + budget, torn_len);
let (mut store, _) =
SnapshotStore::open(config(dir.clone(), 1), vfs).unwrap();
store.commit(b"first", Some(Position::new(1, 0))).unwrap();
let commit_result = store.commit(b"second", Some(Position::new(2, 0)));
let mut vfs = store.into_vfs();
vfs.crash();
let (_, recovered) =
SnapshotStore::open(config(dir.clone(), 1), vfs).unwrap();
let recovered = recovered
.unwrap_or_else(|| panic!("budget {budget} lost every snapshot"));
if commit_result.is_err() {
assert_eq!(
recovered.snapshot.as_slice(),
b"first".as_slice(),
"budget {budget} should still recover the first snapshot"
);
} else {
assert_eq!(
recovered.snapshot.as_slice(),
b"second".as_slice(),
"budget {budget} completed the commit and should recover the second"
);
}
}
}
#[test]
fn torn_manifest_slot_falls_back_to_valid_slot() {
let dir = PathBuf::from("/store");
let (mut baseline, _) =
SnapshotStore::open(config(dir.clone(), 1), CrashVfs::new()).unwrap();
baseline
.commit(b"first", Some(Position::new(1, 0)))
.unwrap();
let after_first = baseline.into_vfs().op_count();
let vfs = CrashVfs::with_crash_budget(after_first + 4, 8);
let (mut store, _) = SnapshotStore::open(config(dir.clone(), 1), vfs).unwrap();
store.commit(b"first", Some(Position::new(1, 0))).unwrap();
let result = store.commit(b"second", Some(Position::new(2, 0)));
let mut vfs = store.into_vfs();
vfs.crash();
let (_, recovered) = SnapshotStore::open(config(dir, 1), vfs).unwrap();
assert!(result.is_err());
assert_eq!(recovered.unwrap().snapshot.as_slice(), b"first".as_slice());
}
#[test]
fn fresh_store_reopens_without_commit() {
let dir = PathBuf::from("/store");
let (store, _) =
SnapshotStore::open(config(dir.clone(), 1), CrashVfs::new()).unwrap();
let vfs = store.into_vfs();
let (_, recovered) = SnapshotStore::open(config(dir, 1), vfs).unwrap();
assert!(recovered.is_none());
}
#[test]
fn real_fs_fresh_store_reopens_without_commit() {
let dir = tempfile::tempdir().unwrap();
let cfg = config(dir.path().to_path_buf(), 1);
let (store, _) = SnapshotStore::open(cfg.clone(), RealVfs).unwrap();
let vfs = store.into_vfs();
let (_, recovered) = SnapshotStore::open(cfg, vfs).unwrap();
assert!(recovered.is_none());
}
#[test]
fn crash_inside_pruning_still_opens() {
let dir = PathBuf::from("/store");
let setup_ops = {
let (mut store, _) =
SnapshotStore::open(config(dir.clone(), 4), CrashVfs::new()).unwrap();
for version in 1..=4u64 {
store
.commit(b"snapshot", Some(Position::new(version, 0)))
.unwrap();
}
store.into_vfs().op_count()
};
let vfs = CrashVfs::with_crash_budget(setup_ops, 0);
let (mut store, _) = SnapshotStore::open(config(dir.clone(), 4), vfs).unwrap();
for version in 1..=4u64 {
store
.commit(b"snapshot", Some(Position::new(version, 0)))
.unwrap();
}
let vfs = store.into_vfs();
let (store, recovered) = SnapshotStore::open(config(dir, 1), vfs).unwrap();
assert_eq!(recovered.unwrap().cursor, Some(Position::new(4, 0)));
assert_eq!(store.durable_cursor(), Some(Position::new(4, 0)));
}
#[test]
fn corrupt_both_slots_is_typed() {
let dir = PathBuf::from("/store");
let mut vfs = CrashVfs::new();
vfs.create_dir_all(&dir).unwrap();
let manifest_path = dir.join(MANIFEST_FILE);
let zeroed = vec![0xABu8; SLOT_SIZE * 2];
vfs.write(&manifest_path, &zeroed).unwrap();
vfs.fsync_file(&manifest_path).unwrap();
vfs.fsync_dir(&dir).unwrap();
let result = SnapshotStore::open(config(dir, 1), vfs);
assert!(matches!(result, Err(StoreError::CorruptManifest)));
}
#[test]
fn dangling_manifest_pointer_is_typed() {
let dir = PathBuf::from("/store");
let (mut store, _) =
SnapshotStore::open(config(dir.clone(), 1), CrashVfs::new()).unwrap();
store.commit(b"first", Some(Position::new(1, 0))).unwrap();
let mut vfs = store.into_vfs();
vfs.remove(&dir.join(snapshot_file_name(1))).unwrap();
let result = SnapshotStore::open(config(dir, 1), vfs);
assert!(matches!(result, Err(StoreError::MissingSnapshot { id: 1 })));
}
#[test]
fn orphan_temp_and_unreferenced_snapshots_are_pruned() {
let dir = PathBuf::from("/store");
let mut vfs = CrashVfs::new();
vfs.create_dir_all(&dir).unwrap();
for id in 1..=4u64 {
let path = dir.join(snapshot_file_name(id));
vfs.write(&path, format!("snap-{id}").as_bytes()).unwrap();
vfs.fsync_file(&path).unwrap();
}
let temp_path = dir.join(TEMP_FILE);
vfs.write(&temp_path, b"leftover").unwrap();
vfs.fsync_file(&temp_path).unwrap();
vfs.fsync_dir(&dir).unwrap();
let record = SlotRecord {
version: 1,
cursor: None,
snapshot_id: 4,
};
let manifest_path = dir.join(MANIFEST_FILE);
let mut manifest_bytes = vec![0u8; SLOT_SIZE * 2];
manifest_bytes[..SLOT_SIZE].copy_from_slice(&encode_slot(&record));
vfs.write(&manifest_path, &manifest_bytes).unwrap();
vfs.fsync_file(&manifest_path).unwrap();
vfs.fsync_dir(&dir).unwrap();
let (store, recovered) =
SnapshotStore::open(config(dir.clone(), 1), vfs).unwrap();
assert_eq!(recovered.unwrap().snapshot.as_slice(), b"snap-4".as_slice());
let mut vfs = store.into_vfs();
let remaining = vfs.list(&dir).unwrap();
assert!(!remaining.contains(&temp_path));
assert!(remaining.contains(&dir.join(snapshot_file_name(4))));
assert!(remaining.contains(&dir.join(snapshot_file_name(3))));
assert!(!remaining.contains(&dir.join(snapshot_file_name(2))));
assert!(!remaining.contains(&dir.join(snapshot_file_name(1))));
}
#[test]
fn real_fs_commit_reopen_smoke() {
let dir = tempfile::tempdir().unwrap();
let cfg = config(dir.path().to_path_buf(), 1);
let (mut store, _) = SnapshotStore::open(cfg.clone(), RealVfs).unwrap();
store
.commit(b"real-bytes", Some(Position::new(3, 1)))
.unwrap();
let vfs = store.into_vfs();
let (_, recovered) = SnapshotStore::open(cfg, vfs).unwrap();
let recovered = recovered.unwrap();
assert_eq!(recovered.snapshot.as_slice(), b"real-bytes".as_slice());
assert_eq!(recovered.cursor, Some(Position::new(3, 1)));
}
}