use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use crate::error::{BusError, Result};
const RESERVATION_BLOCK: u64 = 1024;
pub trait SequenceReservations: Send + Sync {
fn reserved(&self) -> Result<u64>;
fn reserve(&self, through: u64) -> Result<()>;
}
#[cfg(unix)]
fn parent_dir(path: &std::path::Path) -> &std::path::Path {
match path.parent() {
Some(p) if !p.as_os_str().is_empty() => p,
_ => std::path::Path::new("."),
}
}
type DirSync = Arc<dyn Fn(&std::path::Path) -> std::io::Result<()> + Send + Sync>;
#[cfg(unix)]
fn sync_parent_dir(path: &std::path::Path) -> std::io::Result<()> {
std::fs::File::open(parent_dir(path))?.sync_all()
}
#[cfg(unix)]
fn reserve_unix(
path: &std::path::Path,
through: u64,
dir_sync: &DirSync,
before_rename: impl FnOnce(),
) -> Result<()> {
use std::io::Write as _;
let failed =
|e: std::io::Error| BusError::SequenceReservation(format!("{}: {e}", path.display()));
let dir = parent_dir(path);
let mut staged = tempfile::NamedTempFile::new_in(dir).map_err(failed)?;
staged
.write_all(through.to_string().as_bytes())
.map_err(failed)?;
staged.as_file().sync_all().map_err(failed)?;
before_rename();
staged.persist(path).map_err(|e| failed(e.error))?;
dir_sync(path).map_err(failed)
}
#[derive(Clone)]
pub struct FileSequenceReservations {
path: PathBuf,
#[cfg_attr(not(unix), allow(dead_code))]
dir_sync: DirSync,
}
impl std::fmt::Debug for FileSequenceReservations {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FileSequenceReservations")
.field("path", &self.path)
.finish_non_exhaustive()
}
}
impl FileSequenceReservations {
#[cfg(unix)]
#[must_use]
pub fn new(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
dir_sync: Arc::new(sync_parent_dir),
}
}
#[cfg(not(unix))]
#[must_use]
pub fn new(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
dir_sync: Arc::new(|_dir: &std::path::Path| Ok(())),
}
}
#[cfg(all(test, unix))]
fn with_dir_sync(path: impl Into<PathBuf>, dir_sync: DirSync) -> Self {
Self {
path: path.into(),
dir_sync,
}
}
}
impl SequenceReservations for FileSequenceReservations {
fn reserved(&self) -> Result<u64> {
match std::fs::read_to_string(&self.path) {
Ok(text) => text.trim().parse().map_err(|e| {
BusError::SequenceReservation(format!("{}: {e}", self.path.display()))
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
Err(e) => Err(BusError::SequenceReservation(format!(
"{}: {e}",
self.path.display()
))),
}
}
#[cfg(not(unix))]
fn reserve(&self, _through: u64) -> Result<()> {
Err(BusError::SequenceReservation(format!(
"{}: durable file reservations are Unix-only; this platform has no \
tested atomic-rename + directory-sync durability guarantee \
(agent-mesh#101 review round 2)",
self.path.display()
)))
}
#[cfg(unix)]
fn reserve(&self, through: u64) -> Result<()> {
reserve_unix(&self.path, through, &self.dir_sync, || {})
}
}
pub(crate) struct Sequencer {
state: Mutex<Issued>,
reservations: Option<Arc<dyn SequenceReservations>>,
}
struct Issued {
last: u64,
reserved: u64,
}
impl Sequencer {
pub(crate) fn in_memory() -> Self {
Self::after(0)
}
pub(crate) fn after(last: u64) -> Self {
Self {
state: Mutex::new(Issued {
last,
reserved: u64::MAX,
}),
reservations: None,
}
}
pub(crate) fn reserving(reservations: Arc<dyn SequenceReservations>) -> Result<Self> {
let last = reservations.reserved()?;
Ok(Self {
state: Mutex::new(Issued {
last,
reserved: last,
}),
reservations: Some(reservations),
})
}
pub(crate) fn next(&self) -> Result<u64> {
let mut issued = self.state.lock().expect("sequencer poisoned");
let next = issued
.last
.checked_add(1)
.ok_or_else(|| BusError::SequenceReservation("sequences exhausted".into()))?;
if next > issued.reserved {
let through = issued.reserved.saturating_add(RESERVATION_BLOCK);
if let Some(reservations) = &self.reservations {
reservations.reserve(through)?;
}
issued.reserved = through;
}
issued.last = next;
Ok(next)
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
#[derive(Default)]
pub(crate) struct Kept {
pub(crate) through: Mutex<u64>,
pub(crate) failing: std::sync::atomic::AtomicBool,
}
impl SequenceReservations for Kept {
fn reserved(&self) -> Result<u64> {
Ok(*self.through.lock().unwrap())
}
fn reserve(&self, through: u64) -> Result<()> {
if self.failing.load(std::sync::atomic::Ordering::SeqCst) {
return Err(BusError::SequenceReservation("disk full".into()));
}
*self.through.lock().unwrap() = through;
Ok(())
}
}
#[test]
fn a_successor_starts_above_everything_its_predecessor_could_issue() {
let kept = Arc::new(Kept::default());
let first = Sequencer::reserving(kept.clone()).unwrap();
let issued: Vec<u64> = (0..3).map(|_| first.next().unwrap()).collect();
assert_eq!(issued, [1, 2, 3]);
assert_eq!(
kept.reserved().unwrap(),
RESERVATION_BLOCK,
"reserved first"
);
drop(first);
let second = Sequencer::reserving(kept.clone()).unwrap();
assert_eq!(second.next().unwrap(), RESERVATION_BLOCK + 1);
for _ in 1..RESERVATION_BLOCK {
second.next().unwrap();
}
assert_eq!(
kept.reserved().unwrap(),
2 * RESERVATION_BLOCK,
"one write per block"
);
assert_eq!(second.next().unwrap(), 2 * RESERVATION_BLOCK + 1);
assert_eq!(kept.reserved().unwrap(), 3 * RESERVATION_BLOCK);
}
#[test]
fn an_unreserved_sequence_is_never_issued() {
let kept = Arc::new(Kept::default());
kept.failing
.store(true, std::sync::atomic::Ordering::SeqCst);
let sequencer = Sequencer::reserving(kept.clone()).unwrap();
assert!(sequencer.next().is_err());
kept.failing
.store(false, std::sync::atomic::Ordering::SeqCst);
assert_eq!(sequencer.next().unwrap(), 1, "nothing was consumed");
}
#[test]
#[cfg(unix)]
fn file_reservations_round_trip_and_read_zero_when_absent() {
let dir = tempfile::tempdir().unwrap();
let file = FileSequenceReservations::new(dir.path().join("agent.seq"));
assert_eq!(file.reserved().unwrap(), 0);
file.reserve(2048).unwrap();
assert_eq!(file.reserved().unwrap(), 2048);
assert_eq!(
FileSequenceReservations::new(dir.path().join("agent.seq"))
.reserved()
.unwrap(),
2048,
"a successor reads the same reservation"
);
std::fs::write(dir.path().join("agent.seq"), "garbage").unwrap();
assert!(
file.reserved().is_err(),
"an unreadable reservation fails closed"
);
}
#[test]
#[cfg(unix)]
fn distinct_destinations_sharing_a_stem_never_share_a_staging_file() {
let dir = tempfile::tempdir().unwrap();
let a = FileSequenceReservations::new(dir.path().join("agent.a"));
let b = FileSequenceReservations::new(dir.path().join("agent.b"));
a.reserve(2048).unwrap();
b.reserve(1024).unwrap();
assert_eq!(a.reserved().unwrap(), 2048, "a's own value survives b");
assert_eq!(b.reserved().unwrap(), 1024);
assert!(
!dir.path().join("agent.reserving").exists(),
"no fixed-suffix staging file is left behind"
);
}
#[test]
#[cfg(unix)]
fn distinct_same_stem_stores_forced_to_overlap_staging_still_land_independently() {
let dir = tempfile::tempdir().unwrap();
let path_a = dir.path().join("agent.a");
let path_b = dir.path().join("agent.b");
let (paused_tx, paused_rx) = std::sync::mpsc::channel::<()>();
let (resume_tx, resume_rx) = std::sync::mpsc::channel::<()>();
let dir_sync_a: DirSync = Arc::new(sync_parent_dir);
let path_a_for_thread = path_a.clone();
let handle_a = std::thread::spawn(move || {
reserve_unix(&path_a_for_thread, 2048, &dir_sync_a, || {
paused_tx.send(()).unwrap();
resume_rx.recv().unwrap();
})
});
paused_rx.recv().unwrap(); FileSequenceReservations::new(path_b.clone())
.reserve(1024)
.unwrap(); resume_tx.send(()).unwrap();
handle_a.join().unwrap().unwrap();
assert_eq!(
std::fs::read_to_string(&path_a).unwrap(),
"2048",
"a's own value, not clobbered by b's overlapping staging"
);
assert_eq!(std::fs::read_to_string(&path_b).unwrap(), "1024");
}
#[test]
#[cfg(unix)]
fn a_destination_already_named_reserving_is_not_truncated_in_place() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("agent.reserving");
let file = FileSequenceReservations::new(path.clone());
file.reserve(2048).unwrap();
assert_eq!(file.reserved().unwrap(), 2048);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "2048");
}
#[test]
#[cfg(unix)]
fn a_paused_staging_never_exposes_a_partial_or_missing_destination() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("agent.reserving");
FileSequenceReservations::new(path.clone())
.reserve(2048)
.unwrap();
let (paused_tx, paused_rx) = std::sync::mpsc::channel::<()>();
let (resume_tx, resume_rx) = std::sync::mpsc::channel::<()>();
let dir_sync: DirSync = Arc::new(sync_parent_dir);
let path_for_thread = path.clone();
let handle = std::thread::spawn(move || {
reserve_unix(&path_for_thread, 4096, &dir_sync, || {
paused_tx.send(()).unwrap();
resume_rx.recv().unwrap();
})
});
paused_rx.recv().unwrap(); assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"2048",
"old value survives while staging is paused before rename"
);
resume_tx.send(()).unwrap();
handle.join().unwrap().unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "4096");
}
#[test]
#[cfg(unix)]
fn concurrent_reservations_on_distinct_destinations_never_cross_contaminate() {
let dir = tempfile::tempdir().unwrap();
let a = Arc::new(FileSequenceReservations::new(dir.path().join("agent.a")));
let b = Arc::new(FileSequenceReservations::new(dir.path().join("agent.b")));
for round in 0..200u64 {
let (av, bv) = (2000 + round, 1000 + round);
let (ac, bc) = (a.clone(), b.clone());
let ta = std::thread::spawn(move || ac.reserve(av).unwrap());
let tb = std::thread::spawn(move || bc.reserve(bv).unwrap());
ta.join().unwrap();
tb.join().unwrap();
assert_eq!(a.reserved().unwrap(), av);
assert_eq!(b.reserved().unwrap(), bv);
}
}
#[test]
#[cfg(unix)]
fn a_directory_sync_failure_fails_reserve_and_next() {
let dir = tempfile::tempdir().unwrap();
let store = Arc::new(FileSequenceReservations::with_dir_sync(
dir.path().join("agent.seq"),
Arc::new(|_path| Err(std::io::Error::other("dir sync failed"))),
));
assert!(store.reserve(1024).is_err());
let sequencer = Sequencer::reserving(store).unwrap();
assert!(
sequencer.next().is_err(),
"next must not issue from a block reserve() couldn't durably confirm"
);
}
#[test]
#[cfg(unix)]
fn directory_sync_observes_the_rename_already_in_place() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("agent.seq");
let observed = Arc::new(Mutex::new(None));
let observed_in_hook = observed.clone();
let hook_path = path.clone();
let store = FileSequenceReservations::with_dir_sync(
path,
Arc::new(move |_dir_target| {
*observed_in_hook.lock().unwrap() =
Some(std::fs::read_to_string(&hook_path).unwrap());
Ok(())
}),
);
store.reserve(4096).unwrap();
assert_eq!(observed.lock().unwrap().as_deref(), Some("4096"));
}
#[test]
#[cfg(not(unix))]
fn reserve_fails_closed_on_unsupported_platforms() {
let dir = tempfile::tempdir().unwrap();
let store = Arc::new(FileSequenceReservations::new(dir.path().join("agent.seq")));
assert!(store.reserve(1024).is_err());
let sequencer = Sequencer::reserving(store).unwrap();
assert!(sequencer.next().is_err());
}
}