use std::path::PathBuf;
use std::sync::mpsc;
use std::sync::atomic::{AtomicU8, Ordering};
use std::time::Duration;
use crate::commit::{
lock_file_for_metadata, try_with_file_lock, try_with_metadata_file_lock, with_commit_actor,
with_file_lock, with_metadata_file_lock,
};
use crate::generations::write_json_atomic;
use crate::StorageError;
use super::tmp_dir;
#[tokio::test(flavor = "multi_thread")]
async fn commit_actor_serializes_concurrent_read_modify_write_cycles() {
let dir = tmp_dir("commit_actor_no_lost_update").await;
let md = dir.join("ds__g1_metadata.json");
write_json_atomic(&md, "0").unwrap();
let seen_a = std::sync::Arc::new(AtomicU8::new(0));
let seen_b = std::sync::Arc::new(AtomicU8::new(0));
let md_a = md.clone();
let md_a_ref = md_a.clone();
let seen_a_for_a = seen_a.clone();
let a = with_commit_actor(&md_a_ref, move || async move {
let n: u8 = std::fs::read_to_string(&md_a).unwrap().trim().parse().unwrap();
seen_a_for_a.store(n + 1, Ordering::SeqCst);
write_json_atomic(&md_a, &(n + 1).to_string()).unwrap();
Ok(())
});
let md_b = md.clone();
let md_b_ref = md_b.clone();
let seen_b_for_b = seen_b.clone();
let b = with_commit_actor(&md_b_ref, move || async move {
let n: u8 = std::fs::read_to_string(&md_b).unwrap().trim().parse().unwrap();
seen_b_for_b.store(n + 11, Ordering::SeqCst);
write_json_atomic(&md_b, &(n + 1).to_string()).unwrap();
Ok(())
});
let (ra, rb) = tokio::join!(a, b);
ra.unwrap();
rb.unwrap();
let a_seen = seen_a.load(Ordering::SeqCst);
let b_seen = seen_b.load(Ordering::SeqCst);
assert!(
(a_seen, b_seen) == (1, 12) || (a_seen, b_seen) == (2, 11),
"cycles must observe strictly ordered states, got a={a_seen}, b={b_seen}"
);
let final_count: u8 = std::fs::read_to_string(&md).unwrap().trim().parse().unwrap();
assert_eq!(final_count, 2, "concurrent RMW cycles must not lose updates");
}
#[test]
fn lock_file_convention_is_stem_dot_lock() {
assert_eq!(
lock_file_for_metadata(Path::new("/base/ds__g1_metadata.json")),
PathBuf::from("/base/ds__g1_metadata.lock")
);
assert_eq!(
lock_file_for_metadata(Path::new("/base/registry")),
PathBuf::from("/base/registry.lock")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn metadata_file_lock_holds_flock_across_the_actor_cycle() {
let base = tmp_dir("composed_lock_cycle").await;
let md = base.join("ds__g1_metadata.json");
write_json_atomic(&md, "0").unwrap();
let (entered_tx, entered_rx) = mpsc::channel::<()>();
let (release_tx, release_rx) = mpsc::channel::<()>();
let md_a = md.clone();
let md_a_ref = md_a.clone();
let cycle_a = tokio::spawn(async move {
with_metadata_file_lock(&md_a_ref, move || async move {
entered_tx.send(()).unwrap();
release_rx.recv().unwrap();
let n: u8 = std::fs::read_to_string(&md_a).unwrap().trim().parse().unwrap();
write_json_atomic(&md_a, &(n + 1).to_string()).unwrap();
Ok(())
})
.await
.unwrap();
});
entered_rx
.recv_timeout(Duration::from_secs(5))
.expect("composed cycle must start");
let (entered_b_tx, entered_b_rx) = mpsc::channel::<()>();
let lock_b = lock_file_for_metadata(&md);
let holder_b = std::thread::spawn(move || {
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async {
with_file_lock(&lock_b, move || {
entered_b_tx.send(()).unwrap();
Ok(())
})
.await
.unwrap()
})
});
assert!(
entered_b_rx.recv_timeout(Duration::from_millis(300)).is_err(),
"the flock must be held across the awaited cycle"
);
release_tx.send(()).unwrap();
cycle_a.await.unwrap();
entered_b_rx
.recv_timeout(Duration::from_secs(5))
.expect("waiter must acquire after the cycle releases");
holder_b.join().unwrap();
let _ = std::fs::remove_dir_all(&base);
}
#[tokio::test(flavor = "multi_thread")]
async fn metadata_file_lock_serializes_concurrent_cycles() {
let base = tmp_dir("composed_lock_concurrent").await;
let md = base.join("ds__g1_metadata.json");
write_json_atomic(&md, "0").unwrap();
let run = |md: PathBuf| async move {
let md_inner = md.clone();
with_metadata_file_lock(&md, move || async move {
let n: u8 = std::fs::read_to_string(&md_inner)
.unwrap()
.trim()
.parse()
.unwrap();
write_json_atomic(&md_inner, &(n + 1).to_string()).unwrap();
Ok(())
})
.await
.unwrap();
};
let (ra, rb) = tokio::join!(run(md.clone()), run(md.clone()));
let _ = (ra, rb);
let final_count: u8 = std::fs::read_to_string(&md).unwrap().trim().parse().unwrap();
assert_eq!(final_count, 2, "concurrent composed cycles must not lose updates");
let _ = std::fs::remove_dir_all(&base);
}
use std::path::Path;
#[test]
fn file_lock_excludes_second_holder_until_released() {
let base = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(tmp_dir("file_lock_exclusion"));
let lock = base.join("ds__g1_metadata.lock");
let (arrived_tx, arrived_rx) = mpsc::channel::<()>();
let (release_tx, release_rx) = mpsc::channel::<()>();
let lock_a = lock.clone();
let a = std::thread::spawn(move || {
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async {
with_file_lock(&lock_a, move || {
arrived_tx.send(()).unwrap();
release_rx.recv().unwrap();
Ok("first")
})
.await
.unwrap()
})
});
arrived_rx
.recv_timeout(Duration::from_secs(5))
.expect("first holder must acquire the lock");
let (entered_tx, entered_rx) = mpsc::channel::<()>();
let lock_b = lock.clone();
let b = std::thread::spawn(move || {
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async {
with_file_lock(&lock_b, move || {
entered_tx.send(()).unwrap();
Ok(())
})
.await
.unwrap()
})
});
assert!(
entered_rx.recv_timeout(Duration::from_millis(300)).is_err(),
"second holder must block while the first holds the lock"
);
release_tx.send(()).unwrap();
assert_eq!(a.join().unwrap(), "first", "first holder completes");
entered_rx
.recv_timeout(Duration::from_secs(5))
.expect("second holder must acquire after release");
b.join().unwrap();
assert!(lock.is_file(), "lock file must exist at {lock:?}");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn file_lock_creates_missing_parent_dirs() {
let base = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(tmp_dir("file_lock_fresh_parent"));
let lock = base.join("fresh").join("nested").join("ds.lock");
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async { with_file_lock(&lock, || Ok(())).await })
.unwrap();
assert!(lock.is_file());
let _ = std::fs::remove_dir_all(&base);
}
#[tokio::test(flavor = "multi_thread")]
async fn try_file_lock_runs_closure_uncontended_and_releases() {
let base = tmp_dir("try_file_lock_uncontended").await;
let lock = base.join("ds__g1_metadata.lock");
let out = try_with_file_lock(&lock, || Ok::<_, StorageError>("ran")).await.unwrap();
assert_eq!(out, "ran", "closure output must pass through");
try_with_file_lock(&lock, || Ok::<_, StorageError>(())).await.unwrap();
assert!(lock.is_file(), "lock file is the rendezvous point, left in place");
let _ = std::fs::remove_dir_all(&base);
}
#[tokio::test(flavor = "multi_thread")]
async fn try_file_lock_fails_fast_naming_lock_file_while_held() {
let base = tmp_dir("try_file_lock_contended").await;
let lock = base.join("ds__g1_metadata.lock");
let (held_tx, held_rx) = mpsc::channel::<()>();
let (release_tx, release_rx) = mpsc::channel::<()>();
let lock_a = lock.clone();
let holder = std::thread::spawn(move || {
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async {
with_file_lock(&lock_a, move || {
held_tx.send(()).unwrap();
release_rx.recv().unwrap();
Ok(())
})
.await
.unwrap()
})
});
held_rx
.recv_timeout(Duration::from_secs(5))
.expect("holder must acquire the lock");
let started = std::time::Instant::now();
let err = try_with_file_lock(&lock, || Ok::<_, StorageError>(())).await.unwrap_err();
assert!(
started.elapsed() < Duration::from_secs(2),
"try variant must fail fast, not wait for the holder"
);
match err {
StorageError::LockWouldBlock { path } => {
assert_eq!(path, lock, "the error must name the lock file");
}
other => panic!("expected LockWouldBlock, got {other:?}"),
}
release_tx.send(()).unwrap();
holder.join().unwrap();
try_with_file_lock(&lock, || Ok::<_, StorageError>(())).await.unwrap();
let _ = std::fs::remove_dir_all(&base);
}
#[tokio::test(flavor = "multi_thread")]
async fn try_metadata_file_lock_fails_fast_then_runs_cycle_uncontended() {
let base = tmp_dir("try_composed_lock").await;
let md = base.join("ds__g1_metadata.json");
write_json_atomic(&md, "0").unwrap();
let lock = lock_file_for_metadata(&md);
let (held_tx, held_rx) = mpsc::channel::<()>();
let (release_tx, release_rx) = mpsc::channel::<()>();
let lock_a = lock.clone();
let holder = std::thread::spawn(move || {
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async {
with_file_lock(&lock_a, move || {
held_tx.send(()).unwrap();
release_rx.recv().unwrap();
Ok(())
})
.await
.unwrap()
})
});
held_rx
.recv_timeout(Duration::from_secs(5))
.expect("holder must acquire the derived lock file");
let err = try_with_metadata_file_lock(&md, || async { Ok(()) }).await.unwrap_err();
match err {
StorageError::LockWouldBlock { path } => {
assert_eq!(path, lock, "must name the derived lock file");
}
other => panic!("expected LockWouldBlock, got {other:?}"),
}
release_tx.send(()).unwrap();
holder.join().unwrap();
let md_cycle = md.clone();
try_with_metadata_file_lock(&md, move || async move {
let n: u8 = std::fs::read_to_string(&md_cycle).unwrap().trim().parse().unwrap();
write_json_atomic(&md_cycle, &(n + 1).to_string()).unwrap();
Ok(())
})
.await
.unwrap();
let count: u8 = std::fs::read_to_string(&md).unwrap().trim().parse().unwrap();
assert_eq!(count, 1, "the uncontended try cycle must publish its RMW");
let _ = std::fs::remove_dir_all(&base);
}