use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const MAX_CHECKPOINTS: usize = 100;
pub const MAX_SNAPSHOT_BYTES: u64 = 16 * 1024 * 1024;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CheckpointFile {
pub path: String,
pub existed: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Checkpoint {
pub entry_id: String,
pub created_at: i64,
pub files: Vec<CheckpointFile>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RewindResult {
pub restored: Vec<String>,
pub deleted: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub failed: Vec<RewindFailure>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
pub cancelled: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RewindFailure {
pub path: String,
pub error: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
enum Blob {
Absent,
File {
name: String,
},
#[serde(skip)]
Memory(Vec<u8>),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Stored {
entry_id: String,
created_at: i64,
files: Vec<(String, Blob)>,
}
#[derive(Debug, Default)]
pub(crate) struct CheckpointStore {
dir: Option<PathBuf>,
loaded: bool,
list: Vec<Stored>,
next_blob: u64,
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_millis()).ok())
.unwrap_or_default()
}
impl CheckpointStore {
pub(crate) fn bind(&mut self, dir: Option<PathBuf>) {
if self.loaded && self.dir == dir {
return;
}
self.dir = dir;
self.loaded = true;
self.list.clear();
let Some(dir) = &self.dir else {
return;
};
let index = std::fs::read(dir.join("index.json"))
.ok()
.and_then(|bytes| serde_json::from_slice::<Vec<Stored>>(&bytes).ok());
if let Some(index) = index {
self.next_blob = index
.iter()
.flat_map(|c| &c.files)
.filter_map(|(_, blob)| match blob {
Blob::File { name } => name.parse::<u64>().ok(),
_ => None,
})
.max()
.map_or(0, |n| n + 1);
self.list = index;
}
}
fn persist(&self) {
let Some(dir) = &self.dir else {
return;
};
if let Ok(bytes) = serde_json::to_vec(&self.list) {
let _ = std::fs::create_dir_all(dir);
let tmp = dir.join("index.json.tmp");
if std::fs::write(&tmp, bytes).is_ok() {
let _ = std::fs::rename(&tmp, dir.join("index.json"));
}
}
}
pub(crate) fn record(&mut self, entry_id: &str, path: &Path) -> bool {
let key = path.display().to_string();
let index = if let Some(index) = self.list.iter().position(|c| c.entry_id == entry_id) {
index
} else {
self.list.push(Stored {
entry_id: entry_id.to_owned(),
created_at: now_ms(),
files: Vec::new(),
});
self.list.len() - 1
};
if self.list[index].files.iter().any(|(p, _)| *p == key) {
return false;
}
let blob = match std::fs::metadata(path) {
Err(_) => Blob::Absent,
Ok(meta) if !meta.is_file() || meta.len() > MAX_SNAPSHOT_BYTES => return false,
Ok(_) => match std::fs::read(path) {
Err(_) => return false,
Ok(bytes) => self.store_blob(bytes),
},
};
self.list[index].files.push((key, blob));
self.evict();
self.persist();
true
}
fn store_blob(&mut self, bytes: Vec<u8>) -> Blob {
let Some(dir) = &self.dir else {
return Blob::Memory(bytes);
};
let name = self.next_blob.to_string();
self.next_blob += 1;
let blobs = dir.join("blobs");
let _ = std::fs::create_dir_all(&blobs);
match std::fs::write(blobs.join(&name), &bytes) {
Ok(()) => Blob::File { name },
Err(_) => Blob::Memory(bytes),
}
}
fn evict(&mut self) {
while self.list.len() > MAX_CHECKPOINTS {
let old = self.list.remove(0);
self.remove_blobs(&old);
}
}
fn remove_blobs(&self, stored: &Stored) {
let Some(dir) = &self.dir else {
return;
};
for (_, blob) in &stored.files {
if let Blob::File { name } = blob {
let _ = std::fs::remove_file(dir.join("blobs").join(name));
}
}
}
pub(crate) fn list(&self) -> Vec<Checkpoint> {
self.list
.iter()
.map(|c| Checkpoint {
entry_id: c.entry_id.clone(),
created_at: c.created_at,
files: c
.files
.iter()
.map(|(path, blob)| CheckpointFile {
path: path.clone(),
existed: *blob != Blob::Absent,
})
.collect(),
})
.collect()
}
pub(crate) fn contains(&self, entry_id: &str) -> bool {
self.list.iter().any(|c| c.entry_id == entry_id)
}
pub(crate) fn rewind(&mut self, entry_id: &str) -> Option<RewindResult> {
let start = self.list.iter().position(|c| c.entry_id == entry_id)?;
let mut earliest: BTreeMap<String, Blob> = BTreeMap::new();
for stored in &self.list[start..] {
for (path, blob) in &stored.files {
earliest.entry(path.clone()).or_insert_with(|| blob.clone());
}
}
let mut result = RewindResult::default();
for (path, blob) in &earliest {
let outcome = match blob {
Blob::Absent => match std::fs::remove_file(path) {
Ok(()) => {
result.deleted.push(path.clone());
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.to_string()),
},
Blob::Memory(bytes) => write_back(path, bytes).map(|()| {
result.restored.push(path.clone());
}),
Blob::File { name } => self
.dir
.as_ref()
.ok_or_else(|| "checkpoint blob missing".to_owned())
.and_then(|dir| {
std::fs::read(dir.join("blobs").join(name)).map_err(|e| e.to_string())
})
.and_then(|bytes| write_back(path, &bytes))
.map(|()| result.restored.push(path.clone())),
};
if let Err(error) = outcome {
result.failed.push(RewindFailure {
path: path.clone(),
error,
});
}
}
let dropped: Vec<Stored> = self.list.drain(start..).collect();
for stored in &dropped {
self.remove_blobs(stored);
}
self.persist();
Some(result)
}
pub(crate) fn copy_matching(&self, keep: impl Fn(&str) -> bool, target: &mut Self) {
for stored in self.list.iter().filter(|c| keep(&c.entry_id)) {
let mut copy = Stored {
entry_id: stored.entry_id.clone(),
created_at: stored.created_at,
files: Vec::new(),
};
for (path, blob) in &stored.files {
let blob = match blob {
Blob::Absent => Blob::Absent,
Blob::Memory(bytes) => target.store_blob(bytes.clone()),
Blob::File { name } => {
let bytes = self
.dir
.as_ref()
.and_then(|dir| std::fs::read(dir.join("blobs").join(name)).ok());
match bytes {
Some(bytes) => target.store_blob(bytes),
None => continue,
}
}
};
copy.files.push((path.clone(), blob));
}
target.list.push(copy);
}
target.persist();
}
}
fn write_back(path: &str, bytes: &[u8]) -> Result<(), String> {
let path = Path::new(path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
std::fs::write(path, bytes).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn roundtrip(persisted: bool) {
let dir = tempfile::tempdir().unwrap();
let work = dir.path().join("work");
std::fs::create_dir_all(&work).unwrap();
let a = work.join("a.txt");
let b = work.join("b.txt");
std::fs::write(&a, "a0").unwrap();
let mut store = CheckpointStore::default();
store.bind(persisted.then(|| dir.path().join("cp")));
assert!(store.record("u1", &a));
assert!(!store.record("u1", &a), "once per checkpoint");
std::fs::write(&a, "a1").unwrap();
assert!(store.record("u2", &a));
assert!(store.record("u2", &b));
std::fs::write(&a, "a2").unwrap();
std::fs::write(&b, "b2").unwrap();
assert_eq!(store.list().len(), 2);
assert!(!store.list()[1].files[1].existed);
if persisted {
let mut reloaded = CheckpointStore::default();
reloaded.bind(Some(dir.path().join("cp")));
assert_eq!(reloaded.list(), store.list());
store = reloaded;
}
let result = store.rewind("u2").unwrap();
assert_eq!(std::fs::read_to_string(&a).unwrap(), "a1");
assert!(!b.exists());
assert_eq!(result.restored.len(), 1);
assert_eq!(result.deleted.len(), 1);
assert_eq!(store.list().len(), 1);
let result = store.rewind("u1").unwrap();
assert_eq!(std::fs::read_to_string(&a).unwrap(), "a0");
assert_eq!(result.restored, vec![a.display().to_string()]);
assert!(store.rewind("u1").is_none());
}
#[test]
fn rewind_in_memory() {
roundtrip(false);
}
#[test]
fn rewind_persisted() {
roundtrip(true);
}
#[test]
fn copy_kept_entries() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a");
std::fs::write(&a, "x").unwrap();
let mut store = CheckpointStore::default();
store.bind(None);
store.record("u1", &a);
store.record("u2", &a);
let mut target = CheckpointStore::default();
target.bind(None);
store.copy_matching(|id| id == "u1", &mut target);
assert_eq!(target.list().len(), 1);
assert!(target.contains("u1"));
}
}