use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use crate::error::Result;
use crate::fs::{Durability, Storage, parent_dir};
use crate::path::guard_in_root;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BatchOp {
Write {
path: PathBuf,
bytes: Vec<u8>,
},
CreateNew {
path: PathBuf,
bytes: Vec<u8>,
},
}
impl BatchOp {
pub fn path(&self) -> &Path {
match self {
BatchOp::Write { path, .. } | BatchOp::CreateNew { path, .. } => path,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct OrderedBatch {
tiers: Vec<Vec<BatchOp>>,
}
impl OrderedBatch {
pub fn new() -> Self {
Self::default()
}
pub fn write(&mut self, path: impl Into<PathBuf>, contents: impl Into<Vec<u8>>) -> &mut Self {
self.stage(BatchOp::Write {
path: path.into(),
bytes: contents.into(),
})
}
pub fn create_new(
&mut self,
path: impl Into<PathBuf>,
contents: impl Into<Vec<u8>>,
) -> &mut Self {
self.stage(BatchOp::CreateNew {
path: path.into(),
bytes: contents.into(),
})
}
pub fn barrier(&mut self) -> &mut Self {
if !self.tiers.last().is_none_or(Vec::is_empty) {
self.tiers.push(Vec::new());
}
self
}
pub fn tiers(&self) -> &[Vec<BatchOp>] {
match self.tiers.split_last() {
Some((last, rest)) if last.is_empty() => rest,
_ => &self.tiers,
}
}
pub fn is_empty(&self) -> bool {
self.tiers.iter().all(Vec::is_empty)
}
pub fn len(&self) -> usize {
self.tiers.iter().map(Vec::len).sum()
}
fn stage(&mut self, op: BatchOp) -> &mut Self {
match self.tiers.last_mut() {
Some(tier) => tier.push(op),
None => self.tiers.push(vec![op]),
}
self
}
pub async fn apply<FS: Storage>(
&self,
fs: &FS,
root: &Path,
finality: Durability,
) -> Result<()> {
for op in self.tiers.iter().flatten() {
guard_in_root(op.path())?;
}
let tiers: Vec<&Vec<BatchOp>> = self.tiers.iter().filter(|t| !t.is_empty()).collect();
let Some((last, earlier)) = tiers.split_last() else {
return Ok(());
};
for tier in earlier {
apply_tier(fs, root, tier, Durability::Ordered).await?;
}
apply_tier(fs, root, last, finality).await
}
}
async fn apply_tier<FS: Storage>(
fs: &FS,
root: &Path,
tier: &[BatchOp],
need: Durability,
) -> Result<()> {
let atomic_replace = fs.capabilities().atomic_replace;
let mut dirs: BTreeSet<PathBuf> = BTreeSet::new();
let mut flush: Vec<PathBuf> = Vec::new();
for op in tier {
let full = root.join(op.path());
if let Some(dir) = parent_dir(&full) {
dirs.insert(dir.to_path_buf());
for made in crate::fs::create_dir_all_traced(fs, dir).await? {
dirs.insert(made);
}
}
match op {
BatchOp::Write { bytes, .. } => {
fs.replace(&full, bytes).await?;
if !atomic_replace {
flush.push(full);
}
}
BatchOp::CreateNew { bytes, .. } => {
fs.create_new(&full, bytes).await?;
flush.push(full);
}
}
}
let debts = flush.into_iter().chain(dirs);
match need {
Durability::Ordered => {
for path in debts {
fs.sync(&path, Durability::Ordered).await?;
}
Ok(())
}
Durability::Durable => Ok(crate::fs::flush_all_durable(fs, debts, root).await?),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
use crate::exec::block_on;
use crate::fs::{InMemoryFs, ReadStorage, StdFs};
use crate::fs_faults::{FailAtWrite, FsEvent, RecordingFs};
fn tmp(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("fstx-ordered-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn read(root: &Path, rel: &str) -> Option<String> {
std::fs::read_to_string(root.join(rel)).ok()
}
#[test]
fn lands_every_tier_in_order() {
let root = tmp("apply");
let mut batch = OrderedBatch::new();
batch.create_new("blobs/payload", "bytes");
batch.barrier();
batch.create_new("revisions/rev", "names the payload");
batch.write("bookmark", "points at the revision");
block_on(batch.apply(&StdFs, &root, Durability::Durable)).unwrap();
assert_eq!(read(&root, "blobs/payload").as_deref(), Some("bytes"));
assert_eq!(
read(&root, "revisions/rev").as_deref(),
Some("names the payload")
);
assert_eq!(
read(&root, "bookmark").as_deref(),
Some("points at the revision")
);
}
#[test]
fn flushes_each_tier_to_the_barrier_and_the_last_to_finality() {
let root = tmp("events");
let fs = RecordingFs::local();
let mut batch = OrderedBatch::new();
batch.create_new("blobs/a", "a");
batch.barrier();
batch.create_new("rev", "names a");
block_on(batch.apply(&fs, &root, Durability::Durable)).unwrap();
assert_eq!(
fs.events(),
vec![
FsEvent::CreateNew(root.join("blobs/a")),
FsEvent::Sync(root.join("blobs/a"), Durability::Ordered),
FsEvent::Sync(root.clone(), Durability::Ordered),
FsEvent::Sync(root.join("blobs"), Durability::Ordered),
FsEvent::CreateNew(root.join("rev")),
FsEvent::Sync(root.join("rev"), Durability::Ordered),
FsEvent::Sync(root.clone(), Durability::Durable),
]
);
}
#[test]
fn a_freshly_minted_directory_chain_is_flushed_link_by_link() {
let root = tmp("chain");
let fs = RecordingFs::local();
let mut batch = OrderedBatch::new();
batch.create_new("a/b/c/blob", "bytes");
block_on(batch.apply(&fs, &root, Durability::Durable)).unwrap();
for dir in [
root.clone(),
root.join("a"),
root.join("a/b"),
root.join("a/b/c"),
] {
assert!(
fs.events()
.iter()
.any(|e| matches!(e, FsEvent::Sync(p, _) if *p == dir)),
"{} never flushed; events: {:?}",
dir.display(),
fs.events()
);
}
let drains: Vec<usize> = fs
.events()
.iter()
.enumerate()
.filter(|(_, e)| matches!(e, FsEvent::Sync(_, Durability::Durable)))
.map(|(i, _)| i)
.collect();
assert_eq!(drains.len(), 1, "events: {:?}", fs.events());
assert_eq!(
drains[0],
fs.events().len() - 1,
"events: {:?}",
fs.events()
);
}
#[test]
fn an_ordered_finality_never_drains_the_device() {
let root = tmp("ordered-finality");
let fs = RecordingFs::local();
let mut batch = OrderedBatch::new();
batch.create_new("blobs/a", "a");
batch.barrier();
batch.create_new("rev", "names a");
batch.write("bookmark", "points at rev");
block_on(batch.apply(&fs, &root, Durability::Ordered)).unwrap();
assert!(
fs.events()
.iter()
.all(|e| !matches!(e, FsEvent::Sync(_, Durability::Durable))),
"events: {:?}",
fs.events()
);
}
#[test]
fn a_replaced_write_lands_through_the_backends_replace() {
let root = tmp("write-durable");
std::fs::write(root.join("bookmark"), "old").unwrap();
let fs = RecordingFs::local();
let mut batch = OrderedBatch::new();
batch.write("bookmark", "new");
block_on(batch.apply(&fs, &root, Durability::Durable)).unwrap();
let tmp_name = crate::fs::temp_sibling(&root.join("bookmark"));
assert_eq!(
fs.events(),
vec![
FsEvent::Write(tmp_name.clone()),
FsEvent::Sync(tmp_name.clone(), Durability::Ordered),
FsEvent::Rename(tmp_name, root.join("bookmark")),
FsEvent::Sync(root.clone(), Durability::Durable),
]
);
assert_eq!(read(&root, "bookmark").as_deref(), Some("new"));
}
#[test]
fn a_replaced_write_respects_a_backends_native_atomic_replace() {
let fs = InMemoryFs::new();
block_on(fs.write(Path::new("store/bookmark"), b"old")).unwrap();
let mut batch = OrderedBatch::new();
batch.write("bookmark", "new");
block_on(batch.apply(&fs, Path::new("store"), Durability::Durable)).unwrap();
assert_eq!(
block_on(fs.read_to_string(Path::new("store/bookmark"))).unwrap(),
"new"
);
}
#[test]
fn an_error_leaves_a_consistent_prefix() {
let root = tmp("prefix");
let mut batch = OrderedBatch::new();
batch.create_new("blobs/a", "a");
batch.barrier();
batch.create_new("rev", "never lands");
batch.create_new("after", "never reached");
let err =
block_on(batch.apply(&FailAtWrite::nth(1), &root, Durability::Durable)).unwrap_err();
assert!(err.to_string().contains("disk full"), "{err}");
assert_eq!(read(&root, "blobs/a").as_deref(), Some("a"));
assert_eq!(read(&root, "rev"), None);
assert_eq!(read(&root, "after"), None);
assert!(
!crate::journal::Journal::default().path_in(&root).exists(),
"an ordered batch must never plant a journal"
);
}
#[test]
fn an_occupied_create_surfaces_already_exists_and_stops() {
let root = tmp("occupied");
std::fs::create_dir_all(root.join("blobs")).unwrap();
std::fs::write(root.join("blobs/a"), "already here").unwrap();
let mut batch = OrderedBatch::new();
batch.create_new("blobs/a", "different bytes");
batch.barrier();
batch.create_new("rev", "never lands");
let err = block_on(batch.apply(&StdFs, &root, Durability::Durable)).unwrap_err();
match err {
Error::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists),
other => panic!("expected Io(AlreadyExists), got {other:?}"),
}
assert_eq!(read(&root, "blobs/a").as_deref(), Some("already here"));
assert_eq!(read(&root, "rev"), None);
}
#[test]
fn a_path_escaping_the_root_is_refused_before_anything_lands() {
let root = tmp("escape");
let mut batch = OrderedBatch::new();
batch.create_new("fine", "fine");
batch.barrier();
batch.write("../outside", "never");
let err = block_on(batch.apply(&StdFs, &root, Durability::Durable)).unwrap_err();
assert!(matches!(err, Error::Escape(_)), "{err:?}");
assert_eq!(
read(&root, "fine"),
None,
"the guard must run before the first tier, not between tiers"
);
}
#[test]
fn barriers_at_the_edges_and_doubled_up_cost_nothing() {
let mut batch = OrderedBatch::new();
batch.barrier();
batch.create_new("a", "a");
batch.barrier();
batch.barrier();
batch.create_new("b", "b");
batch.barrier();
assert_eq!(batch.tiers().len(), 2);
assert_eq!(batch.len(), 2);
let fs = InMemoryFs::new();
block_on(batch.apply(&fs, Path::new("root"), Durability::Durable)).unwrap();
assert_eq!(
block_on(fs.read_to_string(Path::new("root/a"))).unwrap(),
"a"
);
assert_eq!(
block_on(fs.read_to_string(Path::new("root/b"))).unwrap(),
"b"
);
}
#[test]
fn an_empty_batch_applies_as_nothing() {
let fs = RecordingFs::local();
let batch = OrderedBatch::new();
assert!(batch.is_empty());
block_on(batch.apply(&fs, Path::new("/nonexistent"), Durability::Durable)).unwrap();
assert!(fs.events().is_empty());
}
#[test]
fn works_over_a_backend_that_cannot_flush_at_all() {
let fs = InMemoryFs::new();
let mut batch = OrderedBatch::new();
batch.create_new("blobs/a", "a");
batch.barrier();
batch.create_new("rev", "names a");
block_on(batch.apply(&fs, Path::new("store"), Durability::Durable)).unwrap();
assert_eq!(
block_on(fs.read_to_string(Path::new("store/rev"))).unwrap(),
"names a"
);
}
}