use std::collections::BTreeSet;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::machine::{Effect, State};
const STATE_FILE: &str = "state.json";
const BUNDLES_DIR: &str = "bundles";
const SEQ_PREFIX: &str = "seq-";
#[derive(Debug)]
pub struct Store {
root: PathBuf,
write_lock: Mutex<()>,
}
impl Store {
pub fn new(root: PathBuf) -> Self {
Self {
root,
write_lock: Mutex::new(()),
}
}
#[cfg(test)]
pub fn root(&self) -> &Path {
&self.root
}
fn state_path(&self) -> PathBuf {
self.root.join(STATE_FILE)
}
pub(crate) fn bundles_dir(&self) -> PathBuf {
self.root.join(BUNDLES_DIR)
}
pub fn bundle_dir(&self, seq: u64) -> PathBuf {
self.bundles_dir().join(format!("{SEQ_PREFIX}{seq}"))
}
pub fn load_state(&self) -> State {
let path = self.state_path();
let bytes = match fs::read(&path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == io::ErrorKind::NotFound => return State::default(),
Err(e) => {
log::warn!(
"hot-update: failed to read {}: {e}; starting fresh",
path.display()
);
return State::default();
}
};
match serde_json::from_slice(&bytes) {
Ok(state) => state,
Err(e) => {
log::warn!(
"hot-update: corrupt state.json ({e}); discarding it and starting fresh"
);
State::default()
}
}
}
pub fn save_state(&self, state: &State) -> io::Result<()> {
fs::create_dir_all(&self.root)?;
let path = self.state_path();
let tmp = self.root.join(format!("{STATE_FILE}.tmp"));
let json = serde_json::to_vec_pretty(state).map_err(io::Error::other)?;
{
let mut file = fs::File::create(&tmp)?;
io::Write::write_all(&mut file, &json)?;
file.sync_all()?;
}
fs::rename(&tmp, &path)
}
pub fn update<T>(&self, transition: impl FnOnce(State) -> (State, T)) -> io::Result<T> {
let _guard = self.write_lock.lock().unwrap_or_else(|e| e.into_inner());
let (state, out) = transition(self.load_state());
self.save_state(&state)?;
Ok(out)
}
pub fn present_seqs(&self) -> BTreeSet<u64> {
let Ok(entries) = fs::read_dir(self.bundles_dir()) else {
return BTreeSet::new();
};
entries
.flatten()
.filter(|entry| entry.path().is_dir())
.filter_map(|entry| parse_seq(&entry.file_name().to_string_lossy()))
.collect()
}
pub fn foreign_entries(&self) -> Vec<PathBuf> {
let Ok(entries) = fs::read_dir(self.bundles_dir()) else {
return Vec::new();
};
entries
.flatten()
.filter(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
parse_seq(&name).is_none() || !entry.path().is_dir()
})
.map(|entry| entry.path())
.collect()
}
pub fn allocate_seq(&self, state: &State) -> u64 {
let disk_max = self.present_seqs().into_iter().max().unwrap_or(0);
let state_max = [
state.committed,
state.last_good,
state.staged,
state.booting,
]
.into_iter()
.flatten()
.chain(state.versions.keys().copied())
.max()
.unwrap_or(0);
disk_max.max(state_max) + 1
}
pub fn apply_effects(&self, effects: &[Effect]) {
for effect in effects {
match effect {
Effect::DeleteBundle(seq) => self.delete_path(&self.bundle_dir(*seq)),
}
}
}
pub fn sweep_foreign_entries(&self) {
for path in self.foreign_entries() {
self.delete_path(&path);
}
}
fn delete_path(&self, path: &Path) {
let result = if path.is_dir() {
fs::remove_dir_all(path)
} else {
fs::remove_file(path)
};
match result {
Ok(()) => log::debug!("hot-update: removed {}", path.display()),
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => log::warn!(
"hot-update: failed to remove {} ({e}); leaving it for next boot's GC",
path.display()
),
}
}
}
fn parse_seq(name: &str) -> Option<u64> {
name.strip_prefix(SEQ_PREFIX)?.parse().ok()
}
#[cfg(test)]
mod tests;