// 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::cache::{ ObjectDataCacheBodyReservation, ObjectDataCacheFillResult, ObjectDataCacheGetPlan, ObjectDataCacheInvalidationResult, ObjectDataCacheLookup, ObjectDataCacheReservedBody, }; use crate::config::ObjectDataCacheConfig; use crate::entry::{ObjectDataCacheEntry, projected_weight}; use crate::index::{ObjectDataCacheGenerationalInsertResult, ObjectDataCacheIdentityIndex}; use crate::key::{ObjectDataCacheIdentity, ObjectDataCacheKey}; use crate::memory::ObjectDataCacheMemoryGate; use crate::singleflight::{ObjectDataCacheSingleflight, ObjectDataCacheSingleflightAcquire}; use crate::stats::ObjectDataCacheStats; use bytes::Bytes; use moka::future::{Cache, FutureExt}; use moka::ops::compute; use std::future::ready; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore, watch}; #[derive(Debug)] struct ClearFence { state: Mutex, quiesced: Notify, } #[derive(Debug)] struct ClearFenceState { /// `None` permanently rejects fills after an impossible counter failure. generation: Option, active_fills: usize, clear: Option>, } #[derive(Debug)] struct ClearOperation { completion: watch::Sender>, } #[derive(Debug)] pub(crate) struct FillGenerationGuard { fence: Arc, generation: u64, } impl ClearFence { fn new() -> Self { Self { state: Mutex::new(ClearFenceState { generation: Some(0), active_fills: 0, clear: None, }), quiesced: Notify::new(), } } fn try_register_fill(self: &Arc) -> Option { let mut state = self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); if state.clear.is_some() { return None; } let generation = state.generation?; let Some(active_fills) = state.active_fills.checked_add(1) else { state.generation = None; return None; }; state.active_fills = active_fills; Some(FillGenerationGuard { fence: Arc::clone(self), generation, }) } fn begin_clear(&self) -> (Arc, bool) { let mut state = self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); if let Some(operation) = &state.clear { return (Arc::clone(operation), false); } let operation = Arc::new(ClearOperation::new()); state.clear = Some(Arc::clone(&operation)); state.generation = state.generation.and_then(|generation| generation.checked_add(1)); (operation, true) } async fn wait_for_active_fills(&self) { loop { let notified = self.quiesced.notified(); if self .state .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .active_fills == 0 { return; } notified.await; } } fn finish_clear(&self, operation: &Arc, result: ObjectDataCacheInvalidationResult) { let mut state = self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); if !state.clear.as_ref().is_some_and(|current| Arc::ptr_eq(current, operation)) { return; } let next_generation = state.generation.and_then(|generation| generation.checked_add(1)); state.generation = next_generation; operation.complete(result); if next_generation.is_some() { state.clear = None; } } } impl FillGenerationGuard { fn belongs_to(&self, fence: &Arc) -> bool { Arc::ptr_eq(&self.fence, fence) } } impl ClearOperation { fn new() -> Self { let (completion, _) = watch::channel(None); Self { completion } } async fn wait(&self) -> ObjectDataCacheInvalidationResult { let mut completion = self.completion.subscribe(); loop { if let Some(result) = *completion.borrow_and_update() { return result; } let _ = completion.changed().await; } } fn complete(&self, result: ObjectDataCacheInvalidationResult) { self.completion.send_replace(Some(result)); } } impl FillGenerationGuard { fn is_current(&self) -> bool { let state = self.fence.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); state.clear.is_none() && state.generation == Some(self.generation) } } impl Drop for FillGenerationGuard { fn drop(&mut self) { let mut state = self.fence.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); let Some(active_fills) = state.active_fills.checked_sub(1) else { state.generation = None; return; }; state.active_fills = active_fills; if state.active_fills == 0 { // There is exactly one owned clear drain. `notify_one` retains a // permit if it reaches zero between the state check and `.await`. self.fence.quiesced.notify_one(); } } } /// Weighted Moka backend for reusable object bodies. #[derive(Debug)] pub struct MokaBackend { cache: Cache>, index: Arc, singleflight: ObjectDataCacheSingleflight, memory_gate: ObjectDataCacheMemoryGate, /// Cache capacity in weighted bytes. The derived growth headroom remains an /// input to the compatible memory-gate API, although live allocation claims /// are no longer capped by cache residency (backlog#1331). max_capacity: u64, /// Bounds the number of concurrent distinct-key fills. Singleflight only /// dedups per key, so without this limiter distinct-key fills are unbounded. fill_semaphore: Arc, next_generation: AtomicU64, /// Serializes global clears and fences fill publication by generation. clear_fence: Arc, /// Test-only barrier that pauses a fill between the index insert and the /// cache insert so the fill-vs-invalidation race can be driven deterministically. #[cfg(test)] fill_barrier: std::sync::Mutex>>, /// Test-only barrier that pauses identity-budget eviction after the index /// selects a victim and before the cache removal starts. #[cfg(test)] identity_eviction_barrier: std::sync::Mutex>>, /// Test-only blocking rendezvous inside the conditional compute closure. #[cfg(test)] identity_compute_barrier: std::sync::Mutex>>, #[cfg(test)] fill_before_index_barrier: std::sync::Mutex>>, #[cfg(test)] clear_barrier: std::sync::Mutex>>, #[cfg(test)] clear_joined: Arc, } /// Test-only rendezvous that pauses a fill between the index insert and the /// cache insert. The fill signals `reached` and blocks on `release`; the test /// waits for `reached`, drives an interleaving, then grants `release`. #[cfg(test)] #[derive(Debug)] struct FillBarrier { reached: tokio::sync::Semaphore, release: tokio::sync::Semaphore, completed: tokio::sync::Semaphore, } #[cfg(test)] #[derive(Debug)] struct ComputeBarrier { reached: tokio::sync::Semaphore, released: std::sync::Mutex, release: std::sync::Condvar, } #[cfg(test)] impl ComputeBarrier { fn new() -> Self { Self { reached: tokio::sync::Semaphore::new(0), released: std::sync::Mutex::new(false), release: std::sync::Condvar::new(), } } fn wait(&self) { self.reached.add_permits(1); let mut released = self.released.lock().unwrap_or_else(|p| p.into_inner()); while !*released { released = self.release.wait(released).unwrap_or_else(|p| p.into_inner()); } } async fn wait_until_reached(&self) { if let Ok(permit) = self.reached.acquire().await { permit.forget(); } } fn release(&self) { *self.released.lock().unwrap_or_else(|p| p.into_inner()) = true; self.release.notify_all(); } } #[cfg(test)] impl FillBarrier { fn new() -> Self { Self { reached: tokio::sync::Semaphore::new(0), release: tokio::sync::Semaphore::new(0), completed: tokio::sync::Semaphore::new(0), } } /// Called from inside the fill: announce arrival, then block until released. async fn wait(&self) { self.reached.add_permits(1); if let Ok(permit) = self.release.acquire().await { permit.forget(); } } /// Called from the test: block until a fill reaches the barrier. async fn wait_until_reached(&self) { if let Ok(permit) = self.reached.acquire().await { permit.forget(); } } /// Called from the test: let the paused fill proceed. fn release(&self) { self.release.add_permits(1); } fn complete(&self) { self.completed.add_permits(1); } async fn wait_until_completed(&self) { if let Ok(permit) = self.completed.acquire().await { permit.forget(); } } } impl MokaBackend { /// Creates a new backend from the validated configuration. pub fn new( config: &ObjectDataCacheConfig, stats: Arc, ) -> Result { let max_capacity = config.resolved_max_bytes()?; let ttl = config.ttl; let time_to_idle = config.time_to_idle; // Size the fill-concurrency limiter to // min(fill_concurrency_per_cpu * available_parallelism, fill_concurrency_max). // Both knobs are validated as non-zero, so the result is at least 1. let parallelism = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); let fill_permits = usize::from(config.fill_concurrency_per_cpu) .saturating_mul(parallelism) .min(usize::from(config.fill_concurrency_max)) .max(1); // The identity index tracks the keys cached per object so that // invalidation can find them without a full-cache scan. Moka evicts // entries on its own (TTL, time-to-idle, capacity-LRU) without going // through `invalidate_object`, so without a listener those evicted keys // would linger in the index forever and leak memory. Hold only a `Weak` // reference in the listener to avoid an Arc cycle keeping the index (and // thus the cache) alive. let index = Arc::new(ObjectDataCacheIdentityIndex::new(usize::from(config.identity_keys_max))); let index_for_eviction = Arc::downgrade(&index); let cache = Cache::builder() .max_capacity(max_capacity) .weigher(|key, value: &Arc| value.estimated_weight(key)) .time_to_live(ttl) .time_to_idle(time_to_idle) .async_eviction_listener(move |key, value, cause| { let index_for_eviction = index_for_eviction.clone(); async move { // Explicit removals and replacements are already reconciled // with the index by the caller; only true evictions leak. if !cause.was_evicted() { return; } let Some(index) = index_for_eviction.upgrade() else { return; }; let identity = ObjectDataCacheIdentity::new(Arc::clone(&key.bucket), Arc::clone(&key.object)); // Prune by generation token: moka delivers the evicted value, // so the index only drops the key when this evicted entry is // still the one it tracks. An inline `Expired` upsert or a // deferred `Size` notification for a superseded generation // then cannot remove the key a fresh refill just registered. index.remove_generation(&identity, &key, value.generation()).await; } .boxed() }) .build(); Ok(Self { cache, index, singleflight: ObjectDataCacheSingleflight::new(Arc::clone(&stats)), memory_gate: ObjectDataCacheMemoryGate::new(config, stats), max_capacity, fill_semaphore: Arc::new(Semaphore::new(fill_permits)), next_generation: AtomicU64::new(0), clear_fence: Arc::new(ClearFence::new()), #[cfg(test)] fill_barrier: std::sync::Mutex::new(None), #[cfg(test)] identity_eviction_barrier: std::sync::Mutex::new(None), #[cfg(test)] identity_compute_barrier: std::sync::Mutex::new(None), #[cfg(test)] fill_before_index_barrier: std::sync::Mutex::new(None), #[cfg(test)] clear_barrier: std::sync::Mutex::new(None), #[cfg(test)] clear_joined: Arc::new(Semaphore::new(0)), }) } /// Installs a test-only fill barrier and returns it to the caller. #[cfg(test)] fn install_fill_barrier(&self) -> Arc { let barrier = Arc::new(FillBarrier::new()); *self.fill_barrier.lock().unwrap_or_else(|p| p.into_inner()) = Some(Arc::clone(&barrier)); barrier } #[cfg(test)] fn install_identity_eviction_barrier(&self) -> Arc { let barrier = Arc::new(FillBarrier::new()); *self.identity_eviction_barrier.lock().unwrap_or_else(|p| p.into_inner()) = Some(Arc::clone(&barrier)); barrier } #[cfg(test)] fn install_identity_compute_barrier(&self) -> Arc { let barrier = Arc::new(ComputeBarrier::new()); *self.identity_compute_barrier.lock().unwrap_or_else(|p| p.into_inner()) = Some(Arc::clone(&barrier)); barrier } #[cfg(test)] fn install_fill_before_index_barrier(&self) -> Arc { let barrier = Arc::new(FillBarrier::new()); *self .fill_before_index_barrier .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(Arc::clone(&barrier)); barrier } #[cfg(test)] fn install_clear_barrier(&self) -> Arc { let barrier = Arc::new(FillBarrier::new()); *self.clear_barrier.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(Arc::clone(&barrier)); barrier } #[cfg(test)] async fn wait_until_clear_joined(&self) { if let Ok(permit) = self.clear_joined.acquire().await { permit.forget(); } } /// Returns the current cache entry count. pub fn entry_count(&self) -> u64 { self.cache.entry_count() } /// Returns the approximate weighted size of cached entries. pub fn weighted_size(&self) -> u64 { self.cache.weighted_size() } pub(crate) const fn max_capacity(&self) -> u64 { self.max_capacity } /// Looks up a cached body for the supplied plan. pub async fn lookup_body(&self, plan: &ObjectDataCacheGetPlan) -> ObjectDataCacheLookup { let ObjectDataCacheGetPlan::Cacheable { key } = plan else { return ObjectDataCacheLookup::SkipNotCacheable; }; match self.cache.get(key).await { Some(entry) => ObjectDataCacheLookup::Hit(entry.bytes()), None => ObjectDataCacheLookup::Miss, } } pub(crate) fn reserve_body(&self, plan: &ObjectDataCacheGetPlan) -> Option { let ObjectDataCacheGetPlan::Cacheable { key } = plan else { return None; }; if projected_weight(key, key.size) > self.max_capacity { return None; } // Register with the clear fence before the caller allocates or reads // the body. A clear that starts after this point must wait for this // reservation to publish or be dropped, so an old cold read cannot // become visible after clear returns. let fill_generation = self.clear_fence.try_register_fill()?; let permit = Arc::clone(&self.fill_semaphore).try_acquire_owned().ok()?; let cache_growth_headroom = self.max_capacity.saturating_sub(self.cache.weighted_size()); let memory = self.memory_gate.try_claim(key.size, cache_growth_headroom)?; Some(ObjectDataCacheBodyReservation { memory, permit, fill_generation, key: key.clone(), expected_size: key.size, }) } /// Inserts a cached body for the supplied plan. pub async fn fill_body(&self, plan: &ObjectDataCacheGetPlan, bytes: Bytes) -> ObjectDataCacheFillResult { self.fill_body_inner(plan, bytes, None).await } pub(crate) async fn fill_reserved_body( &self, plan: &ObjectDataCacheGetPlan, body: ObjectDataCacheReservedBody, ) -> ObjectDataCacheFillResult { let ObjectDataCacheGetPlan::Cacheable { key } = plan else { return ObjectDataCacheFillResult::SkippedNotCacheable; }; if !body.fill_generation.belongs_to(&self.clear_fence) || body.key != *key { return ObjectDataCacheFillResult::SkippedNotCacheable; } if u64::try_from(body.bytes.len()).unwrap_or(u64::MAX) != body.expected_size { return ObjectDataCacheFillResult::SkippedSizeMismatch; } let (bytes, permit, fill_generation) = body.into_parts(); self.fill_body_inner(plan, bytes, Some((permit, fill_generation))).await } async fn fill_body_inner( &self, plan: &ObjectDataCacheGetPlan, bytes: Bytes, reservation: Option<(OwnedSemaphorePermit, FillGenerationGuard)>, ) -> ObjectDataCacheFillResult { let is_pre_reserved = reservation.is_some(); let ObjectDataCacheGetPlan::Cacheable { key } = plan else { return ObjectDataCacheFillResult::SkippedNotCacheable; }; let body_bytes = u64::try_from(bytes.len()).unwrap_or(u64::MAX); if projected_weight(key, body_bytes) > self.max_capacity { return ObjectDataCacheFillResult::SkippedNotCacheable; } // The caller already owns the body, so a non-leader gains nothing by // waiting for another request's leader — it just skips its own fill. // Report it as JoinedInflightFill (ODC-18): it did no fill work, so it // must not be counted as an insert or record fill bytes. It also does // NOT wait for the leader — waiting only matters when the joiner needs // the result value, which the GET path never does (ODC-15). let ObjectDataCacheSingleflightAcquire::Leader(leader) = self.singleflight.try_acquire(key.clone()) else { return ObjectDataCacheFillResult::JoinedInflightFill; }; // Registration and the clear transition share one short state lock, // closing the check/register race without holding a lock across body // processing or cache/index I/O. Only the singleflight leader can // publish, so duplicate fills stay off this global hot-path lock. let (reserved_permit, fill_generation) = match reservation { Some((permit, fill_generation)) => (Some(permit), fill_generation), None => { let Some(fill_generation) = self.clear_fence.try_register_fill() else { return leader.finish(ObjectDataCacheFillResult::SkippedInvalidationRace); }; (None, fill_generation) } }; #[cfg(test)] let fill_before_index_barrier = self .fill_before_index_barrier .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .clone(); #[cfg(test)] if let Some(barrier) = fill_before_index_barrier { barrier.wait().await; } if !fill_generation.is_current() { return leader.finish(ObjectDataCacheFillResult::SkippedInvalidationRace); } // Bound distinct-key fill concurrency after winning leadership and // before the memory-gate check. Reject rather than queue: queuing would // reintroduce the GET-latency coupling this path is meant to avoid. let permit = match reserved_permit { Some(permit) => permit, None => match Arc::clone(&self.fill_semaphore).try_acquire_owned() { Ok(permit) => permit, Err(_) => return leader.finish(ObjectDataCacheFillResult::SkippedFillConcurrency), }, }; // Keep passing cache growth headroom for compatibility with the public // gate API. Allocation-scoped reservations deliberately do not cap by // this value because an evicted entry can still have live body clones. let cache_growth_headroom = self.max_capacity.saturating_sub(self.cache.weighted_size()); if !is_pre_reserved && !self.memory_gate.try_claim_buffered(body_bytes, cache_growth_headroom) { return leader.finish(ObjectDataCacheFillResult::SkippedMemoryPressure); } let identity = ObjectDataCacheIdentity::new(Arc::clone(&key.bucket), Arc::clone(&key.object)); // Keep keys that are still cached or still mid-fill: another in-flight // fill for a different key of this identity may have registered in the // index but not yet published its cache entry, and must not be pruned. self.index .prune_missing(&identity, |candidate| { self.cache.contains_key(candidate) || self.singleflight.has_inflight(candidate) }) .await; // The counter supplies uniqueness only; cache/index publication provides // the synchronization, so relaxed ordering is sufficient here. let generation = match self .next_generation .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| current.checked_add(1)) { Ok(previous) => previous + 1, Err(_) => return leader.finish(ObjectDataCacheFillResult::SkippedIdentityOverflow), }; let entry = Arc::new(ObjectDataCacheEntry::with_generation(bytes, generation)); // Run the register/insert/recheck/undo sequence in a spawned task and // await its handle. The GET path already detaches the whole fill from // the response future (ODC-15), but this inner spawn is still required: // once the index key is registered, the recheck/undo must complete even // if the enclosing fill future is aborted (e.g. the detached fill task // is cancelled). Aborting the outer future then only detaches this // JoinHandle; the task still finishes the undo, so a stale body cannot // survive with no index entry. The owned singleflight leader moves into // the task as well, keeping later same-key fills out until publication // or rollback is complete. let cache = self.cache.clone(); let index = Arc::clone(&self.index); let fill_key = key.clone(); let fill_identity = identity.clone(); #[cfg(test)] let fill_barrier = self.fill_barrier.lock().unwrap_or_else(|p| p.into_inner()).clone(); #[cfg(test)] let identity_eviction_barrier = self .identity_eviction_barrier .lock() .unwrap_or_else(|p| p.into_inner()) .clone(); #[cfg(test)] let identity_compute_barrier = self .identity_compute_barrier .lock() .unwrap_or_else(|p| p.into_inner()) .clone(); let handle = tokio::spawn(async move { // Hold the fill-concurrency permit until the register/insert/undo // sequence completes, then release it on task exit. let _permit = permit; // Keep the generation registered through the complete // register/publish/recheck/undo sequence. If the outer caller is // cancelled, this owned task still acknowledges the clear fence. let fill_generation = fill_generation; if !fill_generation.is_current() { return leader.finish(ObjectDataCacheFillResult::SkippedInvalidationRace); } // Register the key in the identity index BEFORE the entry becomes // visible in the cache, so a concurrent invalidation always finds it. match index .insert_generation(fill_identity.clone(), fill_key.clone(), generation) .await { ObjectDataCacheGenerationalInsertResult::Inserted { evicted } => { for evicted in evicted { #[cfg(test)] if let Some(barrier) = &identity_eviction_barrier { barrier.wait().await; } let expected_generation = evicted.generation; #[cfg(test)] let compute_barrier = identity_compute_barrier.clone(); let _ = cache .entry(evicted.key) .and_compute_with(move |current| { let remove = current .as_ref() .is_some_and(|entry| entry.value().generation() == expected_generation); #[cfg(test)] if let Some(barrier) = compute_barrier { barrier.wait(); } ready(if remove { compute::Op::Remove } else { compute::Op::Nop }) }) .await; #[cfg(test)] if let Some(barrier) = &identity_eviction_barrier { barrier.complete(); } } } ObjectDataCacheGenerationalInsertResult::Duplicate => {} } #[cfg(test)] if let Some(barrier) = fill_barrier { barrier.wait().await; } if !fill_generation.is_current() { // Leave the registered key for the owned clear drain. Besides // making the acknowledgement visible to clear, this preserves // the pre-existing invalidation outcome count for admin stats. return leader.finish(ObjectDataCacheFillResult::SkippedInvalidationRace); } let _ = cache .entry(fill_key.clone()) .and_compute_with(move |_| ready(compute::Op::Put(entry))) .await; // An invalidation may have raced between the index and cache // inserts; re-check the index and undo the fill so the stale // body cannot outlive the invalidation. let indexed = index.contains_generation(&fill_identity, &fill_key, generation).await; let result = if fill_generation.is_current() && indexed { ObjectDataCacheFillResult::Inserted } else { cache.remove(&fill_key).await; if fill_generation.is_current() { index.remove_generation(&fill_identity, &fill_key, generation).await; } ObjectDataCacheFillResult::SkippedInvalidationRace }; leader.finish(result) }); handle.await.unwrap_or(ObjectDataCacheFillResult::SkippedInvalidationRace) } /// Conservatively invalidates all cached keys matching the object identity. /// /// The identity index is authoritative: fills register the key in the /// index before the entry becomes visible in the cache (and undo the fill /// if an invalidation raced in between), so no full-cache scan fallback is /// needed when the index has no entry for the identity. pub async fn invalidate_object(&self, identity: &ObjectDataCacheIdentity) -> ObjectDataCacheInvalidationResult { let keys_to_remove = self.index.remove_identity(identity).await; // ODC-36: report the removal count so the facade can label the // invalidation outcome (removed vs noop) and skip the gauge refresh on // the no-op path. The vast majority of invalidations touch identities // that were never cached. let removed = keys_to_remove.len(); for key in keys_to_remove { self.cache.remove(&key).await; } Self::invalidation_result(removed) } /// Invalidates every cached key of an identity in `bucket` whose object key /// starts with `prefix`. Full index scan; rare force-delete/admin path only. pub async fn invalidate_prefix(&self, bucket: &str, prefix: &str) -> ObjectDataCacheInvalidationResult { let keys_to_remove = self .index .remove_matching(|identity| identity.bucket.as_ref() == bucket && identity.object.starts_with(prefix)) .await; let removed = keys_to_remove.len(); for key in keys_to_remove { self.cache.remove(&key).await; } Self::invalidation_result(removed) } /// Invalidates every cached key of every identity in `bucket`. Full index /// scan; rare bucket-delete/admin path only. pub async fn invalidate_bucket(&self, bucket: &str) -> ObjectDataCacheInvalidationResult { let keys_to_remove = self .index .remove_matching(|identity| identity.bucket.as_ref() == bucket) .await; let removed = keys_to_remove.len(); for key in keys_to_remove { self.cache.remove(&key).await; } Self::invalidation_result(removed) } /// Drops every cached body and resets the identity index. Concurrent clear /// callers join one owned drain, so cancelling a caller cannot cancel the /// operation or reopen its fill generation. Lookups remain lock-free and /// may observe entries until the drain removes them; fills started while /// the clear is active skip publication. pub async fn clear(&self) -> ObjectDataCacheInvalidationResult { let (operation, should_start) = self.clear_fence.begin_clear(); #[cfg(test)] if !should_start { self.clear_joined.add_permits(1); } if should_start { let cache = self.cache.clone(); let index = Arc::clone(&self.index); let clear_fence = Arc::clone(&self.clear_fence); let owned_operation = Arc::clone(&operation); #[cfg(test)] let clear_barrier = self .clear_barrier .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .take(); tokio::spawn(async move { #[cfg(test)] if let Some(barrier) = clear_barrier { barrier.wait().await; } clear_fence.wait_for_active_fills().await; let result = Self::drain_all(cache, index).await; clear_fence.finish_clear(&owned_operation, result); }); } operation.wait().await } async fn drain_all( cache: Cache>, index: Arc, ) -> ObjectDataCacheInvalidationResult { let keys_to_remove = index.remove_matching(|_| true).await; let removed = keys_to_remove.len(); for key in keys_to_remove { cache.remove(&key).await; } // Moka maintenance is still needed to retire queued removals, but clear // must not rely on lazy invalidation alone: under heavy contention an // entry can remain physically resident (and still counted) even though // the invalidation fence already hides it from lookups. Sweep both the // entries visible to iteration and a fallback invalidate_all fence so // delayed internal writes cannot strand a counted entry. for _ in 0..256 { cache.run_pending_tasks().await; let lingering_keys: Vec<_> = cache.iter().map(|(key, _)| key.as_ref().clone()).collect(); for key in lingering_keys { cache.remove(&key).await; } cache.invalidate_all(); cache.run_pending_tasks().await; if cache.entry_count() == 0 { break; } tokio::task::yield_now().await; } Self::invalidation_result(removed) } const fn invalidation_result(removed: usize) -> ObjectDataCacheInvalidationResult { if removed > 0 { ObjectDataCacheInvalidationResult::Removed { keys: removed } } else { ObjectDataCacheInvalidationResult::NoOp } } } #[cfg(test)] mod tests { use super::{ClearFence, FillGenerationGuard, MokaBackend}; use crate::cache::{ ObjectDataCacheFillResult, ObjectDataCacheGetPlan, ObjectDataCacheInvalidationResult, ObjectDataCacheLookup, }; use crate::config::{ObjectDataCacheConfig, ObjectDataCacheMode}; use crate::key::{ObjectDataCacheBodyVariant, ObjectDataCacheIdentity, ObjectDataCacheKey}; use crate::stats::ObjectDataCacheStats; use bytes::Bytes; use std::sync::Arc; use std::time::Duration; /// Fills here must succeed regardless of the live memory reading, which /// differs between a developer host and a CI container, so the gate is /// opted out of. Tests that exercise the gate re-enable it explicitly. fn enabled_config() -> ObjectDataCacheConfig { ObjectDataCacheConfig { mode: ObjectDataCacheMode::FillMaterializeEnabled, max_bytes: 8_388_608, max_memory_percent: 5, max_entry_bytes: 1_048_576, ttl: Duration::from_millis(100), time_to_idle: Duration::from_millis(100), min_free_memory_percent: 0, // >= 2 so concurrent-fill tests get at least two permits even on a // single-CPU CI runner (permits = min(per_cpu * parallelism, max)). fill_concurrency_per_cpu: 2, fill_concurrency_max: 32, identity_keys_max: 16, } } fn memory_gated_config() -> ObjectDataCacheConfig { ObjectDataCacheConfig { min_free_memory_percent: 20, ..enabled_config() } } fn cacheable_plan(object: &str, etag: &str) -> ObjectDataCacheGetPlan { cacheable_plan_with_size(object, etag, 5) } fn cacheable_plan_with_size(object: &str, etag: &str, size: u64) -> ObjectDataCacheGetPlan { ObjectDataCacheGetPlan::Cacheable { key: ObjectDataCacheKey::new("bucket", object, None, etag, size, ObjectDataCacheBodyVariant::FullObjectPlainV1), } } fn versioned_plan(object: &str, version: &str, etag: &str) -> ObjectDataCacheGetPlan { ObjectDataCacheGetPlan::Cacheable { key: ObjectDataCacheKey::new("bucket", object, Some(version), etag, 5, ObjectDataCacheBodyVariant::FullObjectPlainV1), } } fn identity_budget_fixture() -> (Arc, ObjectDataCacheGetPlan, ObjectDataCacheGetPlan, ObjectDataCacheGetPlan) { let mut config = enabled_config(); config.ttl = Duration::from_secs(30); config.time_to_idle = Duration::from_secs(30); config.identity_keys_max = 2; ( Arc::new(MokaBackend::new(&config, Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build")), versioned_plan("object", "v1", "etag-1"), versioned_plan("object", "v2", "etag-2"), versioned_plan("object", "v3", "etag-3"), ) } #[tokio::test] async fn moka_backend_round_trips_cached_body() { let backend = MokaBackend::new(&enabled_config(), Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"); let plan = cacheable_plan("object", "etag-a"); let fill = backend.fill_body(&plan, Bytes::from_static(b"hello")).await; let lookup = backend.lookup_body(&plan).await; assert!(matches!(fill, ObjectDataCacheFillResult::Inserted)); assert!(matches!(lookup, ObjectDataCacheLookup::Hit(ref bytes) if bytes.as_ref() == b"hello")); } #[tokio::test] async fn moka_backend_never_reports_inserted_for_overweight_entry() { let mut config = enabled_config(); config.max_bytes = 8 * 1024; config.max_entry_bytes = 4 * 1024; let backend = MokaBackend::new(&config, Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"); // Projected weight is 8193 bytes, one byte over max_capacity. let object = "o".repeat(4022); let plan = cacheable_plan_with_size(&object, "e", 4 * 1024); let body = Bytes::from(vec![0_u8; 4 * 1024]); let fill = backend.fill_body(&plan, body).await; backend.cache.run_pending_tasks().await; assert_eq!(fill, ObjectDataCacheFillResult::SkippedNotCacheable); assert!(matches!(backend.lookup_body(&plan).await, ObjectDataCacheLookup::Miss)); } #[tokio::test] async fn accepted_boundary_entry_remains_hit_after_pending_maintenance() { let mut config = enabled_config(); config.max_bytes = 8 * 1024; config.max_entry_bytes = 4 * 1024; let backend = MokaBackend::new(&config, Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"); // Projected weight is exactly max_capacity: 4096 + 6 + 4021 + 4 + 1 + 64. let object = "o".repeat(4021); let plan = cacheable_plan_with_size(&object, "e", 4 * 1024); let body = Bytes::from(vec![0_u8; 4 * 1024]); let fill = backend.fill_body(&plan, body).await; backend.cache.run_pending_tasks().await; let lookup = backend.lookup_body(&plan).await; assert_eq!(fill, ObjectDataCacheFillResult::Inserted); assert_eq!(backend.weighted_size(), config.max_bytes, "Moka must charge the exact projected weight"); assert!(matches!(lookup, ObjectDataCacheLookup::Hit(ref bytes) if bytes.len() == 4 * 1024)); } #[tokio::test] async fn moka_backend_invalidates_matching_identity() { let backend = MokaBackend::new(&enabled_config(), Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"); let plan_a = cacheable_plan("object-a", "etag-a"); let plan_b = cacheable_plan("object-b", "etag-b"); let _ = backend.fill_body(&plan_a, Bytes::from_static(b"aaaaa")).await; let _ = backend.fill_body(&plan_b, Bytes::from_static(b"bbbbb")).await; // object-a has exactly one cached key, so invalidation reports Removed { keys: 1 }. let result = backend .invalidate_object(&ObjectDataCacheIdentity::new("bucket", "object-a")) .await; let lookup_a = backend.lookup_body(&plan_a).await; let lookup_b = backend.lookup_body(&plan_b).await; assert_eq!(result, ObjectDataCacheInvalidationResult::Removed { keys: 1 }); assert!(matches!(lookup_a, ObjectDataCacheLookup::Miss)); assert!(matches!(lookup_b, ObjectDataCacheLookup::Hit(_))); } // ODC-36: invalidating an identity that was never cached reports NoOp so the // facade can skip the cache-state gauge refresh on the common no-op path. #[tokio::test] async fn moka_backend_invalidate_uncached_identity_is_noop() { let backend = MokaBackend::new(&enabled_config(), Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"); let result = backend .invalidate_object(&ObjectDataCacheIdentity::new("bucket", "never-cached")) .await; assert_eq!(result, ObjectDataCacheInvalidationResult::NoOp); } fn bucketed_plan(bucket: &str, object: &str, etag: &str) -> ObjectDataCacheGetPlan { ObjectDataCacheGetPlan::Cacheable { key: ObjectDataCacheKey::new(bucket, object, None, etag, 5, ObjectDataCacheBodyVariant::FullObjectPlainV1), } } // ODC-27: prefix invalidation drops only the identities under the prefix and // leaves every other cached body intact. #[tokio::test] async fn moka_backend_invalidate_prefix_removes_only_matching_prefix() { let mut config = enabled_config(); config.ttl = Duration::from_secs(30); config.time_to_idle = Duration::from_secs(30); let backend = MokaBackend::new(&config, Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"); let plan_a = cacheable_plan("photos/a.jpg", "etag-a"); let plan_b = cacheable_plan("photos/b.jpg", "etag-b"); let plan_c = cacheable_plan("videos/c.mp4", "etag-c"); let _ = backend.fill_body(&plan_a, Bytes::from_static(b"aaaaa")).await; let _ = backend.fill_body(&plan_b, Bytes::from_static(b"bbbbb")).await; let _ = backend.fill_body(&plan_c, Bytes::from_static(b"ccccc")).await; let result = backend.invalidate_prefix("bucket", "photos/").await; assert_eq!(result, ObjectDataCacheInvalidationResult::Removed { keys: 2 }); assert!(matches!(backend.lookup_body(&plan_a).await, ObjectDataCacheLookup::Miss)); assert!(matches!(backend.lookup_body(&plan_b).await, ObjectDataCacheLookup::Miss)); assert!( matches!(backend.lookup_body(&plan_c).await, ObjectDataCacheLookup::Hit(_)), "an object outside the prefix must survive" ); } // ODC-27: a prefix that matches nothing cached is a no-op. #[tokio::test] async fn moka_backend_invalidate_prefix_uncached_is_noop() { let backend = MokaBackend::new(&enabled_config(), Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"); let _ = backend .fill_body(&cacheable_plan("photos/a.jpg", "e"), Bytes::from_static(b"aaaaa")) .await; let result = backend.invalidate_prefix("bucket", "videos/").await; assert_eq!(result, ObjectDataCacheInvalidationResult::NoOp); } // ODC-28: bucket invalidation drops every identity in the bucket and leaves // other buckets untouched. #[tokio::test] async fn moka_backend_invalidate_bucket_removes_only_that_bucket() { let mut config = enabled_config(); config.ttl = Duration::from_secs(30); config.time_to_idle = Duration::from_secs(30); let backend = MokaBackend::new(&config, Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"); let plan_a = bucketed_plan("bucket-a", "o1", "etag-a"); let plan_b = bucketed_plan("bucket-a", "o2", "etag-b"); let plan_other = bucketed_plan("bucket-b", "o3", "etag-c"); let _ = backend.fill_body(&plan_a, Bytes::from_static(b"aaaaa")).await; let _ = backend.fill_body(&plan_b, Bytes::from_static(b"bbbbb")).await; let _ = backend.fill_body(&plan_other, Bytes::from_static(b"ccccc")).await; let result = backend.invalidate_bucket("bucket-a").await; assert_eq!(result, ObjectDataCacheInvalidationResult::Removed { keys: 2 }); assert!(matches!(backend.lookup_body(&plan_a).await, ObjectDataCacheLookup::Miss)); assert!(matches!(backend.lookup_body(&plan_b).await, ObjectDataCacheLookup::Miss)); assert!( matches!(backend.lookup_body(&plan_other).await, ObjectDataCacheLookup::Hit(_)), "an object in another bucket must survive a bucket flush" ); } // ODC-C2: clear drops every cached body and empties the identity index. #[tokio::test] async fn moka_backend_clear_removes_everything() { let mut config = enabled_config(); config.ttl = Duration::from_secs(30); config.time_to_idle = Duration::from_secs(30); let backend = MokaBackend::new(&config, Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"); let plan_a = bucketed_plan("bucket-a", "o1", "etag-a"); let plan_b = bucketed_plan("bucket-b", "o2", "etag-b"); let _ = backend.fill_body(&plan_a, Bytes::from_static(b"aaaaa")).await; let _ = backend.fill_body(&plan_b, Bytes::from_static(b"bbbbb")).await; let result = backend.clear().await; assert_eq!(result, ObjectDataCacheInvalidationResult::Removed { keys: 2 }); assert!(matches!(backend.lookup_body(&plan_a).await, ObjectDataCacheLookup::Miss)); assert!(matches!(backend.lookup_body(&plan_b).await, ObjectDataCacheLookup::Miss)); assert_eq!(backend.entry_count(), 0, "clear must drain cached entries before returning"); assert_eq!(backend.index.identity_count().await, 0, "clear must empty the identity index"); } #[tokio::test] async fn moka_backend_clear_empty_cache_is_noop() { let backend = MokaBackend::new(&enabled_config(), Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"); assert_eq!(backend.clear().await, ObjectDataCacheInvalidationResult::NoOp); } #[tokio::test] async fn clear_fences_fill_started_before_index_registration() { let backend = Arc::new( MokaBackend::new(&enabled_config(), Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"), ); let fill_barrier = backend.install_fill_before_index_barrier(); let clear_barrier = backend.install_clear_barrier(); let plan = cacheable_plan("object", "etag-a"); let fill_backend = Arc::clone(&backend); let fill_plan = plan.clone(); let fill = tokio::spawn(async move { fill_backend.fill_body(&fill_plan, Bytes::from_static(b"hello")).await }); fill_barrier.wait_until_reached().await; let clear_backend = Arc::clone(&backend); let clear = tokio::spawn(async move { clear_backend.clear().await }); clear_barrier.wait_until_reached().await; clear_barrier.release(); assert!( !clear.is_finished(), "clear must wait for a pre-registration fill to acknowledge the fence" ); fill_barrier.release(); assert_eq!( fill.await.expect("fill task should finish"), ObjectDataCacheFillResult::SkippedInvalidationRace ); assert_eq!(clear.await.expect("clear task should finish"), ObjectDataCacheInvalidationResult::NoOp); assert!(matches!(backend.lookup_body(&plan).await, ObjectDataCacheLookup::Miss)); assert_eq!(backend.index.identity_count().await, 0); } #[tokio::test] async fn clear_fences_body_reserved_before_materialization() { let backend = Arc::new( MokaBackend::new(&enabled_config(), Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"), ); let clear_barrier = backend.install_clear_barrier(); let plan = cacheable_plan("reserved-object", "etag-a"); let reservation = backend .reserve_body(&plan) .expect("reservation must register before body reads"); let clear_backend = Arc::clone(&backend); let clear = tokio::spawn(async move { clear_backend.clear().await }); clear_barrier.wait_until_reached().await; clear_barrier.release(); assert!( !clear.is_finished(), "clear must wait while a pre-materialization reservation can still publish" ); let body = reservation.wrap_bytes(Bytes::from_static(b"hello")); assert_eq!( backend.fill_reserved_body(&plan, body).await, ObjectDataCacheFillResult::SkippedInvalidationRace ); assert_eq!(clear.await.expect("clear task should finish"), ObjectDataCacheInvalidationResult::NoOp); assert!(matches!(backend.lookup_body(&plan).await, ObjectDataCacheLookup::Miss)); assert_eq!(backend.index.identity_count().await, 0); } #[tokio::test] async fn clear_fences_fill_registered_before_cache_publish() { let backend = Arc::new( MokaBackend::new(&enabled_config(), Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"), ); let fill_barrier = backend.install_fill_barrier(); let clear_barrier = backend.install_clear_barrier(); let plan = cacheable_plan("object", "etag-a"); let fill_backend = Arc::clone(&backend); let fill_plan = plan.clone(); let fill = tokio::spawn(async move { fill_backend.fill_body(&fill_plan, Bytes::from_static(b"hello")).await }); fill_barrier.wait_until_reached().await; let clear_backend = Arc::clone(&backend); let clear = tokio::spawn(async move { clear_backend.clear().await }); clear_barrier.wait_until_reached().await; clear_barrier.release(); assert!(!clear.is_finished(), "clear must wait for a registered fill to acknowledge the fence"); fill_barrier.release(); assert_eq!( fill.await.expect("fill task should finish"), ObjectDataCacheFillResult::SkippedInvalidationRace ); assert_eq!( clear.await.expect("clear task should finish"), ObjectDataCacheInvalidationResult::Removed { keys: 1 } ); assert!(matches!(backend.lookup_body(&plan).await, ObjectDataCacheLookup::Miss)); assert_eq!(backend.index.identity_count().await, 0); } #[tokio::test] async fn fill_started_during_clear_skips_without_waiting() { let backend = Arc::new( MokaBackend::new(&enabled_config(), Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"), ); let clear_barrier = backend.install_clear_barrier(); let clear_backend = Arc::clone(&backend); let clear = tokio::spawn(async move { clear_backend.clear().await }); clear_barrier.wait_until_reached().await; assert_eq!( backend .fill_body(&cacheable_plan("object", "etag-a"), Bytes::from_static(b"hello")) .await, ObjectDataCacheFillResult::SkippedInvalidationRace ); clear_barrier.release(); assert_eq!(clear.await.expect("clear task should finish"), ObjectDataCacheInvalidationResult::NoOp); } #[tokio::test] async fn concurrent_clear_joins_same_operation() { let backend = Arc::new( MokaBackend::new(&enabled_config(), Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"), ); let _ = backend .fill_body(&cacheable_plan("object", "etag-a"), Bytes::from_static(b"hello")) .await; let clear_barrier = backend.install_clear_barrier(); let first_backend = Arc::clone(&backend); let first = tokio::spawn(async move { first_backend.clear().await }); clear_barrier.wait_until_reached().await; let second_backend = Arc::clone(&backend); let second = tokio::spawn(async move { second_backend.clear().await }); backend.wait_until_clear_joined().await; assert!(!second.is_finished(), "a concurrent clear must join the active drain"); clear_barrier.release(); let expected = ObjectDataCacheInvalidationResult::Removed { keys: 1 }; assert_eq!(first.await.expect("first clear should finish"), expected); assert_eq!(second.await.expect("second clear should finish"), expected); } #[tokio::test] async fn cancelled_clear_does_not_reopen_old_generation() { let backend = Arc::new( MokaBackend::new(&enabled_config(), Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"), ); let clear_barrier = backend.install_clear_barrier(); let first_backend = Arc::clone(&backend); let first = tokio::spawn(async move { first_backend.clear().await }); clear_barrier.wait_until_reached().await; first.abort(); assert_eq!( backend .fill_body(&cacheable_plan("during-clear", "etag-a"), Bytes::from_static(b"hello")) .await, ObjectDataCacheFillResult::SkippedInvalidationRace ); let second_backend = Arc::clone(&backend); let second = tokio::spawn(async move { second_backend.clear().await }); backend.wait_until_clear_joined().await; second.abort(); clear_barrier.release(); assert_eq!(backend.clear().await, ObjectDataCacheInvalidationResult::NoOp); assert_eq!( backend .fill_body(&cacheable_plan("after-clear", "etag-b"), Bytes::from_static(b"hello")) .await, ObjectDataCacheFillResult::Inserted ); } #[tokio::test] async fn clear_completion_reopens_generation_atomically() { let fence = Arc::new(ClearFence::new()); let (operation, should_start) = fence.begin_clear(); assert!(should_start); fence.finish_clear(&operation, ObjectDataCacheInvalidationResult::NoOp); assert_eq!(operation.wait().await, ObjectDataCacheInvalidationResult::NoOp); assert!(fence.try_register_fill().is_some()); } #[tokio::test] async fn cancelled_fill_acknowledges_clear_fence() { let backend = Arc::new( MokaBackend::new(&enabled_config(), Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"), ); let fill_barrier = backend.install_fill_barrier(); let plan = cacheable_plan("object", "etag-a"); let fill_backend = Arc::clone(&backend); let fill_plan = plan.clone(); let fill = tokio::spawn(async move { fill_backend.fill_body(&fill_plan, Bytes::from_static(b"hello")).await }); fill_barrier.wait_until_reached().await; fill.abort(); let _ = fill.await; assert_eq!( backend.clear_fence.state.lock().unwrap().active_fills, 1, "the detached publish task must retain its active-fill acknowledgement" ); assert_eq!( tokio::time::timeout(Duration::from_millis(100), backend.fill_body(&plan, Bytes::from_static(b"newer")),) .await .expect("same-key retry must not wait for detached publication"), ObjectDataCacheFillResult::JoinedInflightFill, "the detached publication must retain same-key singleflight ownership", ); let clear_barrier = backend.install_clear_barrier(); let clear_backend = Arc::clone(&backend); let clear = tokio::spawn(async move { clear_backend.clear().await }); clear_barrier.wait_until_reached().await; clear_barrier.release(); assert!(!clear.is_finished(), "clear must wait for the detached publish task"); fill_barrier.release(); assert_eq!( clear.await.expect("clear task should finish"), ObjectDataCacheInvalidationResult::Removed { keys: 1 } ); assert!(matches!(backend.lookup_body(&plan).await, ObjectDataCacheLookup::Miss)); assert_eq!(backend.index.identity_count().await, 0); } #[test] fn clear_fence_generation_overflow_is_fail_closed() { for generation in [u64::MAX, u64::MAX - 1] { let fence = Arc::new(ClearFence::new()); fence.state.lock().unwrap().generation = Some(generation); let (operation, should_start) = fence.begin_clear(); assert!(should_start); fence.finish_clear(&operation, ObjectDataCacheInvalidationResult::NoOp); assert!(fence.try_register_fill().is_none()); assert!(fence.state.lock().unwrap().generation.is_none()); let (joined, should_start) = fence.begin_clear(); assert!(!should_start, "a failed generation must retain the permanent clear fence"); assert!(Arc::ptr_eq(&operation, &joined)); } } #[test] fn clear_fence_active_counter_failure_is_fail_closed() { let overflow = Arc::new(ClearFence::new()); overflow.state.lock().unwrap().active_fills = usize::MAX; assert!(overflow.try_register_fill().is_none()); assert!(overflow.state.lock().unwrap().generation.is_none()); let underflow = Arc::new(ClearFence::new()); drop(FillGenerationGuard { fence: Arc::clone(&underflow), generation: 0, }); assert!(underflow.state.lock().unwrap().generation.is_none()); assert!(underflow.try_register_fill().is_none()); } #[tokio::test] async fn moka_backend_expires_entries_by_ttl() { let backend = MokaBackend::new(&enabled_config(), Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"); let plan = cacheable_plan("object", "etag-a"); let _ = backend.fill_body(&plan, Bytes::from_static(b"hello")).await; tokio::time::sleep(Duration::from_millis(150)).await; let lookup = backend.lookup_body(&plan).await; assert!(matches!(lookup, ObjectDataCacheLookup::Miss)); } #[tokio::test] async fn moka_backend_eviction_prunes_identity_index() { let backend = MokaBackend::new(&enabled_config(), Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"); // Cache several distinct identities under the short TTL from the config. for i in 0..8 { let plan = cacheable_plan(&format!("object-{i}"), "etag-a"); let _ = backend.fill_body(&plan, Bytes::from_static(b"hello")).await; } assert_eq!(backend.index.identity_count().await, 8); // Let every entry expire, then let moka process the expirations so the // eviction listener runs and prunes the identity index. tokio::time::sleep(Duration::from_millis(150)).await; backend.cache.run_pending_tasks().await; assert_eq!(backend.cache.entry_count(), 0, "all entries should have expired"); assert_eq!( backend.index.identity_count().await, 0, "identity index must not outlive evicted cache entries" ); } #[tokio::test] async fn moka_backend_expires_entries_by_tti() { let mut config = enabled_config(); config.ttl = Duration::from_secs(30); config.time_to_idle = Duration::from_millis(100); let backend = MokaBackend::new(&config, Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"); let plan = cacheable_plan("object", "etag-a"); let _ = backend.fill_body(&plan, Bytes::from_static(b"hello")).await; tokio::time::sleep(Duration::from_millis(150)).await; let lookup = backend.lookup_body(&plan).await; assert!(matches!(lookup, ObjectDataCacheLookup::Miss)); } #[tokio::test] async fn moka_backend_skips_fill_under_memory_pressure() { let stats = Arc::new(ObjectDataCacheStats::default()); // The gate must be enabled here, otherwise allows_fill short-circuits. let backend = MokaBackend::new(&memory_gated_config(), Arc::clone(&stats)).expect("moka backend should build"); backend .memory_gate .set_test_snapshot(Some(crate::memory::ObjectDataCacheMemorySnapshot { total_bytes: 1_000, available_bytes: 100, })); let plan = cacheable_plan("object", "etag-a"); let result = backend.fill_body(&plan, Bytes::from_static(b"hello")).await; let lookup = backend.lookup_body(&plan).await; assert_eq!(result, ObjectDataCacheFillResult::SkippedMemoryPressure); assert!(matches!(lookup, ObjectDataCacheLookup::Miss)); assert_eq!(stats.snapshot().memory_pressure_events, 1); } #[test] fn reserve_body_rejects_before_materialization_under_memory_pressure() { let stats = Arc::new(ObjectDataCacheStats::default()); let backend = MokaBackend::new(&memory_gated_config(), Arc::clone(&stats)).expect("moka backend should build"); backend .memory_gate .set_test_snapshot(Some(crate::memory::ObjectDataCacheMemorySnapshot { total_bytes: 1_000, available_bytes: 100, })); assert!(backend.reserve_body(&cacheable_plan("object", "etag-a")).is_none()); assert_eq!(backend.memory_gate.claimed_bytes_for_test(), 0); assert_eq!(stats.snapshot().memory_pressure_events, 1); } #[tokio::test] async fn reserved_body_claim_survives_cache_eviction_until_last_clone_drops() { let backend = MokaBackend::new(&memory_gated_config(), Arc::new(ObjectDataCacheStats::default())) .expect("moka backend should build"); backend .memory_gate .set_test_snapshot(Some(crate::memory::ObjectDataCacheMemorySnapshot { total_bytes: 1_000, available_bytes: 500, })); let plan = cacheable_plan("object", "etag-a"); let body = backend .reserve_body(&plan) .expect("the allocation should fit the memory budget") .wrap_bytes(Bytes::from_static(b"hello")); assert_eq!(backend.memory_gate.claimed_bytes_for_test(), 5); assert_eq!(backend.fill_reserved_body(&plan, body).await, ObjectDataCacheFillResult::Inserted); assert_eq!( backend.memory_gate.claimed_bytes_for_test(), 5, "reserved fill must not claim the same allocation twice" ); let ObjectDataCacheLookup::Hit(response_body) = backend.lookup_body(&plan).await else { panic!("the reserved body should be cached"); }; let _ = backend .invalidate_object(&ObjectDataCacheIdentity::new("bucket", "object")) .await; backend.cache.run_pending_tasks().await; assert_eq!( backend.memory_gate.claimed_bytes_for_test(), 5, "the response clone still owns the cached allocation" ); drop(response_body); backend.cache.run_pending_tasks().await; assert_eq!(backend.memory_gate.claimed_bytes_for_test(), 0); } #[tokio::test] async fn moka_buffered_fill_holds_conservative_claim_until_refresh() { let backend = MokaBackend::new(&memory_gated_config(), Arc::new(ObjectDataCacheStats::default())) .expect("moka backend should build"); let snapshot = crate::memory::ObjectDataCacheMemorySnapshot { total_bytes: 1_000, available_bytes: 500, }; backend.memory_gate.set_test_snapshot(Some(snapshot)); let plan = cacheable_plan("object", "etag-a"); assert_eq!( backend.fill_body(&plan, Bytes::from_static(b"hello")).await, ObjectDataCacheFillResult::Inserted ); assert_eq!(backend.memory_gate.claimed_bytes_for_test(), 5); let _ = backend .invalidate_object(&ObjectDataCacheIdentity::new("bucket", "object")) .await; backend.cache.run_pending_tasks().await; assert_eq!( backend.memory_gate.claimed_bytes_for_test(), 5, "an already-buffered body cannot be retrofitted with an allocation owner" ); backend.memory_gate.store_raw_snapshot_for_test(snapshot); assert_eq!(backend.memory_gate.claimed_bytes_for_test(), 0); } // ODC-15 + ODC-18: a concurrent duplicate fill for the same key must NOT // wait for the in-flight leader (it already owns the body), and it must be // reported as JoinedInflightFill — not Inserted — so it records no fill // work and cannot inflate the inserted count/bytes for one real insert. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn moka_backend_duplicate_fill_same_key_joins_without_waiting() { let backend = Arc::new( MokaBackend::new(&enabled_config(), Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"), ); let plan = cacheable_plan("object", "etag-a"); let barrier = backend.install_fill_barrier(); let fill_a = { let backend = Arc::clone(&backend); let plan = plan.clone(); tokio::spawn(async move { backend.fill_body(&plan, Bytes::from_static(b"hello")).await }) }; // A has registered its singleflight key and parked before the cache // insert. A second fill for the same key must join (skip) right away. barrier.wait_until_reached().await; let fill_b = backend.fill_body(&plan, Bytes::from_static(b"hello")).await; assert_eq!( fill_b, ObjectDataCacheFillResult::JoinedInflightFill, "a duplicate fill must join the in-flight leader (not insert), and not wait for it" ); barrier.release(); let fill_a = fill_a.await.expect("leader fill task should complete"); assert_eq!(fill_a, ObjectDataCacheFillResult::Inserted); assert!(matches!(backend.lookup_body(&plan).await, ObjectDataCacheLookup::Hit(ref bytes) if bytes.as_ref() == b"hello")); } // ODC-11: the fill-concurrency limiter must reject (not queue) once // saturated. With a single permit, an in-flight fill for one key holds the // permit; a concurrent fill for a DISTINCT key is rejected immediately. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn moka_backend_rejects_fill_when_concurrency_saturated() { let mut config = enabled_config(); // One permit: min(per_cpu * parallelism, max) == min(N, 1) == 1. config.fill_concurrency_per_cpu = 1; config.fill_concurrency_max = 1; let backend = Arc::new(MokaBackend::new(&config, Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build")); let plan_a = versioned_plan("object", "v1", "etag-a"); let plan_b = versioned_plan("object", "v2", "etag-b"); let barrier = backend.install_fill_barrier(); let fill_a = { let backend = Arc::clone(&backend); let plan_a = plan_a.clone(); tokio::spawn(async move { backend.fill_body(&plan_a, Bytes::from_static(b"aaaaa")).await }) }; // A holds the only permit and is parked before the cache insert. Clear // the barrier so B (a distinct key) runs without pausing; B must be // rejected by the saturated limiter rather than queue behind A. barrier.wait_until_reached().await; *backend.fill_barrier.lock().unwrap() = None; let fill_b = backend.fill_body(&plan_b, Bytes::from_static(b"bbbbb")).await; assert_eq!( fill_b, ObjectDataCacheFillResult::SkippedFillConcurrency, "a distinct-key fill must be rejected, not queued, when the limiter is saturated" ); assert!( matches!(backend.lookup_body(&plan_b).await, ObjectDataCacheLookup::Miss), "a rejected fill must not populate the cache" ); barrier.release(); let fill_a = fill_a.await.expect("leader fill task should complete"); assert_eq!(fill_a, ObjectDataCacheFillResult::Inserted); } // ODC-12(1): the first refill after a TTL expiry must not self-destruct. // The expired-but-resident entry is upserted, which fires moka's eviction // listener inline with cause `Expired`; the generation token keeps that // listener from removing the index key the refill just registered. #[tokio::test] async fn moka_backend_refill_after_expiry_without_maintenance_inserts() { let backend = MokaBackend::new(&enabled_config(), Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"); let plan = cacheable_plan("object", "etag-a"); let first = backend.fill_body(&plan, Bytes::from_static(b"hello")).await; assert_eq!(first, ObjectDataCacheFillResult::Inserted); // Let the entry expire but do NOT run pending tasks or look it up, so the // expired entry is still resident when the refill upserts over it. tokio::time::sleep(Duration::from_millis(150)).await; let refill = backend.fill_body(&plan, Bytes::from_static(b"world")).await; let lookup = backend.lookup_body(&plan).await; assert_eq!(refill, ObjectDataCacheFillResult::Inserted, "refill after expiry must not self-destruct"); assert!(matches!(lookup, ObjectDataCacheLookup::Hit(ref bytes) if bytes.as_ref() == b"world")); } // ODC-20: two concurrent fills for different keys of one identity must both // succeed. Fill A registers its key and pauses before publishing to the // cache; fill B's prune must keep A's in-flight key so A's recheck holds. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn moka_backend_concurrent_fills_same_identity_both_insert() { let backend = Arc::new( MokaBackend::new(&enabled_config(), Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"), ); let plan_a = versioned_plan("object", "v1", "etag-a"); let plan_b = versioned_plan("object", "v2", "etag-b"); let barrier = backend.install_fill_barrier(); let fill_a = { let backend = Arc::clone(&backend); let plan_a = plan_a.clone(); tokio::spawn(async move { backend.fill_body(&plan_a, Bytes::from_static(b"aaaaa")).await }) }; // Wait until A has registered its key and paused before the cache insert. barrier.wait_until_reached().await; // Clear the barrier so B runs to completion without pausing. *backend.fill_barrier.lock().unwrap() = None; let fill_b = backend.fill_body(&plan_b, Bytes::from_static(b"bbbbb")).await; assert_eq!(fill_b, ObjectDataCacheFillResult::Inserted); // Release A and let it finish its recheck. barrier.release(); let fill_a = fill_a.await.expect("fill A task should complete"); assert_eq!( fill_a, ObjectDataCacheFillResult::Inserted, "an in-flight sibling fill must not phantom-race" ); assert!(matches!(backend.lookup_body(&plan_a).await, ObjectDataCacheLookup::Hit(_))); assert!(matches!(backend.lookup_body(&plan_b).await, ObjectDataCacheLookup::Hit(_))); } // ODC-23: exceeding the per-identity key budget evicts only the oldest key // and still caches the newest body, instead of clearing the whole identity. #[tokio::test] async fn moka_backend_identity_budget_evicts_oldest_and_caches_newest() { let (backend, plan_v1, plan_v2, plan_v3) = identity_budget_fixture(); assert_eq!( backend.fill_body(&plan_v1, Bytes::from_static(b"aaaaa")).await, ObjectDataCacheFillResult::Inserted ); assert_eq!( backend.fill_body(&plan_v2, Bytes::from_static(b"bbbbb")).await, ObjectDataCacheFillResult::Inserted ); // The third fill exceeds the budget of 2 and evicts the oldest (v1). assert_eq!( backend.fill_body(&plan_v3, Bytes::from_static(b"ccccc")).await, ObjectDataCacheFillResult::Inserted ); assert!( matches!(backend.lookup_body(&plan_v1).await, ObjectDataCacheLookup::Miss), "oldest key must be evicted" ); assert!(matches!(backend.lookup_body(&plan_v2).await, ObjectDataCacheLookup::Hit(_))); assert!( matches!(backend.lookup_body(&plan_v3).await, ObjectDataCacheLookup::Hit(_)), "newest body must be cached" ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn identity_budget_eviction_preserves_refill_before_conditional_delete() { let (backend, plan_v1, plan_v2, plan_v3) = identity_budget_fixture(); assert_eq!( backend.fill_body(&plan_v1, Bytes::from_static(b"old-a")).await, ObjectDataCacheFillResult::Inserted ); assert_eq!( backend.fill_body(&plan_v2, Bytes::from_static(b"body-b")).await, ObjectDataCacheFillResult::Inserted ); let barrier = backend.install_identity_eviction_barrier(); let fill_v3 = { let backend = Arc::clone(&backend); let plan_v3 = plan_v3.clone(); tokio::spawn(async move { backend.fill_body(&plan_v3, Bytes::from_static(b"body-c")).await }) }; barrier.wait_until_reached().await; *backend.identity_eviction_barrier.lock().unwrap_or_else(|p| p.into_inner()) = None; assert_eq!( backend.fill_body(&plan_v1, Bytes::from_static(b"new-a")).await, ObjectDataCacheFillResult::Inserted ); assert!(matches!( backend.lookup_body(&plan_v1).await, ObjectDataCacheLookup::Hit(ref bytes) if bytes.as_ref() == b"new-a" )); barrier.release(); assert_eq!(fill_v3.await.expect("v3 fill task should complete"), ObjectDataCacheFillResult::Inserted); assert!(matches!( backend.lookup_body(&plan_v1).await, ObjectDataCacheLookup::Hit(ref bytes) if bytes.as_ref() == b"new-a" )); } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn identity_budget_conditional_delete_is_atomic_with_refill() { let (backend, plan_v1, plan_v2, plan_v3) = identity_budget_fixture(); assert_eq!( backend.fill_body(&plan_v1, Bytes::from_static(b"old-a")).await, ObjectDataCacheFillResult::Inserted ); assert_eq!( backend.fill_body(&plan_v2, Bytes::from_static(b"body-b")).await, ObjectDataCacheFillResult::Inserted ); let compute_barrier = backend.install_identity_compute_barrier(); let fill_v3 = { let backend = Arc::clone(&backend); let plan_v3 = plan_v3.clone(); tokio::spawn(async move { backend.fill_body(&plan_v3, Bytes::from_static(b"body-c")).await }) }; compute_barrier.wait_until_reached().await; *backend.identity_compute_barrier.lock().unwrap_or_else(|p| p.into_inner()) = None; let fill_barrier = backend.install_fill_barrier(); let refill_v1 = { let backend = Arc::clone(&backend); let plan_v1 = plan_v1.clone(); tokio::spawn(async move { backend.fill_body(&plan_v1, Bytes::from_static(b"new-a")).await }) }; fill_barrier.wait_until_reached().await; fill_barrier.release(); tokio::task::yield_now().await; assert!(!refill_v1.is_finished(), "the refill must serialize behind the same-key compute"); compute_barrier.release(); assert_eq!(fill_v3.await.expect("v3 fill task should complete"), ObjectDataCacheFillResult::Inserted); assert_eq!( refill_v1.await.expect("v1 refill task should complete"), ObjectDataCacheFillResult::Inserted ); assert!(matches!( backend.lookup_body(&plan_v1).await, ObjectDataCacheLookup::Hit(ref bytes) if bytes.as_ref() == b"new-a" )); } #[tokio::test] async fn identity_budget_eviction_keeps_index_and_cache_generation_consistent() { let (backend, plan_v1, plan_v2, plan_v3) = identity_budget_fixture(); assert_eq!( backend.fill_body(&plan_v1, Bytes::from_static(b"body-a")).await, ObjectDataCacheFillResult::Inserted ); assert_eq!( backend.fill_body(&plan_v2, Bytes::from_static(b"body-b")).await, ObjectDataCacheFillResult::Inserted ); assert_eq!( backend.fill_body(&plan_v3, Bytes::from_static(b"body-c")).await, ObjectDataCacheFillResult::Inserted ); let ObjectDataCacheGetPlan::Cacheable { key: key_v1 } = &plan_v1 else { panic!("versioned plan should be cacheable"); }; let ObjectDataCacheGetPlan::Cacheable { key: key_v2 } = &plan_v2 else { panic!("versioned plan should be cacheable"); }; let ObjectDataCacheGetPlan::Cacheable { key: key_v3 } = &plan_v3 else { panic!("versioned plan should be cacheable"); }; let identity = ObjectDataCacheIdentity::new("bucket", "object"); let entry_v2 = backend.cache.get(key_v2).await.expect("v2 should remain cached"); let entry_v3 = backend.cache.get(key_v3).await.expect("v3 should remain cached"); assert!(backend.cache.get(key_v1).await.is_none(), "evicted v1 must not remain cached"); assert!(!backend.index.contains_key(&identity, key_v1).await, "evicted v1 must not remain indexed"); assert!( backend .index .contains_generation(&identity, key_v2, entry_v2.generation()) .await, "v2 index generation must match its cache entry" ); assert!( backend .index .contains_generation(&identity, key_v3, entry_v3.generation()) .await, "v3 index generation must match its cache entry" ); } #[tokio::test] async fn cache_generation_is_monotonic_across_clear_and_overflow_fails_closed() { let mut config = enabled_config(); config.ttl = Duration::from_secs(30); config.time_to_idle = Duration::from_secs(30); let backend = MokaBackend::new(&config, Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"); let plan_v1 = versioned_plan("object", "v1", "etag-1"); let plan_v2 = versioned_plan("object", "v2", "etag-2"); let ObjectDataCacheGetPlan::Cacheable { key: key_v1 } = &plan_v1 else { panic!("versioned plan should be cacheable"); }; assert_eq!( backend.fill_body(&plan_v1, Bytes::from_static(b"old-a")).await, ObjectDataCacheFillResult::Inserted ); let first_generation = backend .cache .get(key_v1) .await .expect("first generation should be cached") .generation(); assert_eq!(backend.clear().await, ObjectDataCacheInvalidationResult::Removed { keys: 1 }); assert_eq!( backend.fill_body(&plan_v1, Bytes::from_static(b"new-a")).await, ObjectDataCacheFillResult::Inserted ); let second_generation = backend .cache .get(key_v1) .await .expect("second generation should be cached") .generation(); assert!(second_generation > first_generation, "clear must not reset the generation counter"); backend.next_generation.store(u64::MAX, std::sync::atomic::Ordering::Relaxed); assert_eq!( backend.fill_body(&plan_v2, Bytes::from_static(b"body-b")).await, ObjectDataCacheFillResult::SkippedIdentityOverflow ); assert!(matches!(backend.lookup_body(&plan_v2).await, ObjectDataCacheLookup::Miss)); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn identity_budget_eviction_preserves_refill_after_caller_cancellation() { let (backend, plan_v1, plan_v2, plan_v3) = identity_budget_fixture(); assert_eq!( backend.fill_body(&plan_v1, Bytes::from_static(b"old-a")).await, ObjectDataCacheFillResult::Inserted ); assert_eq!( backend.fill_body(&plan_v2, Bytes::from_static(b"body-b")).await, ObjectDataCacheFillResult::Inserted ); let barrier = backend.install_identity_eviction_barrier(); let fill_v3 = { let backend = Arc::clone(&backend); let plan_v3 = plan_v3.clone(); tokio::spawn(async move { backend.fill_body(&plan_v3, Bytes::from_static(b"body-c")).await }) }; barrier.wait_until_reached().await; fill_v3.abort(); *backend.identity_eviction_barrier.lock().unwrap_or_else(|p| p.into_inner()) = None; assert_eq!( backend.fill_body(&plan_v1, Bytes::from_static(b"new-a")).await, ObjectDataCacheFillResult::Inserted ); barrier.release(); barrier.wait_until_completed().await; assert!(matches!( backend.lookup_body(&plan_v1).await, ObjectDataCacheLookup::Hit(ref bytes) if bytes.as_ref() == b"new-a" )); } // ODC-31: the fill-vs-invalidation recheck. Pause the fill between the index // insert and the cache insert, invalidate the identity, then resume; the fill // must undo itself and report the race, leaving nothing cached. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn moka_backend_fill_loses_race_to_invalidation() { let backend = Arc::new( MokaBackend::new(&enabled_config(), Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"), ); let plan = cacheable_plan("object", "etag-a"); let barrier = backend.install_fill_barrier(); let fill = { let backend = Arc::clone(&backend); let plan = plan.clone(); tokio::spawn(async move { backend.fill_body(&plan, Bytes::from_static(b"hello")).await }) }; barrier.wait_until_reached().await; // Invalidate while the fill is parked after registering the index key. let _ = backend .invalidate_object(&ObjectDataCacheIdentity::new("bucket", "object")) .await; barrier.release(); let result = fill.await.expect("fill task should complete"); let lookup = backend.lookup_body(&plan).await; assert_eq!(result, ObjectDataCacheFillResult::SkippedInvalidationRace); assert!( matches!(lookup, ObjectDataCacheLookup::Miss), "a body must not survive a racing invalidation" ); } // ODC-13: cancelling the enclosing GET future at the recheck point must still // undo a fill that lost the race, so no orphaned body survives with no index // entry. The spawned fill task completes the recheck/undo after cancellation. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn moka_backend_cancelled_fill_still_undoes_lost_race() { let backend = Arc::new( MokaBackend::new(&enabled_config(), Arc::new(ObjectDataCacheStats::default())).expect("moka backend should build"), ); let plan = cacheable_plan("object", "etag-a"); let barrier = backend.install_fill_barrier(); let fill = { let backend = Arc::clone(&backend); let plan = plan.clone(); tokio::spawn(async move { backend.fill_body(&plan, Bytes::from_static(b"hello")).await }) }; barrier.wait_until_reached().await; // A concurrent invalidation removes the index key the fill registered. let _ = backend .invalidate_object(&ObjectDataCacheIdentity::new("bucket", "object")) .await; // Cancel the enclosing fill future before it reaches the recheck. fill.abort(); // Resume the detached fill task so it runs the recheck and undo. barrier.release(); // The detached task finishes the undo; poll until nothing is cached. let mut cached = true; for _ in 0..200 { if matches!(backend.lookup_body(&plan).await, ObjectDataCacheLookup::Miss) { cached = false; break; } tokio::time::sleep(Duration::from_millis(5)).await; } assert!(!cached, "a cancelled fill must not leave an orphaned body after a racing invalidation"); } // ---- Concurrency stress (backlog#1107 production-readiness follow-up) ---- // // The deterministic race tests above pin specific interleavings with an // injected barrier. These drive sustained, real-parallelism contention on a // small key space to catch leaks and state corruption the pinned tests // cannot: a leaked singleflight leader, a stranded semaphore permit, an // index/cache divergence that only shows up under volume. /// Hammer fill / lookup / invalidate concurrently on a few hot identities, /// then assert the shared machinery is neither leaked nor corrupted. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn moka_backend_concurrency_storm_leaves_no_leaked_state() { let stats = Arc::new(ObjectDataCacheStats::default()); // Long TTL so entries survive the storm — we want fill-vs-invalidate // races, not just expiry churn. let mut config = enabled_config(); config.ttl = Duration::from_secs(30); config.time_to_idle = Duration::from_secs(30); let backend = Arc::new(MokaBackend::new(&config, Arc::clone(&stats)).expect("moka backend should build")); const TASKS: usize = 48; const ITERS: usize = 400; const KEYS: usize = 6; // small pool → heavy contention on shared identities let stress_plans: Arc> = Arc::new( (0..KEYS) .map(|k| versioned_plan(&format!("object-{}", k % 3), &format!("v{k}"), &format!("etag-{k}"))) .collect(), ); let mut handles = Vec::new(); for t in 0..TASKS { let backend = Arc::clone(&backend); let stress_plans = Arc::clone(&stress_plans); handles.push(tokio::spawn(async move { for i in 0..ITERS { let k = (t + i) % KEYS; let object = format!("object-{}", k % 3); let plan = &stress_plans[k]; // Deterministic per-(task,iter) mix: mostly fills, plus // lookups and invalidations racing against them. match (t + i) % 4 { 0 | 1 => { let _ = backend.fill_body(plan, Bytes::from_static(b"hello")).await; } 2 => { let _ = backend.lookup_body(plan).await; } _ => { let identity = ObjectDataCacheIdentity::new("bucket", object.as_str()); let _ = backend.invalidate_object(&identity).await; } } } })); } for handle in handles { handle.await.expect("stress task must not panic or deadlock"); } // Every fill_body call returned, so no singleflight leader may still be // registered: a non-zero count means a fill leaked its in-flight slot. assert_eq!( stats.snapshot().inflight_fills, 0, "the concurrency storm leaked an in-flight fill (singleflight/semaphore not released)" ); // The shared state is not corrupted: a fresh, uncontended sequence must // still fill, hit, and invalidate correctly. let clean = versioned_plan("fresh-object", "v1", "fresh-etag"); assert_eq!( backend.fill_body(&clean, Bytes::from_static(b"hello")).await, ObjectDataCacheFillResult::Inserted, "a clean fill after the storm must still insert" ); assert!(matches!(backend.lookup_body(&clean).await, ObjectDataCacheLookup::Hit(_))); let _ = backend .invalidate_object(&ObjectDataCacheIdentity::new("bucket", "fresh-object")) .await; assert!( matches!(backend.lookup_body(&clean).await, ObjectDataCacheLookup::Miss), "invalidation must still win after a concurrency storm" ); let _ = backend.clear().await; for plan in stress_plans.iter().chain(std::iter::once(&clean)) { assert!( matches!(backend.lookup_body(plan).await, ObjectDataCacheLookup::Miss), "clear() must make every exercised key unobservable" ); } assert!( backend.cache.iter().next().is_none(), "clear() must expose no entries through iteration (approximate entry count: {})", backend.entry_count() ); } /// A memory-constrained container (low snapshot) must reject an entire /// concurrent fill burst — admission is bounded, not a check-then-act race /// that lets the burst through. This is the CI-runnable proxy for the /// OOM-under-burst concern; it does not cover the separate 5s-stale-snapshot /// overshoot, which needs byte-based reservation in the gate. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn moka_backend_gate_bounds_admission_under_concurrent_burst() { let stats = Arc::new(ObjectDataCacheStats::default()); let backend = Arc::new(MokaBackend::new(&memory_gated_config(), Arc::clone(&stats)).expect("moka backend should build")); // Simulate a pod whose available memory is far below the free floor. backend .memory_gate .set_test_snapshot(Some(crate::memory::ObjectDataCacheMemorySnapshot { total_bytes: 1_000, available_bytes: 50, })); const TASKS: usize = 32; let mut handles = Vec::new(); for t in 0..TASKS { let backend = Arc::clone(&backend); handles.push(tokio::spawn(async move { let plan = versioned_plan(&format!("object-{t}"), "v1", &format!("etag-{t}")); backend.fill_body(&plan, Bytes::from_static(b"hello")).await })); } let mut rejected = 0; for handle in handles { if handle.await.expect("burst task should complete") == ObjectDataCacheFillResult::SkippedMemoryPressure { rejected += 1; } } assert_eq!(rejected, TASKS, "the gate must reject every fill in the burst when memory is constrained"); assert_eq!(backend.entry_count(), 0, "no body may be admitted under memory pressure"); } /// A burst that begins while the snapshot still reads *high* must be bounded /// to the real headroom, not admitted wholesale. Each fill is large enough /// that only a handful fit the budget; without the admitted-bytes reservation /// every fill would read the same stale-high snapshot and be admitted, /// over-allocating far past the budget before the 5 s refresh (backlog#1107). #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn moka_backend_gate_reservation_bounds_burst_under_stale_snapshot() { let stats = Arc::new(ObjectDataCacheStats::default()); let backend = Arc::new(MokaBackend::new(&memory_gated_config(), Arc::clone(&stats)).expect("moka backend should build")); // Stale-but-high snapshot: 500 KiB available, 20% floor = 200 KiB, so the // real budget above the floor is ~300 KiB. backend .memory_gate .set_test_snapshot(Some(crate::memory::ObjectDataCacheMemorySnapshot { total_bytes: 1_000_000, available_bytes: 500_000, })); // 20 concurrent fills of 40 KiB each = 800 KiB requested, far past the // ~300 KiB budget. Only ~7 should fit. const TASKS: usize = 20; const BODY: usize = 40_000; let mut handles = Vec::new(); for t in 0..TASKS { let backend = Arc::clone(&backend); handles.push(tokio::spawn(async move { let plan = versioned_plan(&format!("object-{t}"), "v1", &format!("etag-{t}")); backend.fill_body(&plan, Bytes::from(vec![0u8; BODY])).await })); } let mut admitted = 0usize; for handle in handles { if handle.await.expect("burst task should complete") == ObjectDataCacheFillResult::Inserted { admitted += 1; } } // The reservation must cap the burst: not every fill gets in (that is the // stale-snapshot overshoot), yet some do (the gate is not just rejecting // everything). The exact count varies with the check-then-reserve race, // but the admitted bytes must stay within a small multiple of the budget. assert!( admitted < TASKS, "reservation must bound the burst — not admit the whole {TASKS}-fill storm" ); assert!(admitted >= 1, "the gate must still admit fills that fit the budget"); assert!( admitted * BODY <= 500_000, "admitted bytes ({}) must not exceed the snapshot's available memory", admitted * BODY ); } }