// Copyright 2024 RustFS Team // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use crate::{ ObjectKey, client::LockClient, error::{LockError, Result}, types::{LockId, LockInfo, LockRequest, LockResponse, LockStatus, LockType}, }; use futures::{ future::join_all, stream::{FuturesUnordered, StreamExt}, }; use rand::RngExt as _; use rustfs_io_metrics::{ record_lock_refresh_quorum_lost, record_read_lock_held_acquire, record_read_lock_held_release, record_write_lock_held_acquire, record_write_lock_held_release, }; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::sync::Notify; use tokio::task::{JoinHandle, JoinSet}; use tokio::time::Instant; use tracing::{debug, warn}; use uuid::Uuid; const UNLOCK_RETRY_ATTEMPTS: usize = 3; const UNLOCK_RETRY_BACKOFF: Duration = Duration::from_millis(100); const LOCK_ACQUIRE_RETRY_INITIAL_BACKOFF: Duration = Duration::from_millis(250); const LOCK_ACQUIRE_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(1); const REMOTE_LOCK_RPC_FAILED_PREFIX: &str = "remote lock rpc failed:"; const REMOTE_LOCK_RPC_TIMED_OUT_PREFIX: &str = "remote lock rpc timed out:"; const UNRECOVERABLE_QUORUM_FAILURE_PREFIX: &str = "unrecoverable quorum failure"; #[derive(Clone, Copy, Debug)] enum LockAcquireFailureKind { NonRetryable, RetryableContention, UnrecoverableQuorum, } /// Generate a new aggregate lock ID for multiple client locks fn generate_aggregate_lock_id(resource: &ObjectKey) -> LockId { LockId { resource: resource.clone(), uuid: Uuid::new_v4().to_string(), } } fn is_remote_lock_rpc_failure(error: &str) -> bool { has_case_insensitive_prefix(error, REMOTE_LOCK_RPC_FAILED_PREFIX) || has_case_insensitive_prefix(error, REMOTE_LOCK_RPC_TIMED_OUT_PREFIX) } fn is_remote_lock_rpc_timeout(error: &str) -> bool { has_case_insensitive_prefix(error, REMOTE_LOCK_RPC_TIMED_OUT_PREFIX) } fn has_case_insensitive_prefix(error: &str, expected_prefix: &str) -> bool { error .get(0..expected_prefix.len()) .is_some_and(|prefix| prefix.eq_ignore_ascii_case(expected_prefix)) } fn is_unrecoverable_quorum_error(error: &str) -> bool { error .get(0..UNRECOVERABLE_QUORUM_FAILURE_PREFIX.len()) .is_some_and(|prefix| prefix.eq_ignore_ascii_case(UNRECOVERABLE_QUORUM_FAILURE_PREFIX)) } /// Derive a safe heartbeat interval for a freshly acquired guard. /// /// Deliberately avoids `Duration::clamp` (which asserts `min <= max` and would panic for /// sub-second ttls where `ttl - 1s` underflows to zero). Returns `None` for degenerate cases so /// no heartbeat is spawned: /// - `entries_len == 0`: no acquired backend lease to renew, /// - `interval.is_zero()` or `interval >= ttl`: too small a ttl / too large an interval to renew. fn derive_refresh_interval(entries_len: usize, ttl: Duration, injected: Option) -> Option { if entries_len == 0 { return None; } let interval = injected.unwrap_or(ttl / 3); if interval.is_zero() || interval >= ttl { return None; } Some(interval) } fn classify_lock_failure(error: &str) -> LockAcquireFailureKind { if is_unrecoverable_quorum_error(error) { return LockAcquireFailureKind::UnrecoverableQuorum; } if error.to_ascii_lowercase().contains("timeout") || is_remote_lock_rpc_failure(error) { return LockAcquireFailureKind::RetryableContention; } LockAcquireFailureKind::NonRetryable } fn classify_lock_error_failure(error: &LockError) -> LockAcquireFailureKind { if error.is_retryable() { LockAcquireFailureKind::RetryableContention } else { classify_lock_failure(&error.to_string()) } } fn should_warn_lock_failure(error: &str) -> bool { if is_remote_lock_rpc_failure(error) { return !is_remote_lock_rpc_timeout(error); } matches!( classify_lock_failure(error), LockAcquireFailureKind::NonRetryable | LockAcquireFailureKind::UnrecoverableQuorum ) } /// Observability channel for lock-loss: set once when a guard's heartbeat detects it has /// lost refresh quorum. Phase 1 only signals (warn + metric + this flag); Phase 2 (backlog#899) /// will `select!` on `notified()` inside long write operations to abort and avoid split-brain. #[derive(Debug, Default)] pub struct LockLostSignal { lost: AtomicBool, valid_until: Mutex>, notify: Notify, } impl LockLostSignal { fn set_valid_until(&self, valid_until: Instant) { if self.lost.load(Ordering::SeqCst) { return; } match self.valid_until.lock() { Ok(mut deadline) => { if deadline.is_some_and(|current| Instant::now() >= current) { drop(deadline); self.mark_lost(); return; } *deadline = Some(valid_until); self.notify.notify_waiters(); } Err(_) => self.mark_lost(), } } fn mark_lost(&self) { self.lost.store(true, Ordering::SeqCst); self.notify.notify_waiters(); } /// Whether refresh quorum has been lost for the associated guard. pub fn is_lost(&self) -> bool { if self.lost.load(Ordering::SeqCst) { return true; } let expired = match self.valid_until.lock() { Ok(deadline) => deadline.is_some_and(|valid_until| Instant::now() >= valid_until), Err(_) => true, }; if expired { self.mark_lost(); } expired } /// Resolves once the lock is declared lost (immediately if already lost). pub async fn notified(&self) { self.notified_after_registration(|| {}).await; } async fn notified_after_registration(&self, after_registration: impl FnOnce()) { let mut after_registration = Some(after_registration); loop { let notified = self.notify.notified(); tokio::pin!(notified); notified.as_mut().enable(); if let Some(after_registration) = after_registration.take() { after_registration(); } if self.is_lost() { return; } let valid_until = match self.valid_until.lock() { Ok(deadline) => *deadline, Err(_) => return, }; match valid_until { Some(valid_until) => { tokio::select! { _ = tokio::time::sleep_until(valid_until) => {} _ = &mut notified => {} } } None => notified.await, } } } } #[derive(Clone, Debug)] struct HeldLockEntry { lock_id: LockId, client: Arc, valid_until: Instant, } impl HeldLockEntry { fn release_entry(&self) -> (LockId, Arc) { (self.lock_id.clone(), self.client.clone()) } } #[derive(Clone, Debug)] struct LockHeartbeatConfig { ttl: Duration, interval: Option, refresh_quorum: usize, owner: String, resource: ObjectKey, } #[derive(Clone, Copy, Debug, Default)] struct LockRefreshStats { refreshed: usize, not_found: usize, refresh_errors: usize, } /// A RAII guard for distributed locks that releases the lock asynchronously when dropped. #[derive(Debug)] pub struct DistributedLockGuard { /// The public-facing lock id. For multi-client scenarios this is typically /// an aggregate id; for single-client it is the only id. lock_id: LockId, /// All underlying leases that should be refreshed and released with the guard. entries: Vec, lock_type: LockType, /// If true, Drop will not try to release (used if user manually released). disarmed: bool, /// Background lease task for renewable leases. refresh_task: Option>, /// Lock-loss signal, shared with the heartbeat task. lock_lost: Arc, } impl DistributedLockGuard { /// Create a new guard. /// /// - `lock_id` is the id returned to the caller (`lock_id()`). /// - `entries` is the full list of underlying leases that should be refreshed and released /// with this guard. /// - `heartbeat`: lease TTL, optional renewal cadence, quorum, and diagnostics. fn new(lock_id: LockId, entries: Vec, lock_type: LockType, heartbeat: LockHeartbeatConfig) -> Self { record_lock_held_acquire(lock_type); let lock_lost = Arc::new(LockLostSignal::default()); match Self::quorum_valid_until(&entries, heartbeat.refresh_quorum) { Some(valid_until) => lock_lost.set_valid_until(valid_until), None => lock_lost.mark_lost(), } // Non-renewable guards are fenced directly by LockLostSignal's deadline checks. let refresh_task = heartbeat.interval.and_then(|_| { tokio::runtime::Handle::try_current().ok().map(|handle| { let entries = entries.clone(); let lock_lost = lock_lost.clone(); handle.spawn(Self::run_heartbeat(entries, heartbeat, lock_lost)) }) }); Self { lock_id, entries, lock_type, disarmed: false, refresh_task, lock_lost, } } fn quorum_valid_until(entries: &[HeldLockEntry], refresh_quorum: usize) -> Option { if refresh_quorum == 0 || entries.len() < refresh_quorum { return None; } let mut deadlines: Vec<_> = entries.iter().map(|entry| entry.valid_until).collect(); deadlines.sort_unstable(); deadlines.get(deadlines.len() - refresh_quorum).copied() } fn signal_refresh_quorum_lost( lock_lost: &LockLostSignal, config: &LockHeartbeatConfig, entries: usize, stats: LockRefreshStats, ) { warn!( resource = %config.resource, owner = config.owner, refreshed = stats.refreshed, not_found = stats.not_found, refresh_errors = stats.refresh_errors, entries, refresh_quorum = config.refresh_quorum, "lock refresh lost quorum" ); record_lock_refresh_quorum_lost(); lock_lost.mark_lost(); } /// Refresh every tracked lease while a quorum is still known to be valid. /// /// A successful refresh extends only that entry's conservative local deadline. RPC errors /// retain the previous deadline, so transient jitter is tolerated but an unconfirmed lease /// can never remain valid beyond its backend TTL. Waiting for a slow refresh is also fenced /// by the current quorum deadline. async fn run_heartbeat(mut entries: Vec, config: LockHeartbeatConfig, lock_lost: Arc) { let Some(interval) = config.interval else { return; }; let now = Instant::now(); let first_refresh = now.checked_add(interval).unwrap_or(now); let mut ticker = tokio::time::interval_at(first_refresh, interval); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { let Some(valid_until) = Self::quorum_valid_until(&entries, config.refresh_quorum) else { Self::signal_refresh_quorum_lost(&lock_lost, &config, entries.len(), LockRefreshStats::default()); return; }; lock_lost.set_valid_until(valid_until); tokio::select! { biased; _ = tokio::time::sleep_until(valid_until) => { Self::signal_refresh_quorum_lost(&lock_lost, &config, entries.len(), LockRefreshStats::default()); return; } _ = ticker.tick() => {} } let mut stats = LockRefreshStats::default(); let mut pending = FuturesUnordered::new(); for (idx, entry) in entries.iter().enumerate() { let refresh_started = Instant::now(); let lock_id = entry.lock_id.clone(); let client = entry.client.clone(); pending.push(async move { (idx, refresh_started, client.refresh(&lock_id).await) }); } while !pending.is_empty() { let Some(valid_until) = Self::quorum_valid_until(&entries, config.refresh_quorum) else { Self::signal_refresh_quorum_lost(&lock_lost, &config, entries.len(), stats); return; }; lock_lost.set_valid_until(valid_until); let next = tokio::select! { biased; _ = tokio::time::sleep_until(valid_until) => { Self::signal_refresh_quorum_lost(&lock_lost, &config, entries.len(), stats); return; } next = pending.next() => next }; let Some((idx, refresh_started, result)) = next else { break; }; match result { Ok(true) => { stats.refreshed += 1; entries[idx].valid_until = refresh_started.checked_add(config.ttl).unwrap_or(refresh_started); } Ok(false) => { stats.not_found += 1; entries[idx].valid_until = Instant::now(); } Err(_) => stats.refresh_errors += 1, } } } } /// Get the lock id associated with this guard pub fn lock_id(&self) -> &LockId { &self.lock_id } /// Whether the guard's heartbeat has observed a refresh-quorum loss. pub fn is_lock_lost(&self) -> bool { self.lock_lost.is_lost() } /// Shared lock-loss signal (used by callers to `select!` on `notified()`; Phase 2). pub fn lock_lost(&self) -> Arc { self.lock_lost.clone() } /// Abort the heartbeat task, if running. Idempotent. /// /// Aborting may truncate an in-flight refresh, but refresh is idempotent and only extends /// an existing lease (never creates one): a late refresh reaching a backend that already /// dropped the entry returns `Ok(false)` with no side effect. fn stop_heartbeat(&mut self) { if let Some(task) = self.refresh_task.take() { task.abort(); } } /// Manually disarm the guard so dropping it won't release the lock. /// Call this if you explicitly released the lock elsewhere. pub fn disarm(&mut self) { // Stop renewing a lock the caller says it released elsewhere. self.stop_heartbeat(); if !self.disarmed { record_lock_held_release(self.lock_type); } self.disarmed = true; } /// Check if the guard has been disarmed (lock already released) pub fn is_disarmed(&self) -> bool { self.disarmed } /// Manually release the lock early. /// This spawns a background release task and then disarms the guard /// to prevent double-release on drop. /// Returns true if release was scheduled or the guard was already disarmed. pub fn release(&mut self) -> bool { // Stop the heartbeat before releasing so no refresh races the unlock. Abort is safe even // if disarmed (take() makes it idempotent / a no-op when never spawned). self.stop_heartbeat(); if self.disarmed { // Lock was already released, return true to indicate lock is in released state return true; } let entries = self.entries.iter().map(HeldLockEntry::release_entry).collect(); DistributedLock::spawn_release_cleanup(entries, "distributed_lock_guard_release"); // Disarm to prevent double-release on drop self.disarmed = true; record_lock_held_release(self.lock_type); true } } impl Drop for DistributedLockGuard { fn drop(&mut self) { // Call release() to handle the actual release logic // If already disarmed, release() will return early // Setting disarmed in release() is harmless here since we're dropping anyway let _ = self.release(); } } /// Distributed lock handler for distributed use cases /// Uses quorum-based acquisition and aggregate lock ID mapping #[derive(Debug)] pub struct DistributedLock { /// Lock clients for this namespace clients: Vec>, /// Namespace identifier namespace: String, /// Quorum size for exclusive/write operations quorum: usize, } type LockAcquireTaskResult = (usize, Result); struct LockAcquireQuorumResult { response: LockResponse, individual_locks: Vec, failure_kind: Option, quorum_impossible: bool, } impl DistributedLock { /// Create new distributed lock pub fn new(namespace: String, clients: Vec>, quorum: usize) -> Self { let q = if clients.len() <= 1 { 1 } else { quorum.clamp(1, clients.len()) }; Self { clients, namespace, quorum: q, } } /// Get namespace identifier pub fn namespace(&self) -> &str { &self.namespace } fn read_quorum(&self) -> usize { let client_count = self.clients.len(); if client_count <= 1 { 1 } else { client_count - (client_count / 2) } } fn required_quorum(&self, lock_type: LockType) -> usize { match lock_type { LockType::Shared => self.read_quorum(), LockType::Exclusive => self.quorum, } } /// Get resource key for this namespace pub fn get_resource_key(&self, resource: &ObjectKey) -> String { format!("{}:{}", self.namespace, resource) } /// Get clients (for health check and stats) pub(crate) fn clients(&self) -> &[Arc] { &self.clients } /// Acquire a lock and return a RAII guard pub(crate) async fn acquire_guard(&self, request: &LockRequest) -> Result> { if self.clients.is_empty() { return Err(LockError::internal("No lock clients available")); } let required_quorum = self.required_quorum(request.lock_type); let LockAcquireQuorumResult { response: resp, individual_locks, failure_kind, .. } = self.acquire_lock_quorum_with_retry(request).await?; if resp.success { // Use aggregate lock_id from LockResponse's LockInfo // The aggregate id is what we expose to callers; individual_locks carries // the real (LockId, client) pairs that must be released. let aggregate_lock_id = resp .lock_info .as_ref() .map(|info| info.id.clone()) .unwrap_or_else(|| LockId::new_unique(&request.resource)); // Heartbeat operates on the per-client individual locks (their per-client ids), never // the aggregate id, so refreshes round-trip to the exact backend entries. let heartbeat = LockHeartbeatConfig { ttl: request.ttl, interval: derive_refresh_interval(individual_locks.len(), request.ttl, request.refresh_interval), refresh_quorum: required_quorum, owner: request.owner.clone(), resource: request.resource.clone(), }; Ok(Some(DistributedLockGuard::new( aggregate_lock_id, individual_locks, request.lock_type, heartbeat, ))) } else { // Check if it's a timeout or quorum failure if let Some(error_msg) = &resp.error { if request.suppress_contention_logs { tracing::debug!( resource = %request.resource, owner = %request.owner, "acquire_lock_quorum contention: {}", error_msg ); } else if matches!(failure_kind, Some(LockAcquireFailureKind::NonRetryable)) { warn!( resource = %request.resource, owner = %request.owner, "acquire_lock_quorum error: {}", error_msg ); } else if matches!(failure_kind, Some(LockAcquireFailureKind::RetryableContention)) { debug!( resource = %request.resource, owner = %request.owner, "acquire_lock_quorum contention: {}", error_msg ); } else { debug!( resource = %request.resource, owner = %request.owner, "acquire_lock_quorum final failure: {}", error_msg ); } if matches!(failure_kind, Some(LockAcquireFailureKind::UnrecoverableQuorum)) { let achieved = individual_locks.len(); Err(LockError::QuorumNotReached { required: required_quorum, achieved, }) } else { Ok(None) } } else { Ok(None) } } } /// Convenience: acquire exclusive lock as a guard pub async fn lock_guard( &self, resource: ObjectKey, owner: &str, timeout: Duration, ttl: Duration, ) -> Result> { let req = LockRequest::new(resource, LockType::Exclusive, owner) .with_acquire_timeout(timeout) .with_ttl(ttl); self.acquire_guard(&req).await } /// Convenience: acquire exclusive lock with expected contention logs suppressed pub async fn lock_guard_quiet( &self, resource: ObjectKey, owner: &str, timeout: Duration, ttl: Duration, ) -> Result> { let req = LockRequest::new(resource, LockType::Exclusive, owner) .with_acquire_timeout(timeout) .with_ttl(ttl) .with_suppress_contention_logs(true); self.acquire_guard(&req).await } /// Convenience: acquire shared lock as a guard pub async fn rlock_guard( &self, resource: ObjectKey, owner: &str, timeout: Duration, ttl: Duration, ) -> Result> { let req = LockRequest::new(resource, LockType::Shared, owner) .with_acquire_timeout(timeout) .with_ttl(ttl); self.acquire_guard(&req).await } fn spawn_lock_requests(&self, request: &LockRequest) -> JoinSet { let mut pending = JoinSet::new(); for (idx, client) in self.clients.iter().cloned().enumerate() { let request = request.clone(); pending.spawn(async move { (idx, client.acquire_lock(&request).await) }); } pending } fn lock_acquire_retry_backoff(attempt: usize, rng: &mut impl rand::Rng) -> Duration { let base = LOCK_ACQUIRE_RETRY_INITIAL_BACKOFF.saturating_mul(attempt.try_into().unwrap_or(u32::MAX)); let jitter = base / 4; let lower = base.saturating_sub(jitter); let upper = base.saturating_add(jitter); let lower_nanos = u64::try_from(lower.as_nanos()).unwrap_or(u64::MAX); let upper_nanos = u64::try_from(upper.as_nanos()).unwrap_or(u64::MAX); Duration::from_nanos(rng.random_range(lower_nanos..=upper_nanos)) } fn lock_acquire_attempt_timeout(&self, remaining: Duration) -> Duration { if self.clients.len() <= 1 { remaining } else { remaining.min(LOCK_ACQUIRE_ATTEMPT_TIMEOUT) } } async fn release_entries(entries: Vec<(LockId, Arc)>, context: &'static str) { let mut pending = entries; for attempt in 1..=UNLOCK_RETRY_ATTEMPTS { let release_results = join_all(pending.into_iter().map(|(lock_id, client)| async move { match client.release(&lock_id).await { Ok(true) => None, Ok(false) => { warn!(%lock_id, attempt, context, "distributed unlock did not find lock on client"); Some((lock_id, client)) } Err(err) => { warn!(%lock_id, attempt, context, "distributed unlock failed on client: {}", err); Some((lock_id, client)) } } })) .await; pending = release_results.into_iter().flatten().collect(); if pending.is_empty() { debug!(attempt, context, "distributed unlock completed"); return; } if attempt < UNLOCK_RETRY_ATTEMPTS { tokio::time::sleep(UNLOCK_RETRY_BACKOFF * attempt as u32).await; } } warn!( remaining = pending.len(), attempts = UNLOCK_RETRY_ATTEMPTS, context, "distributed unlock left unreleased entries after retry" ); } fn spawn_release_cleanup(entries: Vec<(LockId, Arc)>, context: &'static str) { if entries.is_empty() { return; } if let Ok(handle) = tokio::runtime::Handle::try_current() { let join_handle = handle.spawn(async move { Self::release_entries(entries, context).await; }); drop(join_handle); return; } let join_handle = std::thread::spawn(move || match tokio::runtime::Builder::new_current_thread().enable_all().build() { Ok(runtime) => runtime.block_on(async move { Self::release_entries(entries, context).await; }), Err(err) => warn!(context, "failed to create fallback unlock runtime: {}", err), }); drop(join_handle); } fn spawn_pending_cleanup( mut pending: JoinSet, clients: Vec>, fallback_lock_id: LockId, context: &'static str, ) { let handle = tokio::spawn(async move { while let Some(join_result) = pending.join_next().await { match join_result { Ok((idx, Ok(resp))) if resp.success => { let lock_id = resp .lock_info .as_ref() .map(|info| info.id.clone()) .unwrap_or_else(|| fallback_lock_id.clone()); let Some(client) = clients.get(idx) else { tracing::warn!("{context}: missing client for pending lock cleanup at index {}", idx); continue; }; Self::release_entries(vec![(lock_id, client.clone())], context).await; } Ok((idx, Ok(resp))) => { tracing::debug!( "{context}: pending lock request on client {} completed without success: {:?}", idx, resp.error ); } Ok((idx, Err(err))) => { tracing::warn!("{context}: pending lock request on client {} failed: {}", idx, err); } Err(err) => { tracing::warn!("{context}: pending lock cleanup task join failed: {}", err); } } } }); drop(handle); } fn log_failed_lock_response(&self, request: &LockRequest, idx: usize, error: String) { if request.suppress_contention_logs || !should_warn_lock_failure(&error) { tracing::debug!( resource = %request.resource, owner = %request.owner, "Failed to acquire lock on client from response: {}, error: {}", idx, error ); } else { tracing::warn!( resource = %request.resource, owner = %request.owner, "Failed to acquire lock on client from response: {}, error: {}", idx, error ); } } async fn acquire_lock_quorum_with_retry(&self, request: &LockRequest) -> Result { let start = std::time::Instant::now(); let mut attempt = 1; let mut last_result = None; loop { let elapsed = start.elapsed(); if elapsed >= request.acquire_timeout { break; } let remaining = request.acquire_timeout - elapsed; let mut attempt_request = request.clone(); attempt_request.acquire_timeout = self.lock_acquire_attempt_timeout(remaining); attempt_request.lock_id = LockId::new_unique(&request.resource); let result = self.acquire_lock_quorum_once(&attempt_request).await?; if result.response.success || !matches!(result.failure_kind, Some(LockAcquireFailureKind::RetryableContention)) { return Ok(result); } last_result = Some(result); let backoff = Self::lock_acquire_retry_backoff(attempt, &mut rand::rng()); if start.elapsed().saturating_add(backoff) >= request.acquire_timeout { break; } tokio::time::sleep(backoff).await; attempt += 1; } if let Some(mut result) = last_result { if result.quorum_impossible { result.failure_kind = Some(LockAcquireFailureKind::UnrecoverableQuorum); } Ok(result) } else { Ok(LockAcquireQuorumResult { response: LockResponse::failure("Lock acquisition timeout", request.acquire_timeout), individual_locks: Vec::new(), failure_kind: Some(LockAcquireFailureKind::RetryableContention), quorum_impossible: false, }) } } fn lock_acquire_timeout_result(timeout: Duration) -> LockAcquireQuorumResult { LockAcquireQuorumResult { response: LockResponse::failure("Lock acquisition timeout", timeout), individual_locks: Vec::new(), failure_kind: Some(LockAcquireFailureKind::RetryableContention), quorum_impossible: false, } } fn lock_acquire_attempt_timeout_result( timeout: Duration, individual_locks: Vec, last_failure: Option, last_failure_kind: Option, ) -> LockAcquireQuorumResult { let failure_kind = if let Some(kind @ (LockAcquireFailureKind::NonRetryable | LockAcquireFailureKind::UnrecoverableQuorum)) = last_failure_kind { kind } else { return Self::lock_acquire_timeout_result(timeout); }; let mut error = "Lock acquisition timeout".to_string(); if let Some(last_failure) = last_failure { error.push_str("; last failure: "); error.push_str(&last_failure); } LockAcquireQuorumResult { response: LockResponse::failure(error, timeout), individual_locks, failure_kind: Some(failure_kind), quorum_impossible: matches!(failure_kind, LockAcquireFailureKind::UnrecoverableQuorum), } } /// Quorum-based lock acquisition: success if at least the required quorum succeeds. /// Collects all individual lock_ids from successful clients and creates an aggregate lock_id. /// Returns the LockResponse with aggregate lock_id and individual lock mappings. async fn acquire_lock_quorum_once(&self, request: &LockRequest) -> Result { let required_quorum = self.required_quorum(request.lock_type); let attempt_started = Instant::now(); let mut pending = self.spawn_lock_requests(request); let mut individual_locks: Vec = Vec::new(); let fallback_lock_id = request.lock_id.clone(); let mut last_failure = None; let mut last_failure_kind = None; let mut last_hard_failure_kind = None; let mut hard_failures = 0usize; let start = Instant::now(); while !pending.is_empty() { let remaining = request.acquire_timeout.saturating_sub(start.elapsed()); if remaining.is_zero() { Self::spawn_release_cleanup( individual_locks.iter().map(HeldLockEntry::release_entry).collect(), "distributed_lock_attempt_timeout", ); Self::spawn_pending_cleanup( pending, self.clients.clone(), fallback_lock_id.clone(), "distributed_lock_attempt_timeout_cleanup", ); return Ok(Self::lock_acquire_attempt_timeout_result( request.acquire_timeout, individual_locks, last_failure, last_failure_kind, )); } let join_result = match tokio::time::timeout(remaining, pending.join_next()).await { Ok(Some(join_result)) => join_result, Ok(None) => break, Err(_) => { Self::spawn_release_cleanup( individual_locks.iter().map(HeldLockEntry::release_entry).collect(), "distributed_lock_attempt_timeout", ); Self::spawn_pending_cleanup( pending, self.clients.clone(), fallback_lock_id.clone(), "distributed_lock_attempt_timeout_cleanup", ); return Ok(Self::lock_acquire_attempt_timeout_result( request.acquire_timeout, individual_locks, last_failure, last_failure_kind, )); } }; match join_result { Ok((idx, Ok(resp))) => { if resp.success { let lock_id = resp .lock_info .as_ref() .map(|info| info.id.clone()) .unwrap_or_else(|| fallback_lock_id.clone()); if let Some(client) = self.clients.get(idx) { individual_locks.push(HeldLockEntry { lock_id, client: client.clone(), valid_until: attempt_started.checked_add(request.ttl).unwrap_or(attempt_started), }); } else { tracing::warn!("Missing lock client at index {} while recording success", idx); } } else { let error = resp.error.unwrap_or_else(|| "unknown error".to_string()); let failure_kind = classify_lock_failure(&error); if is_remote_lock_rpc_failure(&error) && !is_remote_lock_rpc_timeout(&error) { hard_failures += 1; last_hard_failure_kind = Some(failure_kind); } self.log_failed_lock_response(request, idx, error.clone()); last_failure = Some(error); last_failure_kind = Some(failure_kind); } } Ok((idx, Err(err))) => { hard_failures += 1; let failure_kind = classify_lock_error_failure(&err); tracing::warn!("Failed to acquire lock on client {}: {}", idx, err); last_failure = Some(err.to_string()); last_failure_kind = Some(failure_kind); last_hard_failure_kind = Some(failure_kind); } Err(err) => { hard_failures += 1; tracing::warn!("Lock acquisition task join failed: {}", err); last_failure = Some(err.to_string()); last_failure_kind = Some(LockAcquireFailureKind::NonRetryable); last_hard_failure_kind = Some(LockAcquireFailureKind::NonRetryable); } } if self.clients.len().saturating_sub(hard_failures) < required_quorum { let rollback_count = individual_locks.len(); Self::spawn_release_cleanup( individual_locks.iter().map(HeldLockEntry::release_entry).collect(), "distributed_lock_quorum_rollback", ); if !pending.is_empty() { Self::spawn_pending_cleanup( pending, self.clients.clone(), fallback_lock_id.clone(), "distributed_lock_failure_cleanup", ); } let mut error = format!( "Unrecoverable quorum failure: {rollback_count}/{required_quorum} required; {hard_failures} clients failed" ); if let Some(last_failure) = last_failure { error.push_str("; last failure: "); error.push_str(&last_failure); } let resp = LockResponse::failure(error, Duration::ZERO); let failure_kind = last_hard_failure_kind.unwrap_or(LockAcquireFailureKind::UnrecoverableQuorum); return Ok(LockAcquireQuorumResult { response: resp, individual_locks, failure_kind: Some(failure_kind), quorum_impossible: true, }); } if individual_locks.len() >= required_quorum { if !pending.is_empty() { Self::spawn_pending_cleanup( pending, self.clients.clone(), fallback_lock_id.clone(), "distributed_lock_success_cleanup", ); } let aggregate_lock_id = generate_aggregate_lock_id(&request.resource); tracing::debug!( "Generated aggregate lock_id {} for {} individual locks on resource {}", aggregate_lock_id, individual_locks.len(), request.resource ); let resp = LockResponse::success( LockInfo { id: aggregate_lock_id, resource: request.resource.clone(), lock_type: request.lock_type, status: LockStatus::Acquired, owner: request.owner.clone(), acquired_at: std::time::SystemTime::now(), expires_at: std::time::SystemTime::now() + request.ttl, last_refreshed: std::time::SystemTime::now(), metadata: request.metadata.clone(), priority: request.priority, wait_start_time: None, }, Duration::ZERO, ); return Ok(LockAcquireQuorumResult { response: resp, individual_locks, failure_kind: None, quorum_impossible: false, }); } if individual_locks.len() + pending.len() < required_quorum { let rollback_count = individual_locks.len(); Self::spawn_release_cleanup( individual_locks.iter().map(HeldLockEntry::release_entry).collect(), "distributed_lock_quorum_rollback", ); if !pending.is_empty() { Self::spawn_pending_cleanup( pending, self.clients.clone(), fallback_lock_id.clone(), "distributed_lock_failure_cleanup", ); } let mut error = format!("Failed to acquire quorum: {rollback_count}/{required_quorum} required"); let (failure_kind, quorum_impossible) = if hard_failures > 0 { error = format!( "Unrecoverable quorum failure: {rollback_count}/{required_quorum} required; {hard_failures} clients failed; {error}" ); (last_hard_failure_kind.unwrap_or(LockAcquireFailureKind::UnrecoverableQuorum), true) } else { (LockAcquireFailureKind::RetryableContention, false) }; if let Some(last_failure) = last_failure { error.push_str("; last failure: "); error.push_str(&last_failure); } let resp = LockResponse::failure(error, Duration::ZERO); return Ok(LockAcquireQuorumResult { response: resp, individual_locks, failure_kind: Some(failure_kind), quorum_impossible, }); } } let rollback_count = individual_locks.len(); Self::spawn_release_cleanup( individual_locks.iter().map(HeldLockEntry::release_entry).collect(), "distributed_lock_quorum_rollback", ); let mut error = format!("Failed to acquire quorum: {rollback_count}/{required_quorum} required"); if let Some(last_failure) = &last_failure { error.push_str("; last failure: "); error.push_str(last_failure); } let resp = LockResponse::failure(error, Duration::ZERO); Ok(LockAcquireQuorumResult { response: resp, individual_locks, failure_kind: Some(if hard_failures > 0 { last_hard_failure_kind.unwrap_or(LockAcquireFailureKind::UnrecoverableQuorum) } else { last_failure_kind.unwrap_or(LockAcquireFailureKind::RetryableContention) }), quorum_impossible: hard_failures > 0, }) } } #[inline(always)] fn record_lock_held_acquire(lock_type: LockType) { match lock_type { LockType::Shared => record_read_lock_held_acquire(), LockType::Exclusive => record_write_lock_held_acquire(), } } #[inline(always)] fn record_lock_held_release(lock_type: LockType) { match lock_type { LockType::Shared => record_read_lock_held_release(), LockType::Exclusive => record_write_lock_held_release(), } } #[cfg(test)] mod tests { use super::{ DistributedLock, LOCK_ACQUIRE_ATTEMPT_TIMEOUT, LOCK_ACQUIRE_RETRY_INITIAL_BACKOFF, LockAcquireFailureKind, LockLostSignal, is_remote_lock_rpc_failure, should_warn_lock_failure, }; use crate::{LockError, LockId, LockInfo, LockRequest, LockResponse, LockStats, LockType, ObjectKey, client::LockClient}; use rand::{SeedableRng as _, TryRng, rngs::StdRng}; use std::assert_matches; use std::convert::Infallible; use std::sync::atomic::{AtomicUsize, Ordering}; use std::{ collections::{HashMap, VecDeque}, sync::{Arc, Mutex}, time::Duration, }; #[derive(Clone, Copy, Debug)] enum RefreshOutcome { Alive, // Ok(true) refresh succeeded SlowAlive(Duration), // Ok(true) after a delayed response NotFound, // Ok(false) remote already lost the lock (reclaimed / never held) RpcError, // Err RPC jitter } struct ConstantRng(u64); impl TryRng for ConstantRng { type Error = Infallible; fn try_next_u32(&mut self) -> Result { Ok(self.0 as u32) } fn try_next_u64(&mut self) -> Result { Ok(self.0) } fn try_fill_bytes(&mut self, dst: &mut [u8]) -> Result<(), Self::Error> { for chunk in dst.chunks_mut(std::mem::size_of::()) { chunk.copy_from_slice(&self.0.to_ne_bytes()[..chunk.len()]); } Ok(()) } } #[test] fn lock_acquire_retry_backoff_uses_bounded_jitter() { let mut rng = StdRng::seed_from_u64(42); let base = LOCK_ACQUIRE_RETRY_INITIAL_BACKOFF * 3; let jitter = base / 4; let lower = base - jitter; let upper = base + jitter; let samples = (0..32) .map(|_| DistributedLock::lock_acquire_retry_backoff(3, &mut rng)) .collect::>(); assert!(samples.iter().all(|delay| *delay >= lower && *delay <= upper)); assert!(samples.windows(2).any(|pair| pair[0] != pair[1])); let mut lower_rng = ConstantRng(0); let mut upper_rng = ConstantRng(u64::MAX); assert_eq!(DistributedLock::lock_acquire_retry_backoff(3, &mut lower_rng), lower); assert_eq!(DistributedLock::lock_acquire_retry_backoff(3, &mut upper_rng), upper); } /// Counting test client: acquires successfully and echoes back request.lock_id as /// the per-client id, so the guard heartbeat calls refresh with that id; refresh /// increments a counter and returns per the configured outcome. #[derive(Debug)] struct RefreshCountingClient { refresh_calls: Arc, outcome: RefreshOutcome, active: tokio::sync::Mutex>, } impl RefreshCountingClient { fn new(outcome: RefreshOutcome, counter: Arc) -> Arc { Arc::new(Self { refresh_calls: counter, outcome, active: tokio::sync::Mutex::new(std::collections::HashSet::new()), }) } } #[async_trait::async_trait] impl LockClient for RefreshCountingClient { async fn acquire_lock(&self, request: &LockRequest) -> crate::Result { self.active.lock().await.insert(request.lock_id.clone()); let info = LockInfo { id: request.lock_id.clone(), resource: request.resource.clone(), lock_type: request.lock_type, status: crate::types::LockStatus::Acquired, owner: request.owner.clone(), acquired_at: std::time::SystemTime::now(), expires_at: std::time::SystemTime::now() + request.ttl, last_refreshed: std::time::SystemTime::now(), metadata: request.metadata.clone(), priority: request.priority, wait_start_time: None, }; Ok(LockResponse::success(info, Duration::ZERO)) } async fn release(&self, lock_id: &LockId) -> crate::Result { Ok(self.active.lock().await.remove(lock_id)) } async fn refresh(&self, _lock_id: &LockId) -> crate::Result { self.refresh_calls.fetch_add(1, Ordering::SeqCst); match self.outcome { RefreshOutcome::Alive => Ok(true), RefreshOutcome::SlowAlive(delay) => { tokio::time::sleep(delay).await; Ok(true) } RefreshOutcome::NotFound => Ok(false), RefreshOutcome::RpcError => Err(LockError::internal("refresh rpc jitter")), } } async fn force_release(&self, lock_id: &LockId) -> crate::Result { self.release(lock_id).await } async fn check_status(&self, _lock_id: &LockId) -> crate::Result> { Ok(None) } async fn get_stats(&self) -> crate::Result { Ok(LockStats::default()) } async fn close(&self) -> crate::Result<()> { Ok(()) } async fn is_online(&self) -> bool { true } async fn is_local(&self) -> bool { false } } fn counting_clients(outcomes: &[RefreshOutcome]) -> (Vec>, Vec>) { let mut clients: Vec> = Vec::new(); let mut counters = Vec::new(); for outcome in outcomes { let counter = Arc::new(AtomicUsize::new(0)); clients.push(RefreshCountingClient::new(*outcome, counter.clone()) as Arc); counters.push(counter); } (clients, counters) } // A1 -- heartbeat broadcasts refresh every interval; a live lock stays refreshed (#899 keepalive). #[tokio::test] async fn heartbeat_keeps_distributed_lock_refreshed() { let (clients, counters) = counting_clients(&[ RefreshOutcome::Alive, RefreshOutcome::Alive, RefreshOutcome::Alive, RefreshOutcome::Alive, ]); let lock = DistributedLock::new("test".to_string(), clients, 3); let request = LockRequest::new(ObjectKey::new("bucket", "object"), LockType::Exclusive, "owner") .with_acquire_timeout(Duration::from_millis(200)) .with_ttl(Duration::from_millis(120)) .with_refresh_interval(Duration::from_millis(20)); // interval < ttl -> spawn let guard = lock .acquire_guard(&request) .await .expect("acquire should not error") .expect("quorum should be reached"); tokio::time::sleep(Duration::from_millis(90)).await; // spans >2 intervals // The guard tracks exactly the quorum of per-client locks (excess successes are cleaned // up on acquire), so the heartbeat broadcasts to at least `quorum` clients. let refreshed_clients = counters.iter().filter(|c| c.load(Ordering::SeqCst) >= 1).count(); assert!( refreshed_clients >= 3, "heartbeat should refresh at least the quorum of tracked clients, got {refreshed_clients}" ); assert!(!guard.is_lock_lost(), "all-alive refresh must not signal lock loss"); drop(guard); } // A2 -- a quorum-one distributed lease still requires renewal. #[tokio::test] async fn single_client_guard_keeps_backend_lease_refreshed() { let (clients, counters) = counting_clients(&[RefreshOutcome::Alive]); let lock = DistributedLock::new("test".to_string(), clients, 1); let request = LockRequest::new(ObjectKey::new("bucket", "object"), LockType::Exclusive, "owner") .with_ttl(Duration::from_millis(120)) .with_refresh_interval(Duration::from_millis(20)); let guard = lock .acquire_guard(&request) .await .expect("single-client lock acquisition should not error") .expect("single-client lock quorum should be reached"); assert!( guard.refresh_task.is_some(), "single-client distributed guard must spawn a background task" ); tokio::time::timeout(Duration::from_secs(2), async { while counters[0].load(Ordering::SeqCst) < 2 { tokio::time::sleep(Duration::from_millis(5)).await; } }) .await .expect("single-client distributed guard should renew before the test deadline"); assert!( counters[0].load(Ordering::SeqCst) >= 2, "single-client distributed guard must renew beyond the original backend TTL" ); assert!(!guard.is_lock_lost(), "successful quorum-one refresh must preserve the lease"); drop(guard); } // A3 -- dropping the guard aborts the heartbeat before release; no refresh lands after drop. #[tokio::test] async fn dropping_guard_stops_heartbeat() { let (clients, counters) = counting_clients(&[ RefreshOutcome::Alive, RefreshOutcome::Alive, RefreshOutcome::Alive, RefreshOutcome::Alive, ]); let lock = DistributedLock::new("test".to_string(), clients, 3); let request = LockRequest::new(ObjectKey::new("bucket", "object"), LockType::Exclusive, "owner") .with_ttl(Duration::from_millis(120)) .with_refresh_interval(Duration::from_millis(20)); let guard = lock .acquire_guard(&request) .await .expect("distributed lock acquisition should not error") .expect("distributed lock quorum should be reached"); tokio::time::sleep(Duration::from_millis(50)).await; drop(guard); // should abort the heartbeat first let after_drop: Vec = counters.iter().map(|c| c.load(Ordering::SeqCst)).collect(); tokio::time::sleep(Duration::from_millis(80)).await; // wait >2 more intervals for (idx, counter) in counters.iter().enumerate() { assert_eq!( counter.load(Ordering::SeqCst), after_drop[idx], "client {idx} must not be refreshed after guard drop" ); } } // A4 -- losing refresh quorum (not_found > n - refresh_quorum) signals lock loss. // 4 clients / write-quorum 3 -> n-quorum = 1; 2 NotFound (>1) -> lost. #[tokio::test] async fn heartbeat_quorum_loss_signals_lock_lost() { let (clients, _counters) = counting_clients(&[ RefreshOutcome::NotFound, RefreshOutcome::NotFound, RefreshOutcome::Alive, RefreshOutcome::Alive, ]); let lock = DistributedLock::new("test".to_string(), clients, 3); let request = LockRequest::new(ObjectKey::new("bucket", "object"), LockType::Exclusive, "owner") .with_ttl(Duration::from_millis(120)) .with_refresh_interval(Duration::from_millis(20)); let guard = lock .acquire_guard(&request) .await .expect("distributed lock acquisition should not error") .expect("distributed lock quorum should be reached"); tokio::time::sleep(Duration::from_millis(60)).await; // at least one tick assert!( guard.is_lock_lost(), "losing refresh quorum (2 not_found of 4, quorum 3) must signal lock loss" ); let signal = guard.lock_lost(); tokio::time::timeout(Duration::from_millis(20), signal.notified()) .await .expect("lock_lost().notified() must resolve once quorum lost"); drop(guard); } #[tokio::test] async fn lock_lost_notification_cannot_be_missed_after_waiter_registration() { let signal = LockLostSignal::default(); tokio::time::timeout(Duration::from_secs(2), signal.notified_after_registration(|| signal.mark_lost())) .await .expect("loss between waiter registration and state recheck must still resolve"); } #[tokio::test] async fn expired_lease_is_fenced_without_waiting_for_monitor_scheduling() { let signal = LockLostSignal::default(); signal.set_valid_until(tokio::time::Instant::now()); assert!(signal.is_lost(), "an elapsed backend deadline must fence the owner immediately"); tokio::time::timeout(Duration::from_secs(2), signal.notified()) .await .expect("an elapsed backend deadline must resolve lock-loss waiters immediately"); } #[tokio::test] async fn deadline_notification_permanently_fences_the_signal() { let signal = LockLostSignal::default(); signal.set_valid_until(tokio::time::Instant::now() + Duration::from_millis(10)); tokio::time::timeout(Duration::from_secs(2), signal.notified()) .await .expect("the backend deadline must resolve lock-loss waiters"); assert!( signal.lost.load(Ordering::SeqCst), "deadline notification must persist the lock-loss fence" ); } #[test] fn expired_lease_cannot_be_revived_by_a_later_deadline() { let signal = LockLostSignal::default(); signal.set_valid_until(tokio::time::Instant::now()); assert!(signal.is_lost(), "an elapsed lease must become permanently fenced"); signal.set_valid_until(tokio::time::Instant::now() + Duration::from_secs(60)); assert!(signal.is_lost(), "a later heartbeat must not revive an expired lease"); } // Phase 2 passthrough: NamespaceLockGuard::Standard must forward the distributed // guard's lock-loss signal so the write path can fence its commit on it. #[tokio::test] async fn namespace_guard_forwards_lock_lost() { let (clients, _counters) = counting_clients(&[ RefreshOutcome::NotFound, RefreshOutcome::NotFound, RefreshOutcome::Alive, RefreshOutcome::Alive, ]); let lock = DistributedLock::new("test".to_string(), clients, 3); let request = LockRequest::new(ObjectKey::new("bucket", "object"), LockType::Exclusive, "owner") .with_ttl(Duration::from_millis(120)) .with_refresh_interval(Duration::from_millis(20)); let guard = lock .acquire_guard(&request) .await .expect("namespace lock acquisition should not error") .expect("namespace lock quorum should be reached"); tokio::time::sleep(Duration::from_millis(60)).await; // at least one tick -> lost let ns_guard = crate::namespace::NamespaceLockGuard::Standard(guard); assert!( ns_guard.is_lock_lost(), "NamespaceLockGuard::Standard must forward the distributed guard's lock-loss signal" ); } // A5 -- refresh RPC jitter is tolerated within the lease, but an unconfirmed lease cannot // remain valid after its backend TTL. #[tokio::test] async fn heartbeat_rpc_error_expires_at_backend_ttl() { let (clients, _counters) = counting_clients(&[ RefreshOutcome::RpcError, RefreshOutcome::RpcError, RefreshOutcome::Alive, RefreshOutcome::Alive, ]); let lock = DistributedLock::new("test".to_string(), clients, 3); let request = LockRequest::new(ObjectKey::new("bucket", "object"), LockType::Exclusive, "owner") .with_ttl(Duration::from_millis(120)) .with_refresh_interval(Duration::from_millis(20)); let guard = lock .acquire_guard(&request) .await .expect("distributed lock acquisition should not error") .expect("distributed lock quorum should be reached"); tokio::time::sleep(Duration::from_millis(60)).await; assert!( !guard.is_lock_lost(), "transient RPC jitter before the backend TTL must not immediately signal lock loss" ); let signal = guard.lock_lost(); tokio::time::timeout(Duration::from_secs(2), signal.notified()) .await .expect("unconfirmed leases must signal lock loss when their backend TTL expires"); assert!(guard.is_lock_lost(), "the guard must fail closed once refresh quorum expires"); drop(guard); } // A6 -- a refresh call that remains in flight cannot keep an expired quorum alive. #[tokio::test] async fn heartbeat_slow_refresh_is_fenced_by_backend_ttl() { let (clients, _counters) = counting_clients(&[RefreshOutcome::SlowAlive(Duration::from_millis(200)), RefreshOutcome::Alive]); let lock = DistributedLock::new("test".to_string(), clients, 2); let request = LockRequest::new(ObjectKey::new("bucket", "object"), LockType::Exclusive, "owner") .with_ttl(Duration::from_millis(120)) .with_refresh_interval(Duration::from_millis(20)); let guard = lock .acquire_guard(&request) .await .expect("distributed lock acquisition should not error") .expect("distributed lock quorum should be reached"); let signal = guard.lock_lost(); tokio::time::timeout(Duration::from_secs(2), signal.notified()) .await .expect("an in-flight refresh must not extend an unconfirmed lease past its TTL"); assert!( guard.is_lock_lost(), "slow refresh must fail closed at the last confirmed quorum deadline" ); drop(guard); } // A7 -- sub-second ttl boundary: no panic, and skip heartbeat when interval>=ttl // (fixes clamp panic; aligns with the 50ms-ttl distributed path in namespace tests). #[tokio::test] async fn subsecond_ttl_guard_does_not_panic_and_skips_heartbeat() { // tiny ttl; the point is that acquire must not panic. let (clients, _counters) = counting_clients(&[RefreshOutcome::Alive, RefreshOutcome::Alive, RefreshOutcome::Alive]); let lock = DistributedLock::new("test".to_string(), clients, 2); let request = LockRequest::new(ObjectKey::new("bucket", "object"), LockType::Exclusive, "owner") .with_acquire_timeout(Duration::from_millis(100)) .with_ttl(Duration::from_millis(50)); // a clamp(1s, ttl-1s) would panic here let guard = lock .acquire_guard(&request) .await .expect("subsecond ttl acquire must not panic") .expect("quorum should be reached"); drop(guard); // inject interval >= ttl -> degenerate to no spawn let (clients2, counters2) = counting_clients(&[RefreshOutcome::Alive, RefreshOutcome::Alive, RefreshOutcome::Alive]); let lock2 = DistributedLock::new("test".to_string(), clients2, 2); let request2 = LockRequest::new(ObjectKey::new("bucket", "object2"), LockType::Exclusive, "owner") .with_ttl(Duration::from_millis(50)) .with_refresh_interval(Duration::from_millis(50)); // interval >= ttl -> None let guard2 = lock2 .acquire_guard(&request2) .await .expect("subsecond lock acquisition should not error") .expect("subsecond lock quorum should be reached"); assert!( guard2.refresh_task.is_none(), "non-renewable subsecond guard must not spawn a background task" ); tokio::time::timeout(Duration::from_secs(2), guard2.lock_lost().notified()) .await .expect("non-renewable subsecond guard must signal loss at its backend TTL"); for c in &counters2 { assert_eq!(c.load(Ordering::SeqCst), 0, "interval>=ttl must not refresh the backend lease"); } assert!(guard2.is_lock_lost(), "interval>=ttl guard must fail closed after backend TTL"); drop(guard2); } // A8 -- disarm() must also stop the heartbeat (public API gap). #[tokio::test] async fn disarm_stops_heartbeat() { let (clients, counters) = counting_clients(&[ RefreshOutcome::Alive, RefreshOutcome::Alive, RefreshOutcome::Alive, RefreshOutcome::Alive, ]); let lock = DistributedLock::new("test".to_string(), clients, 3); let request = LockRequest::new(ObjectKey::new("bucket", "object"), LockType::Exclusive, "owner") .with_ttl(Duration::from_millis(120)) .with_refresh_interval(Duration::from_millis(20)); let mut guard = lock .acquire_guard(&request) .await .expect("distributed lock acquisition should not error") .expect("distributed lock quorum should be reached"); tokio::time::sleep(Duration::from_millis(50)).await; guard.disarm(); // should abort the heartbeat let after_disarm: Vec = counters.iter().map(|c| c.load(Ordering::SeqCst)).collect(); tokio::time::sleep(Duration::from_millis(80)).await; for (idx, counter) in counters.iter().enumerate() { assert_eq!( counter.load(Ordering::SeqCst), after_disarm[idx], "disarm() must stop the heartbeat for client {idx}" ); } drop(guard); } #[derive(Debug)] struct ResponseClient { response: LockResponse, delay: Duration, } impl ResponseClient { fn new(response: LockResponse) -> Self { Self { response, delay: Duration::ZERO, } } fn with_delay(mut self, delay: Duration) -> Self { self.delay = delay; self } fn into_client(self) -> Arc { Arc::new(self) } } #[async_trait::async_trait] impl LockClient for ResponseClient { async fn acquire_lock(&self, _request: &LockRequest) -> crate::Result { if !self.delay.is_zero() { tokio::time::sleep(self.delay).await; } Ok(self.response.clone()) } async fn release(&self, _lock_id: &LockId) -> crate::Result { Ok(false) } async fn refresh(&self, _lock_id: &LockId) -> crate::Result { Ok(false) } async fn force_release(&self, _lock_id: &LockId) -> crate::Result { Ok(false) } async fn check_status(&self, _lock_id: &LockId) -> crate::Result> { Ok(None) } async fn get_stats(&self) -> crate::Result { Ok(LockStats::default()) } async fn close(&self) -> crate::Result<()> { Ok(()) } async fn is_online(&self) -> bool { true } async fn is_local(&self) -> bool { false } } #[derive(Debug)] struct TimeoutRecordingClient { seen_timeouts: Arc>>, } impl TimeoutRecordingClient { fn new(seen_timeouts: Arc>>) -> Self { Self { seen_timeouts } } fn into_client(self) -> Arc { Arc::new(self) } } #[async_trait::async_trait] impl LockClient for TimeoutRecordingClient { async fn acquire_lock(&self, request: &LockRequest) -> crate::Result { self.seen_timeouts.lock().unwrap().push(request.acquire_timeout); Ok(LockResponse::failure("Lock acquisition timeout", Duration::ZERO)) } async fn release(&self, _lock_id: &LockId) -> crate::Result { Ok(false) } async fn refresh(&self, _lock_id: &LockId) -> crate::Result { Ok(false) } async fn force_release(&self, _lock_id: &LockId) -> crate::Result { Ok(false) } async fn check_status(&self, _lock_id: &LockId) -> crate::Result> { Ok(None) } async fn get_stats(&self) -> crate::Result { Ok(LockStats::default()) } async fn close(&self) -> crate::Result<()> { Ok(()) } async fn is_online(&self) -> bool { true } async fn is_local(&self) -> bool { false } } #[derive(Debug, Clone, Copy)] enum AcquirePlan { Success { delay: Duration }, Failure { error: &'static str, delay: Duration }, ClientError { message: &'static str, delay: Duration }, } #[derive(Debug)] struct SequencedClient { plans: Mutex>, active: tokio::sync::Mutex>, seen_ids: Arc>>, } impl SequencedClient { fn new(plans: Vec, seen_ids: Arc>>) -> Self { Self { plans: Mutex::new(plans.into()), active: tokio::sync::Mutex::new(HashMap::new()), seen_ids, } } } #[async_trait::async_trait] impl LockClient for SequencedClient { async fn acquire_lock(&self, request: &LockRequest) -> crate::Result { self.seen_ids.lock().unwrap().push(request.lock_id.clone()); let plan = self .plans .lock() .unwrap() .pop_front() .unwrap_or(AcquirePlan::Success { delay: Duration::ZERO }); match plan { AcquirePlan::Success { delay } => { if !delay.is_zero() { tokio::time::sleep(delay).await; } let lock_info = LockInfo { id: request.lock_id.clone(), resource: request.resource.clone(), lock_type: request.lock_type, status: crate::types::LockStatus::Acquired, owner: request.owner.clone(), acquired_at: std::time::SystemTime::now(), expires_at: std::time::SystemTime::now() + request.ttl, last_refreshed: std::time::SystemTime::now(), metadata: request.metadata.clone(), priority: request.priority, wait_start_time: None, }; self.active.lock().await.insert(request.lock_id.clone(), lock_info.clone()); Ok(LockResponse::success(lock_info, Duration::ZERO)) } AcquirePlan::Failure { error, delay } => { if !delay.is_zero() { tokio::time::sleep(delay).await; } Ok(LockResponse::failure(error, Duration::ZERO)) } AcquirePlan::ClientError { message, delay } => { if !delay.is_zero() { tokio::time::sleep(delay).await; } Err(LockError::internal(message)) } } } async fn release(&self, lock_id: &LockId) -> crate::Result { Ok(self.active.lock().await.remove(lock_id).is_some()) } async fn refresh(&self, _lock_id: &LockId) -> crate::Result { Ok(false) } async fn force_release(&self, lock_id: &LockId) -> crate::Result { self.release(lock_id).await } async fn check_status(&self, lock_id: &LockId) -> crate::Result> { Ok(self.active.lock().await.get(lock_id).cloned()) } async fn get_stats(&self) -> crate::Result { Ok(LockStats::default()) } async fn close(&self) -> crate::Result<()> { Ok(()) } async fn is_online(&self) -> bool { true } async fn is_local(&self) -> bool { false } } #[test] fn test_is_remote_lock_rpc_failure() { assert!(is_remote_lock_rpc_failure("Remote lock RPC failed: backend unreachable")); assert!(is_remote_lock_rpc_failure("remote lock rpc failed: temporary network issue")); assert!(is_remote_lock_rpc_failure("Remote lock RPC timed out: RPC timed out after 50ms")); assert!(!is_remote_lock_rpc_failure("Lock is already held")); assert!(!is_remote_lock_rpc_failure("acquired lock failed for other reason")); } #[test] fn test_should_warn_lock_failure() { assert!(should_warn_lock_failure("Remote lock RPC failed: backend unreachable")); assert!(should_warn_lock_failure("permission denied")); assert!(should_warn_lock_failure("Unrecoverable quorum failure: 1/3 required")); assert!(!should_warn_lock_failure("Remote lock RPC timed out: RPC timed out after 50ms")); assert!(!should_warn_lock_failure("Lock acquisition timeout")); } #[tokio::test] async fn acquire_guard_returns_quorum_error_when_rpc_failures_make_quorum_impossible() { let clients: Vec> = vec![ ResponseClient::new(LockResponse::failure("Remote lock RPC failed: node unavailable", Duration::ZERO)).into_client(), ResponseClient::new(LockResponse::failure("lock already held", Duration::ZERO)) .with_delay(Duration::from_millis(50)) .into_client(), ResponseClient::new(LockResponse::failure("Remote lock RPC failed: connection refused", Duration::ZERO)) .into_client(), ResponseClient::new(LockResponse::failure("lock already held", Duration::ZERO)) .with_delay(Duration::from_millis(50)) .into_client(), ]; let lock = DistributedLock::new("test".to_string(), clients, 3); let request = LockRequest::new(ObjectKey::new("bucket", "object"), LockType::Exclusive, "owner") .with_acquire_timeout(Duration::from_secs(1)); let result = lock.acquire_guard(&request).await; assert!( matches!( result, Err(LockError::QuorumNotReached { required: 3, achieved: 0 }) ), "unexpected result: {result:?}" ); } #[tokio::test] async fn acquire_guard_retries_rpc_timeouts_until_caller_timeout() { let clients: Vec> = vec![ ResponseClient::new(LockResponse::failure( "Remote lock RPC timed out: RPC timed out after 50ms", Duration::ZERO, )) .into_client(), ResponseClient::new(LockResponse::failure("lock already held", Duration::ZERO)) .with_delay(Duration::from_millis(50)) .into_client(), ResponseClient::new(LockResponse::failure( "Remote lock RPC timed out: RPC timed out after 50ms", Duration::ZERO, )) .into_client(), ResponseClient::new(LockResponse::failure("lock already held", Duration::ZERO)) .with_delay(Duration::from_millis(50)) .into_client(), ]; let lock = DistributedLock::new("test".to_string(), clients, 3); let request = LockRequest::new(ObjectKey::new("bucket", "object"), LockType::Exclusive, "owner") .with_acquire_timeout(Duration::from_millis(900)); let started = tokio::time::Instant::now(); let result = lock.acquire_guard(&request).await; let elapsed = started.elapsed(); assert_matches!(result, Ok(None)); assert!( elapsed >= Duration::from_millis(250), "remote RPC timeouts should be retried within the caller timeout, got {elapsed:?}" ); } #[tokio::test] async fn acquire_guard_retries_remote_rpc_failures_that_temporarily_preclude_quorum() { let clients: Vec> = vec![ Arc::new(SequencedClient::new( vec![ AcquirePlan::Failure { error: "Remote lock RPC failed: node unavailable", delay: Duration::ZERO, }, AcquirePlan::Success { delay: Duration::ZERO }, ], Arc::new(Mutex::new(Vec::new())), )), Arc::new(SequencedClient::new( vec![ AcquirePlan::Failure { error: "Remote lock RPC failed: connection reset", delay: Duration::ZERO, }, AcquirePlan::Success { delay: Duration::ZERO }, ], Arc::new(Mutex::new(Vec::new())), )), Arc::new(SequencedClient::new( vec![ AcquirePlan::Success { delay: Duration::ZERO }, AcquirePlan::Success { delay: Duration::ZERO }, ], Arc::new(Mutex::new(Vec::new())), )), Arc::new(SequencedClient::new( vec![ AcquirePlan::Success { delay: Duration::ZERO }, AcquirePlan::Success { delay: Duration::ZERO }, ], Arc::new(Mutex::new(Vec::new())), )), ]; let lock = DistributedLock::new("test".to_string(), clients, 3); let request = LockRequest::new(ObjectKey::new("bucket", "object"), LockType::Exclusive, "owner") .with_acquire_timeout(Duration::from_secs(2)); let guard = lock .acquire_guard(&request) .await .expect("transient remote RPC quorum gap should retry") .expect("second attempt should acquire quorum"); assert!(guard.entries.len() >= 3, "retry should acquire quorum entries"); drop(guard); } #[tokio::test] async fn acquire_guard_retries_client_errors_that_temporarily_preclude_quorum() { let clients: Vec> = vec![ Arc::new(SequencedClient::new( vec![ AcquirePlan::ClientError { message: "can not get client, err: temporary transport unavailable", delay: Duration::ZERO, }, AcquirePlan::Success { delay: Duration::ZERO }, ], Arc::new(Mutex::new(Vec::new())), )), Arc::new(SequencedClient::new( vec![ AcquirePlan::ClientError { message: "can not get client, err: connection pool busy", delay: Duration::ZERO, }, AcquirePlan::Success { delay: Duration::ZERO }, ], Arc::new(Mutex::new(Vec::new())), )), Arc::new(SequencedClient::new( vec![ AcquirePlan::Success { delay: Duration::ZERO }, AcquirePlan::Success { delay: Duration::ZERO }, ], Arc::new(Mutex::new(Vec::new())), )), Arc::new(SequencedClient::new( vec![ AcquirePlan::Success { delay: Duration::ZERO }, AcquirePlan::Success { delay: Duration::ZERO }, ], Arc::new(Mutex::new(Vec::new())), )), ]; let lock = DistributedLock::new("test".to_string(), clients, 3); let request = LockRequest::new(ObjectKey::new("bucket", "object"), LockType::Exclusive, "owner") .with_acquire_timeout(Duration::from_secs(2)); let guard = lock .acquire_guard(&request) .await .expect("retryable client error quorum gap should retry") .expect("second attempt should acquire quorum"); assert!(guard.entries.len() >= 3, "retry should acquire quorum entries"); drop(guard); } #[tokio::test] async fn acquire_guard_returns_timeout_when_zero_locks_make_quorum_impossible_for_attempt() { let clients: Vec> = vec![ ResponseClient::new(LockResponse::failure("lock already held", Duration::ZERO)).into_client(), ResponseClient::new(LockResponse::failure("lock already held", Duration::ZERO)).into_client(), ResponseClient::new(LockResponse::failure("lock already held", Duration::ZERO)) .with_delay(Duration::from_secs(1)) .into_client(), ResponseClient::new(LockResponse::failure("lock already held", Duration::ZERO)) .with_delay(Duration::from_secs(1)) .into_client(), ]; let lock = DistributedLock::new("test".to_string(), clients, 3); let request = LockRequest::new(ObjectKey::new("bucket", "object"), LockType::Exclusive, "owner") .with_acquire_timeout(Duration::from_millis(120)); let started = tokio::time::Instant::now(); let result = lock.acquire_guard(&request).await; assert_matches!(result, Ok(None)); assert!( started.elapsed() < Duration::from_secs(1), "acquire should fail this attempt before waiting for delayed impossible-quorum tasks" ); } #[tokio::test] async fn acquire_guard_retries_attempt_timeout_when_hard_failure_does_not_preclude_quorum() { let clients: Vec> = vec![ Arc::new(SequencedClient::new( vec![ AcquirePlan::Failure { error: "Remote lock RPC failed: node unavailable", delay: Duration::ZERO, }, AcquirePlan::Success { delay: Duration::ZERO }, ], Arc::new(Mutex::new(Vec::new())), )), Arc::new(SequencedClient::new( vec![ AcquirePlan::Success { delay: Duration::from_millis(1200), }, AcquirePlan::Success { delay: Duration::ZERO }, ], Arc::new(Mutex::new(Vec::new())), )), Arc::new(SequencedClient::new( vec![ AcquirePlan::Success { delay: Duration::from_millis(1200), }, AcquirePlan::Success { delay: Duration::ZERO }, ], Arc::new(Mutex::new(Vec::new())), )), Arc::new(SequencedClient::new( vec![ AcquirePlan::Success { delay: Duration::from_millis(1200), }, AcquirePlan::Success { delay: Duration::ZERO }, ], Arc::new(Mutex::new(Vec::new())), )), ]; let lock = DistributedLock::new("test".to_string(), clients, 3); let request = LockRequest::new(ObjectKey::new("bucket", "object"), LockType::Exclusive, "owner") .with_acquire_timeout(Duration::from_secs(2)); let guard = lock .acquire_guard(&request) .await .expect("attempt timeout with possible quorum should retry") .expect("second attempt should acquire quorum"); assert_eq!(guard.entries.len(), 3); drop(guard); } #[tokio::test] async fn acquire_quorum_timeout_preserves_non_retryable_failure() { let clients: Vec> = vec![ ResponseClient::new(LockResponse::failure("permission denied", Duration::ZERO)).into_client(), ResponseClient::new(LockResponse::failure("lock already held", Duration::ZERO)) .with_delay(Duration::from_secs(1)) .into_client(), ResponseClient::new(LockResponse::failure("lock already held", Duration::ZERO)) .with_delay(Duration::from_secs(1)) .into_client(), ]; let lock = DistributedLock::new("test".to_string(), clients, 2); let request = LockRequest::new(ObjectKey::new("bucket", "object"), LockType::Exclusive, "owner") .with_acquire_timeout(Duration::from_millis(120)); let result = lock.acquire_lock_quorum_with_retry(&request).await.unwrap(); assert_matches!(result.failure_kind, Some(LockAcquireFailureKind::NonRetryable)); assert!( result .response .error .as_deref() .is_some_and(|error| error.contains("permission denied")), "unexpected response: {:?}", result.response ); } #[tokio::test] async fn acquire_guard_retries_transient_timeout_before_quorum() { let clients: Vec> = vec![ ResponseClient::new(LockResponse::failure( "Timeout { resource: \"bucket/object-flaky-acquire@latest\", timeout: 1s }", Duration::ZERO, )) .into_client(), ResponseClient::new(LockResponse::failure( "Timeout { resource: \"bucket/object-flaky-acquire@latest\", timeout: 1s }", Duration::ZERO, )) .into_client(), ResponseClient::new(LockResponse::failure( "Timeout { resource: \"bucket/object-flaky-acquire@latest\", timeout: 1s }", Duration::ZERO, )) .into_client(), ResponseClient::new(LockResponse::failure( "Timeout { resource: \"bucket/object-flaky-acquire@latest\", timeout: 1s }", Duration::ZERO, )) .into_client(), ]; let lock = DistributedLock::new("test".to_string(), clients, 3); let request = LockRequest::new(ObjectKey::new("bucket", "object"), LockType::Exclusive, "owner") .with_acquire_timeout(Duration::from_millis(900)); let started = tokio::time::Instant::now(); let result = lock.acquire_guard(&request).await; let elapsed = started.elapsed(); assert_matches!(result, Ok(None)); assert!( elapsed >= Duration::from_millis(250), "expected at least one retry attempt for transient timeout, got {elapsed:?}" ); } #[tokio::test] async fn acquire_guard_uses_bounded_distributed_attempt_timeout() { let seen_timeouts = Arc::new(Mutex::new(Vec::new())); let clients: Vec> = (0..4) .map(|_| TimeoutRecordingClient::new(seen_timeouts.clone()).into_client()) .collect(); let lock = DistributedLock::new("test".to_string(), clients, 3); let request = LockRequest::new(ObjectKey::new("bucket", "object"), LockType::Exclusive, "owner") .with_acquire_timeout(Duration::from_millis(1500)); let result = lock.acquire_guard(&request).await; assert_matches!(result, Ok(None)); let seen_timeouts = seen_timeouts.lock().unwrap(); assert!(!seen_timeouts.is_empty(), "expected lock clients to observe acquire timeouts"); assert!( seen_timeouts.iter().all(|timeout| *timeout <= LOCK_ACQUIRE_ATTEMPT_TIMEOUT), "distributed attempts should be bounded by {LOCK_ACQUIRE_ATTEMPT_TIMEOUT:?}, saw {seen_timeouts:?}" ); } #[tokio::test] async fn acquire_guard_enforces_attempt_timeout_when_clients_ignore_budget() { let clients: Vec> = (0..4) .map(|_| { ResponseClient::new(LockResponse::failure("lock already held", Duration::ZERO)) .with_delay(Duration::from_secs(5)) .into_client() }) .collect(); let lock = DistributedLock::new("test".to_string(), clients, 3); let request = LockRequest::new(ObjectKey::new("bucket", "object"), LockType::Exclusive, "owner") .with_acquire_timeout(Duration::from_millis(200)); let result = tokio::time::timeout(Duration::from_millis(800), lock.acquire_guard(&request)).await; assert_matches!(result, Ok(Ok(None))); } #[tokio::test] async fn acquire_guard_retries_partial_quorum_after_rollback() { let seen_ids = Arc::new(Mutex::new(Vec::new())); let clients: Vec> = vec![ Arc::new(SequencedClient::new( vec![ AcquirePlan::Success { delay: Duration::ZERO }, AcquirePlan::Success { delay: Duration::ZERO }, ], seen_ids.clone(), )), Arc::new(SequencedClient::new( vec![ AcquirePlan::Failure { error: "Lock acquisition timeout", delay: Duration::ZERO, }, AcquirePlan::Success { delay: Duration::ZERO }, ], seen_ids.clone(), )), Arc::new(SequencedClient::new( vec![ AcquirePlan::Failure { error: "Lock acquisition timeout", delay: Duration::ZERO, }, AcquirePlan::Success { delay: Duration::ZERO }, ], seen_ids.clone(), )), Arc::new(SequencedClient::new( vec![ AcquirePlan::Failure { error: "Lock acquisition timeout", delay: Duration::ZERO, }, AcquirePlan::Success { delay: Duration::ZERO }, ], seen_ids.clone(), )), ]; let lock = DistributedLock::new("test".to_string(), clients, 3); let request = LockRequest::new(ObjectKey::new("bucket", "object"), LockType::Exclusive, "owner") .with_acquire_timeout(Duration::from_secs(2)); let guard = lock .acquire_guard(&request) .await .expect("partial quorum retry should not fail") .expect("second attempt should reach quorum"); let unique_id_count = seen_ids .lock() .unwrap() .iter() .fold(Vec::::new(), |mut uuids, lock_id| { if !uuids.iter().any(|uuid| uuid == &lock_id.uuid) { uuids.push(lock_id.uuid.clone()); } uuids }) .len(); assert!(unique_id_count >= 2, "partial quorum should retry with a fresh lock id"); drop(guard); } #[tokio::test(start_paused = true)] async fn acquire_guard_returns_timeout_when_quorum_remains_contended() { let clients: Vec> = vec![ ResponseClient::new(LockResponse::failure("lock already held", Duration::ZERO)).into_client(), ResponseClient::new(LockResponse::failure("lock already held", Duration::ZERO)).into_client(), ResponseClient::new(LockResponse::failure("lock already held", Duration::ZERO)).into_client(), ResponseClient::new(LockResponse::failure("lock already held", Duration::ZERO)).into_client(), ]; let lock = DistributedLock::new("test".to_string(), clients, 3); let request = LockRequest::new(ObjectKey::new("bucket", "object"), LockType::Exclusive, "owner") .with_acquire_timeout(Duration::from_millis(120)); let result = tokio::time::timeout(Duration::from_secs(1), lock.acquire_guard(&request)).await; assert_matches!(result, Ok(Ok(None))); } #[tokio::test] async fn acquire_guard_uses_fresh_lock_ids_across_retry_attempts() { let seen_ids = Arc::new(Mutex::new(Vec::new())); let delayed_success_a = Arc::new(SequencedClient::new( vec![ AcquirePlan::Success { delay: Duration::from_millis(400), }, AcquirePlan::Success { delay: Duration::ZERO }, ], seen_ids.clone(), )); let delayed_success_b = Arc::new(SequencedClient::new( vec![ AcquirePlan::Success { delay: Duration::from_millis(400), }, AcquirePlan::Success { delay: Duration::ZERO }, ], seen_ids.clone(), )); let clients: Vec> = vec![ Arc::new(SequencedClient::new( vec![ AcquirePlan::Failure { error: "Lock acquisition timeout", delay: Duration::ZERO, }, AcquirePlan::Success { delay: Duration::ZERO }, ], seen_ids.clone(), )), Arc::new(SequencedClient::new( vec![ AcquirePlan::Failure { error: "Lock acquisition timeout", delay: Duration::ZERO, }, AcquirePlan::Success { delay: Duration::ZERO }, ], seen_ids.clone(), )), delayed_success_a.clone(), delayed_success_b.clone(), ]; let lock = DistributedLock::new("test".to_string(), clients, 3); let request = LockRequest::new(ObjectKey::new("bucket", "object"), LockType::Exclusive, "owner") .with_acquire_timeout(Duration::from_secs(2)); let guard = lock .acquire_guard(&request) .await .expect("retry path should not fail") .expect("second attempt should reach quorum"); tokio::time::sleep(Duration::from_millis(700)).await; let mut unique_ids = Vec::new(); for lock_id in seen_ids.lock().unwrap().iter() { if unique_ids.iter().all(|id: &LockId| id.uuid != lock_id.uuid) { unique_ids.push(lock_id.clone()); } } assert!( unique_ids.len() >= 2, "expected retry attempts to use distinct lock ids, saw {unique_ids:?}" ); let retry_lock_ids = unique_ids.iter().skip(1).cloned().collect::>(); assert!( !retry_lock_ids.is_empty(), "expected at least one retry-attempt lock id, saw {unique_ids:?}" ); let delayed_a_active = delayed_success_a.active.lock().await; let delayed_b_active = delayed_success_b.active.lock().await; let remaining_delayed_lock_ids = delayed_a_active .keys() .chain(delayed_b_active.keys()) .cloned() .collect::>(); assert!( remaining_delayed_lock_ids.len() == 1, "exactly one delayed client lock should remain held by the retry guard after late cleanup" ); assert!( retry_lock_ids .iter() .any(|retry_lock_id| retry_lock_id.uuid == remaining_delayed_lock_ids[0].uuid), "late cleanup must not leave only a first-attempt delayed lock active" ); drop(delayed_b_active); drop(delayed_a_active); drop(guard); } }