use std::io::Write;
use std::path::{Path, PathBuf};
use crate::error::CliError;
use crate::home;
const FNV_OFFSET_LOW: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_OFFSET_HIGH: u64 = 0x8422_2325_cbf2_9ce4;
const FNV_PRIME: u64 = 0x100_0000_01b3;
fn fnv1a64(seed: u64, bytes: &[u8]) -> u64 {
let mut h = seed;
for &b in bytes {
h ^= u64::from(b);
h = h.wrapping_mul(FNV_PRIME);
}
h
}
fn key_hash(repo: Option<&str>, branch: Option<&str>, key: &str) -> String {
let mut low = FNV_OFFSET_LOW;
let mut high = FNV_OFFSET_HIGH;
for part in [repo.unwrap_or(""), branch.unwrap_or(""), key] {
let len = (part.len() as u64).to_le_bytes();
low = fnv1a64(low, &len);
high = fnv1a64(high, &len);
low = fnv1a64(low, part.as_bytes());
high = fnv1a64(high, part.as_bytes());
}
format!("{high:016x}{low:016x}")
}
fn file_path_in(root: &Path, repo: Option<&str>, branch: Option<&str>, key: &str) -> PathBuf {
root.join("idempotency").join(key_hash(repo, branch, key))
}
pub fn lookup(
repo: Option<&str>,
branch: Option<&str>,
key: &str,
) -> Result<Option<String>, CliError> {
let root = home::root_dir()?;
lookup_in(&root, repo, branch, key)
}
fn lookup_in(
root: &Path,
repo: Option<&str>,
branch: Option<&str>,
key: &str,
) -> Result<Option<String>, CliError> {
let p = file_path_in(root, repo, branch, key);
match std::fs::read_to_string(&p) {
Ok(s) => Ok(Some(s.trim().to_string())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(CliError::system(
"io_error",
format!("read {}: {}", p.display(), e),
)),
}
}
#[must_use = "a Reservation must be inspected: ignoring AlreadyReserved would duplicate the run"]
pub enum Reservation {
Reserved,
AlreadyReserved(String),
}
struct TempFileGuard(PathBuf);
impl Drop for TempFileGuard {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
fn fsync_dir(dir: &Path) {
if let Ok(f) = std::fs::File::open(dir) {
let _ = f.sync_all();
}
}
pub fn reserve(
repo: Option<&str>,
branch: Option<&str>,
key: &str,
run_id: &str,
) -> Result<Reservation, CliError> {
let root = home::root_dir()?;
reserve_in(&root, repo, branch, key, run_id)
}
fn reserve_in(
root: &Path,
repo: Option<&str>,
branch: Option<&str>,
key: &str,
run_id: &str,
) -> Result<Reservation, CliError> {
let parent = root.join("idempotency");
let p = parent.join(key_hash(repo, branch, key));
std::fs::create_dir_all(&parent)
.map_err(|e| CliError::system("io_error", format!("mkdir {}: {}", parent.display(), e)))?;
let tmp = parent.join(format!(".tmp-{run_id}"));
let _tmp_guard = TempFileGuard(tmp.clone());
{
let mut f = std::fs::File::create(&tmp).map_err(|e| {
CliError::system("io_error", format!("create {}: {}", tmp.display(), e))
})?;
f.write_all(run_id.as_bytes())
.map_err(|e| CliError::system("io_error", format!("write {}: {}", tmp.display(), e)))?;
f.sync_all()
.map_err(|e| CliError::system("io_error", format!("fsync {}: {}", tmp.display(), e)))?;
}
for _ in 0..64 {
match std::fs::hard_link(&tmp, &p) {
Ok(()) => {
fsync_dir(&parent);
return Ok(Reservation::Reserved);
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
match std::fs::read_to_string(&p) {
Ok(existing) => {
return Ok(Reservation::AlreadyReserved(existing.trim().to_string()));
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return Err(CliError::system(
"io_error",
format!("read {}: {}", p.display(), e),
));
}
}
}
Err(e) => {
return Err(CliError::system(
"io_error",
format!("link {} -> {}: {}", tmp.display(), p.display(), e),
));
}
}
}
Err(CliError::system(
"io_error",
format!(
"reserve {}: contended past retry budget (repeated create/release churn)",
p.display()
),
))
}
pub fn release(
repo: Option<&str>,
branch: Option<&str>,
key: &str,
run_id: &str,
) -> Result<(), CliError> {
let root = home::root_dir()?;
release_in(&root, repo, branch, key, run_id)
}
fn release_in(
root: &Path,
repo: Option<&str>,
branch: Option<&str>,
key: &str,
run_id: &str,
) -> Result<(), CliError> {
let p = file_path_in(root, repo, branch, key);
match std::fs::read_to_string(&p) {
Ok(s) if s.trim() == run_id => match std::fs::remove_file(&p) {
Ok(()) => {
fsync_dir(p.parent().unwrap_or(root));
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(CliError::system(
"io_error",
format!("remove {}: {}", p.display(), e),
)),
},
Ok(_) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(CliError::system(
"io_error",
format!("read {}: {}", p.display(), e),
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_hash_is_stable() {
assert_eq!(
key_hash(Some("repo"), Some("main"), "key-1"),
"374d54a7713c5c1529a2efe850ddaf06"
);
assert_eq!(
key_hash(None, None, "key-1"),
"63e136949fd4014a7e5f1d5d18d98cc5"
);
assert_eq!(
key_hash(Some("repo"), Some("main"), ""),
"3b1adbcd4680d8f508f812409f6e4e60"
);
assert_eq!(
key_hash(Some("räpo"), Some("main"), "key-1"),
"f3b96680018fdf90bf5f9357e0ff987d"
);
assert_ne!(
key_hash(Some("a"), Some("bc"), "key"),
key_hash(Some("ab"), Some("c"), "key")
);
}
#[test]
fn reserve_is_atomic_under_concurrency() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let key = "race-key";
const N: usize = 16;
let barrier = std::sync::Arc::new(std::sync::Barrier::new(N));
let winners = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let observed = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let handles: Vec<_> = (0..N)
.map(|i| {
let barrier = barrier.clone();
let winners = winners.clone();
let observed = observed.clone();
let root = root.clone();
std::thread::spawn(move || {
let my_run_id = format!("01run{i:026}");
barrier.wait();
match reserve_in(&root, None, None, key, &my_run_id).unwrap() {
Reservation::Reserved => {
winners.lock().unwrap().push(my_run_id.clone());
observed.lock().unwrap().push(my_run_id);
}
Reservation::AlreadyReserved(existing) => {
observed.lock().unwrap().push(existing);
}
}
})
})
.collect();
for h in handles {
h.join().unwrap();
}
let winners = winners.lock().unwrap();
assert_eq!(winners.len(), 1, "exactly one thread may win the key");
let winning_id = &winners[0];
let observed = observed.lock().unwrap();
assert_eq!(observed.len(), N);
assert!(
observed.iter().all(|id| id == winning_id),
"every caller must resolve to the single winner's run-id; got {observed:?}"
);
assert_eq!(
lookup_in(&root, None, None, key).unwrap().as_deref(),
Some(winning_id.as_str())
);
}
#[test]
fn release_frees_the_key() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let key = "release-key";
let a = "01runaaaaaaaaaaaaaaaaaaaaaaaaa";
let b = "01runbbbbbbbbbbbbbbbbbbbbbbbbb";
let c = "01runccccccccccccccccccccccccc";
release_in(root, None, None, key, a).unwrap();
assert!(matches!(
reserve_in(root, None, None, key, a).unwrap(),
Reservation::Reserved
));
assert!(matches!(
reserve_in(root, None, None, key, b).unwrap(),
Reservation::AlreadyReserved(_)
));
release_in(root, None, None, key, a).unwrap();
assert!(matches!(
reserve_in(root, None, None, key, c).unwrap(),
Reservation::Reserved
));
}
#[test]
fn release_is_ownership_checked() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let key = "cas-key";
let owner = "01runaaaaaaaaaaaaaaaaaaaaaaaaa";
let stale = "01runbbbbbbbbbbbbbbbbbbbbbbbbb";
assert!(matches!(
reserve_in(root, None, None, key, owner).unwrap(),
Reservation::Reserved
));
release_in(root, None, None, key, stale).unwrap();
assert_eq!(
lookup_in(root, None, None, key).unwrap().as_deref(),
Some(owner)
);
assert!(matches!(
reserve_in(root, None, None, key, stale).unwrap(),
Reservation::AlreadyReserved(_)
));
}
}