use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use futures_util::join;
use tokio::time::sleep;
use super::Locker;
use crate::error::Error;
static NEXT_UPLOAD_ID: AtomicU64 = AtomicU64::new(1);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ReleaseExpectation {
GuardDrop,
LeaseTimeout(Duration),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct LockerConformanceConfig {
pub acquisition_timeout: Duration,
pub blocked_timeout: Duration,
pub release_delay: Duration,
pub release_expectation: ReleaseExpectation,
}
impl Default for LockerConformanceConfig {
fn default() -> Self {
Self {
acquisition_timeout: Duration::from_secs(1),
blocked_timeout: Duration::from_millis(50),
release_delay: Duration::from_millis(25),
release_expectation: ReleaseExpectation::GuardDrop,
}
}
}
impl LockerConformanceConfig {
#[must_use]
pub fn with_acquisition_timeout(mut self, timeout: Duration) -> Self {
self.acquisition_timeout = timeout;
self
}
#[must_use]
pub fn with_blocked_timeout(mut self, timeout: Duration) -> Self {
self.blocked_timeout = timeout;
self
}
#[must_use]
pub fn with_release_delay(mut self, delay: Duration) -> Self {
self.release_delay = delay;
self
}
#[must_use]
pub fn with_release_expectation(mut self, expectation: ReleaseExpectation) -> Self {
self.release_expectation = expectation;
self
}
}
pub async fn assert_locker_semantics<L>(locker: &L)
where
L: Locker + ?Sized,
{
assert_locker_semantics_with(locker, LockerConformanceConfig::default()).await;
}
pub async fn assert_locker_semantics_with<L>(locker: &L, config: LockerConformanceConfig)
where
L: Locker + ?Sized,
{
try_lock_is_exclusive_and_releases(locker, config).await;
lock_waits_until_current_guard_releases(locker, config).await;
lock_times_out_while_guard_is_held(locker, config).await;
dropped_guard_releases_or_expires(locker, config).await;
independent_upload_ids_do_not_interfere(locker, config).await;
}
async fn try_lock_is_exclusive_and_releases<L>(locker: &L, config: LockerConformanceConfig)
where
L: Locker + ?Sized,
{
let id = upload_id("try-lock-exclusive");
let guard = locker
.try_lock(&id)
.await
.expect("first try_lock should succeed")
.expect("first try_lock should acquire an unlocked upload");
let second = locker
.try_lock(&id)
.await
.expect("second try_lock should not error");
assert!(
second.is_none(),
"try_lock must be exclusive for a single upload ID"
);
drop(guard);
assert_released_or_expired(locker, &id, config, "try_lock guard").await;
}
async fn lock_waits_until_current_guard_releases<L>(locker: &L, config: LockerConformanceConfig)
where
L: Locker + ?Sized,
{
let id = upload_id("waited-lock");
let guard = locker
.lock(&id, config.acquisition_timeout)
.await
.expect("initial lock should acquire an unlocked upload");
let waiter = locker.lock(&id, config.acquisition_timeout);
let releaser = async move {
sleep(config.release_delay).await;
drop(guard);
};
let (waited, ()) = join!(waiter, releaser);
let waited = waited.expect("lock should wait and then acquire after release");
assert_eq!(waited.upload_id(), id);
drop(waited);
}
async fn lock_times_out_while_guard_is_held<L>(locker: &L, config: LockerConformanceConfig)
where
L: Locker + ?Sized,
{
let id = upload_id("timeout");
let _guard = locker
.lock(&id, config.acquisition_timeout)
.await
.expect("initial lock should acquire an unlocked upload");
let result = locker.lock(&id, config.blocked_timeout).await;
assert!(
matches!(result, Err(Error::LockTimeout(_))),
"lock should return Error::LockTimeout when acquisition exceeds the timeout, got {result:?}"
);
}
async fn dropped_guard_releases_or_expires<L>(locker: &L, config: LockerConformanceConfig)
where
L: Locker + ?Sized,
{
let id = upload_id("drop-release");
let guard = locker
.lock(&id, config.acquisition_timeout)
.await
.expect("initial lock should acquire an unlocked upload");
drop(guard);
assert_released_or_expired(locker, &id, config, "lock guard").await;
}
async fn assert_released_or_expired<L>(
locker: &L,
id: &str,
config: LockerConformanceConfig,
guard_kind: &str,
) where
L: Locker + ?Sized,
{
match config.release_expectation {
ReleaseExpectation::GuardDrop => {
let reacquired = locker
.try_lock(id)
.await
.expect("try_lock after guard drop should not error");
assert!(
reacquired.is_some(),
"dropping the {guard_kind} should release the lock immediately"
);
}
ReleaseExpectation::LeaseTimeout(wait) => {
let reacquired = locker.lock(id, wait).await.expect(
"lock should become available after guard drop and the configured lease timeout",
);
drop(reacquired);
}
}
}
async fn independent_upload_ids_do_not_interfere<L>(locker: &L, config: LockerConformanceConfig)
where
L: Locker + ?Sized,
{
let first_id = upload_id("independent-a");
let second_id = upload_id("independent-b");
let first = locker
.lock(&first_id, config.acquisition_timeout)
.await
.expect("first upload should lock");
let second = locker
.try_lock(&second_id)
.await
.expect("try_lock for a different upload should not error")
.expect("different upload ID should lock independently");
drop(first);
let second_probe = locker
.try_lock(&second_id)
.await
.expect("try_lock probe for second upload should not error");
assert!(
second_probe.is_none(),
"releasing one upload ID must not release an independent upload ID"
);
drop(second);
}
fn upload_id(scenario: &str) -> String {
let next = NEXT_UPLOAD_ID.fetch_add(1, Ordering::Relaxed);
format!("conformance-lock-{scenario}-{next}")
}