use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
use gunnar_server::gc_queue::{GcQueue, GcRunner, GcState};
fn wait_until(what: &str, mut cond: impl FnMut() -> bool) {
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
if cond() {
return;
}
std::thread::sleep(Duration::from_millis(5));
}
panic!("timed out waiting for {what}");
}
fn overlapping_runner(dwell: Duration) -> (GcRunner, Arc<AtomicUsize>) {
let peak = Arc::new(AtomicUsize::new(0));
let live = Arc::new(AtomicUsize::new(0));
let observed = Arc::clone(&peak);
let runner: GcRunner = Arc::new(move |_path: &str| {
let now = live.fetch_add(1, Ordering::SeqCst) + 1;
peak.fetch_max(now, Ordering::SeqCst);
std::thread::sleep(dwell);
live.fetch_sub(1, Ordering::SeqCst);
Ok(4096)
});
(runner, observed)
}
#[test]
fn ten_concurrent_requests_never_run_two_gcs_at_once() {
let repos: Vec<String> = (0..10).map(|n| format!("acct/repo{n}")).collect();
let (runner, runner_peak) = overlapping_runner(Duration::from_millis(20));
let queue = GcQueue::new(runner);
let start = Arc::new(std::sync::Barrier::new(repos.len()));
let handles: Vec<_> = repos
.iter()
.cloned()
.map(|path| {
let queue = queue.clone();
let start = Arc::clone(&start);
std::thread::spawn(move || {
start.wait();
queue.enqueue(&path)
})
})
.collect();
for handle in handles {
handle.join().expect("an enqueueing thread panicked");
}
wait_until("every gc to finish", || {
repos
.iter()
.all(|path| matches!(queue.status(path).map(|j| j.state), Some(GcState::Done)))
});
assert_eq!(
queue.runs(),
repos.len() as u64,
"every request must actually have run a gc; a queue that dropped work \
would pass the overlap assertion below for the wrong reason"
);
assert_eq!(
queue.peak_in_flight(),
1,
"two gcs ran at the same time. The whole point of this queue is that \
they cannot: a gc is hundreds of thousands of unlinks and the box has \
one disk."
);
assert_eq!(
runner_peak.load(Ordering::SeqCst),
1,
"the RUNNER saw overlapping calls, whatever the queue's own counter says"
);
for path in &repos {
let job = queue.status(path).expect("a finished job stays readable");
assert_eq!(job.bytes_reclaimed, 4096, "{path} reported no bytes");
assert_eq!(job.queue_position, 0, "a finished job is not waiting");
}
queue.stop();
}
#[test]
fn a_double_press_does_not_enqueue_twice() {
let release = Arc::new((Mutex::new(false), Condvar::new()));
let entered = Arc::new(AtomicUsize::new(0));
let held = Arc::clone(&release);
let counted = Arc::clone(&entered);
let runner: GcRunner = Arc::new(move |_path: &str| {
counted.fetch_add(1, Ordering::SeqCst);
let (lock, cv) = &*held;
let mut go = lock.lock().unwrap();
while !*go {
go = cv.wait(go).unwrap();
}
Ok(77)
});
let queue = GcQueue::new(runner);
let first = queue.enqueue("acct/impatient");
wait_until("the first gc to start", || {
entered.load(Ordering::SeqCst) == 1
});
let second = queue.enqueue("acct/impatient");
assert_eq!(
second.job_id, first.job_id,
"a second request for a repository already being gc'd must return the \
job that is already there, not start another"
);
assert_eq!(second.state, GcState::Running);
let third = queue.enqueue("acct/impatient");
assert_eq!(third.job_id, first.job_id);
{
let (lock, cv) = &*release;
*lock.lock().unwrap() = true;
cv.notify_all();
}
wait_until("the gc to finish", || {
matches!(
queue.status("acct/impatient").map(|j| j.state),
Some(GcState::Done)
)
});
assert_eq!(
queue.runs(),
1,
"three requests for one repository ran {} gcs. Dedup that only \
collapses job ids is not dedup.",
queue.runs()
);
assert_eq!(
queue
.status("acct/impatient")
.expect("still readable")
.bytes_reclaimed,
77
);
queue.stop();
}
#[test]
fn a_finished_job_stays_readable_and_an_unasked_one_is_absent() {
let (runner, _) = overlapping_runner(Duration::from_millis(1));
let queue = GcQueue::new(runner);
assert!(
queue.status("acct/never-asked").is_none(),
"a repository nobody has asked about must have no job at all"
);
queue.enqueue("acct/done");
wait_until("the gc to finish", || {
matches!(
queue.status("acct/done").map(|j| j.state),
Some(GcState::Done)
)
});
let job = queue.status("acct/done").expect("kept after finishing");
assert_eq!(job.state, GcState::Done);
assert_eq!(job.bytes_reclaimed, 4096);
assert!(job.finished_at_unix_ms > 0, "a finished job carries when");
assert!(
queue.status("acct/never-asked").is_none(),
"one repository's job must not answer for another's"
);
queue.stop();
}
#[test]
fn a_failed_gc_is_reported_and_the_queue_carries_on() {
let runner: GcRunner = Arc::new(|path: &str| {
if path == "acct/broken" {
Err("the store would not open".into())
} else {
Ok(9)
}
});
let queue = GcQueue::new(runner);
queue.enqueue("acct/broken");
queue.enqueue("acct/fine");
wait_until("both jobs to settle", || {
matches!(
queue.status("acct/broken").map(|j| j.state),
Some(GcState::Failed)
) && matches!(
queue.status("acct/fine").map(|j| j.state),
Some(GcState::Done)
)
});
let broken = queue.status("acct/broken").expect("kept");
assert_eq!(
broken.error.as_deref(),
Some("the store would not open"),
"a failed job must carry the reason; 'failed' alone is not actionable"
);
assert_eq!(broken.bytes_reclaimed, 0, "a failed gc reclaimed nothing");
assert_eq!(queue.status("acct/fine").expect("kept").bytes_reclaimed, 9);
queue.stop();
}