From a3913f1bf4560e0b7d12ba7b5bac98c52a080a81 Mon Sep 17 00:00:00 2001 From: "Ethiopia Mengesha (from Dev Box)" Date: Thu, 21 May 2026 10:58:07 -0700 Subject: [PATCH 01/27] feat(cachet): add get_or_insert_with and try_get_or_insert_with Add methods where the closure returns CacheEntry instead of raw V, enabling per-entry TTL control on cache-miss computations. Both methods include full stampede protection via dedicated Mergers. --- crates/cachet/CHANGELOG.md | 6 ++ crates/cachet/Cargo.toml | 2 +- crates/cachet/src/cache.rs | 166 ++++++++++++++++++++++++++++++++++ crates/cachet/tests/cache.rs | 170 +++++++++++++++++++++++++++++++++++ 4 files changed, 343 insertions(+), 1 deletion(-) diff --git a/crates/cachet/CHANGELOG.md b/crates/cachet/CHANGELOG.md index 2ee1bb021..0ed595ed2 100644 --- a/crates/cachet/CHANGELOG.md +++ b/crates/cachet/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [0.6.0] - 2026-05-21 + +- ✨ Features + + - Add `get_or_insert_with` and `try_get_or_insert_with` methods that accept closures returning `CacheEntry`, enabling per-entry TTL control on cache-miss computations. + ## [0.5.0] - 2026-05-19 - ✔️ Tasks diff --git a/crates/cachet/Cargo.toml b/crates/cachet/Cargo.toml index 1b9b64f84..4566684d2 100644 --- a/crates/cachet/Cargo.toml +++ b/crates/cachet/Cargo.toml @@ -4,7 +4,7 @@ [package] name = "cachet" description = "A composable, customizable multi-tier caching library with rich feature support." -version = "0.5.0" +version = "0.6.0" readme = "README.md" keywords = ["oxidizer", "caching", "concurrency"] categories = ["caching", "concurrency"] diff --git a/crates/cachet/src/cache.rs b/crates/cachet/src/cache.rs index 61cc9d1e7..c74cafeec 100644 --- a/crates/cachet/src/cache.rs +++ b/crates/cachet/src/cache.rs @@ -27,7 +27,9 @@ struct Mergers { get: Merger>, Error>>, invalidate: Merger>, get_or_insert: Merger, Error>>, + get_or_insert_with: Merger, Error>>, try_get_or_insert: Merger, Error>>, + try_get_or_insert_with: Merger, Error>>, optionally_get_or_insert: Merger>, Error>>, } @@ -41,7 +43,9 @@ where get: Merger::new(), invalidate: Merger::new(), get_or_insert: Merger::new(), + get_or_insert_with: Merger::new(), try_get_or_insert: Merger::new(), + try_get_or_insert_with: Merger::new(), optionally_get_or_insert: Merger::new(), } } @@ -433,6 +437,168 @@ where Ok(entry) } + /// Retrieves a value from cache, or computes and caches a [`CacheEntry`] if missing. + /// + /// Like [`get_or_insert`](Self::get_or_insert), but the closure returns a full + /// [`CacheEntry`] instead of a raw `V`. This gives callers control over + /// per-entry metadata such as TTL via [`CacheEntry::expires_after`]. + /// + /// # Per-Entry TTL + /// + /// When the closure returns a `CacheEntry` with a TTL set (e.g., via + /// [`CacheEntry::expires_after`]), that TTL takes precedence over any tier-level + /// TTL configured on the cache. This is useful when the compute function returns + /// data with a known validity period (e.g., tokens with an expiry timestamp). + /// + /// # Concurrency + /// + /// Subject to the same TOCTOU window as [`get_or_insert`](Self::get_or_insert) - + /// see its Concurrency section for details. + /// + /// # Stampede Protection + /// + /// When enabled via [`stampede_protection()`](crate::CacheBuilder::stampede_protection), + /// concurrent calls for the same missing key are coalesced - only one caller + /// computes the value while others wait and share the result. + /// + /// # Errors + /// + /// Returns an error if the underlying cache operation fails or (with stampede + /// protection) if the leader task panics. + /// + /// # Examples + /// + /// ```no_run + /// use std::time::Duration; + /// + /// use cachet::{Cache, CacheEntry}; + /// use tick::Clock; + /// # async { + /// + /// let clock = Clock::new_tokio(); + /// let cache = Cache::builder::(clock).memory().build(); + /// + /// let entry = cache + /// .get_or_insert_with("key", || async { + /// let value = 42; // expensive computation + /// let ttl = Duration::from_secs(300); // determined by response + /// CacheEntry::expires_after(value, ttl) + /// }) + /// .await?; + /// assert_eq!(*entry.value(), 42); + /// # Ok::<(), cachet::Error>(()) + /// # }; + /// ``` + pub async fn get_or_insert_with(&self, key: &Q, f: impl FnOnce() -> Fut + Send) -> Result, Error> + where + K: Borrow, + Q: Hash + Eq + ToOwned + ?Sized + Send + Sync, + Fut: Future> + Send, + { + let owned = key.to_owned(); + if let Some(mergers) = &self.mergers { + mergers + .get_or_insert_with + .execute(key, move || async move { self.do_get_or_insert_with(&owned, f).await }) + .await + .unwrap_or_else(|panicked| Err(Error::from_source(panicked))) + } else { + self.do_get_or_insert_with(&owned, f).await + } + } + + async fn do_get_or_insert_with(&self, key: &K, f: impl FnOnce() -> Fut) -> Result, Error> + where + Fut: Future>, + { + if let Some(entry) = self.storage.get(key).await? { + return Ok(entry); + } + let entry = f().await; + self.insert(key.clone(), entry.clone()).await?; + Ok(entry) + } + + /// Retrieves a value from cache, or computes and caches a [`CacheEntry`] if missing. + /// + /// Like [`get_or_insert_with`](Self::get_or_insert_with), but the closure can fail. + /// Only successful results are cached — errors are not cached, allowing retries. + /// + /// # Per-Entry TTL + /// + /// When the closure returns `Ok(CacheEntry)` with a TTL set, that TTL takes + /// precedence over any tier-level TTL. See [`get_or_insert_with`](Self::get_or_insert_with) + /// for details. + /// + /// # Stampede Protection + /// + /// When enabled via [`stampede_protection()`](crate::CacheBuilder::stampede_protection), + /// concurrent calls for the same missing key are coalesced. If the computation + /// fails, the error is shared with all waiters but not cached. + /// + /// # Errors + /// + /// Returns an error if: + /// - The provided function returns an error (wrapped via [`Error::from_source`]) + /// - The underlying cache operation fails + /// - With stampede protection, if the leader task panics + /// + /// Use [`Error::source_as`] to extract the original error type. + /// + /// # Examples + /// + /// ```no_run + /// use std::time::Duration; + /// + /// use cachet::{Cache, CacheEntry, Error}; + /// use tick::Clock; + /// # async { + /// + /// let clock = Clock::new_tokio(); + /// let cache = Cache::builder::(clock).memory().build(); + /// + /// let result = cache + /// .try_get_or_insert_with("key", || async { + /// let value = 42; + /// let ttl = Duration::from_secs(60); + /// Ok::<_, std::io::Error>(CacheEntry::expires_after(value, ttl)) + /// }) + /// .await; + /// assert!(result.is_ok()); + /// # }; + /// ``` + pub async fn try_get_or_insert_with(&self, key: &Q, f: impl FnOnce() -> Fut + Send) -> Result, Error> + where + K: Borrow, + Q: Hash + Eq + ToOwned + ?Sized + Send + Sync, + E: std::error::Error + Send + Sync + 'static, + Fut: Future, E>> + Send, + { + let owned = key.to_owned(); + if let Some(mergers) = &self.mergers { + mergers + .try_get_or_insert_with + .execute(key, move || async move { self.do_try_get_or_insert_with(&owned, f).await }) + .await + .unwrap_or_else(|panicked| Err(Error::from_source(panicked))) + } else { + self.do_try_get_or_insert_with(&owned, f).await + } + } + + async fn do_try_get_or_insert_with(&self, key: &K, f: impl FnOnce() -> Fut) -> Result, Error> + where + E: std::error::Error + Send + Sync + 'static, + Fut: Future, E>>, + { + if let Some(entry) = self.storage.get(key).await? { + return Ok(entry); + } + let entry = f().await.map_err(Error::from_source)?; + self.insert(key.clone(), entry.clone()).await?; + Ok(entry) + } + /// Retrieves a value from cache, or computes and caches it if missing. /// /// Like [`get_or_insert`](Self::get_or_insert), but the provided function can fail. diff --git a/crates/cachet/tests/cache.rs b/crates/cachet/tests/cache.rs index 8b8b3770c..ceaa6dcbe 100644 --- a/crates/cachet/tests/cache.rs +++ b/crates/cachet/tests/cache.rs @@ -338,6 +338,176 @@ fn cache_with_memory_is_sync() { assert_sync::>(); } +// ============================================================================= +// get_or_insert_with tests +// ============================================================================= + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn get_or_insert_with_computes_and_caches() { + let clock = Clock::new_frozen(); + let cache = Cache::builder::(clock).memory().build(); + + let key = "key".to_string(); + + let entry = cache + .get_or_insert_with(&key, || async { + CacheEntry::expires_after(42, std::time::Duration::from_secs(300)) + }) + .await + .unwrap(); + assert_eq!(*entry.value(), 42); + assert_eq!(entry.ttl(), Some(std::time::Duration::from_secs(300))); + + // Second call returns cached value, not the new closure result + let entry = cache + .get_or_insert_with(&key, || async { CacheEntry::new(100) }) + .await + .unwrap(); + assert_eq!(*entry.value(), 42); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn get_or_insert_with_preserves_per_entry_ttl() { + let clock = Clock::new_frozen(); + let cache = Cache::builder::(clock).memory().build(); + + let key = "key".to_string(); + let ttl = std::time::Duration::from_secs(60); + + let entry = cache + .get_or_insert_with(&key, || async { CacheEntry::expires_after(7, ttl) }) + .await + .unwrap(); + + assert_eq!(*entry.value(), 7); + assert_eq!(entry.ttl(), Some(ttl)); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn stampede_protection_get_or_insert_with() { + let clock = Clock::new_frozen(); + let cache = Cache::builder::(clock).memory().stampede_protection().build(); + + let key = "key".to_string(); + + let entry = cache + .get_or_insert_with(&key, || async { + CacheEntry::expires_after(42, std::time::Duration::from_secs(120)) + }) + .await + .unwrap(); + assert_eq!(*entry.value(), 42); + + // Second call returns cached value + let entry = cache + .get_or_insert_with(&key, || async { CacheEntry::new(100) }) + .await + .unwrap(); + assert_eq!(*entry.value(), 42); +} + +// ============================================================================= +// try_get_or_insert_with tests +// ============================================================================= + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn try_get_or_insert_with_success() { + let clock = Clock::new_frozen(); + let cache = Cache::builder::(clock).memory().build(); + + let key = "key".to_string(); + let ttl = std::time::Duration::from_secs(600); + + let entry = cache + .try_get_or_insert_with(&key, || async { + Ok::<_, Error>(CacheEntry::expires_after(42, ttl)) + }) + .await + .unwrap(); + assert_eq!(*entry.value(), 42); + assert_eq!(entry.ttl(), Some(ttl)); + + // Cached on second call + let entry = cache + .try_get_or_insert_with(&key, || async { + Ok::<_, Error>(CacheEntry::new(100)) + }) + .await + .unwrap(); + assert_eq!(*entry.value(), 42); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn try_get_or_insert_with_error_not_cached() { + let clock = Clock::new_frozen(); + let cache = Cache::builder::(clock).memory().build(); + + let key = "key".to_string(); + + let result: Result, Error> = cache + .try_get_or_insert_with(&key, || async { + Err(Error::from_message("computation failed")) + }) + .await; + result.expect_err("error should propagate"); + + // Not cached — second call with success should work + let entry = cache + .try_get_or_insert_with(&key, || async { + Ok::<_, Error>(CacheEntry::new(99)) + }) + .await + .unwrap(); + assert_eq!(*entry.value(), 99); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn stampede_protection_try_get_or_insert_with_success() { + let clock = Clock::new_frozen(); + let cache = Cache::builder::(clock).memory().stampede_protection().build(); + + let key = "key".to_string(); + + let entry = cache + .try_get_or_insert_with(&key, || async { + Ok::<_, Error>(CacheEntry::expires_after(42, std::time::Duration::from_secs(60))) + }) + .await + .unwrap(); + assert_eq!(*entry.value(), 42); + + // Cached on second call + let entry = cache + .try_get_or_insert_with(&key, || async { + Ok::<_, Error>(CacheEntry::new(100)) + }) + .await + .unwrap(); + assert_eq!(*entry.value(), 42); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn stampede_protection_try_get_or_insert_with_error() { + let clock = Clock::new_frozen(); + let cache = Cache::builder::(clock).memory().stampede_protection().build(); + + let key = "key".to_string(); + + let result: Result, Error> = cache + .try_get_or_insert_with(&key, || async { + Err(Error::from_message("test error")) + }) + .await; + result.expect_err("error should propagate through stampede protection"); +} + /// Verifies that `CacheEntry` is Send. #[test] fn cache_entry_is_send() { From bdd8c2c62d4b3635690b29f7192c3872b0ad67de Mon Sep 17 00:00:00 2001 From: "Ethiopia Mengesha (from Dev Box)" Date: Tue, 26 May 2026 07:37:03 -0700 Subject: [PATCH 02/27] get or insert with --- Cargo.lock | 2 +- crates/cachet/tests/cache.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index f67637ef7..0c424e188 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -433,7 +433,7 @@ dependencies = [ [[package]] name = "cachet" -version = "0.5.0" +version = "0.6.0" dependencies = [ "alloc_tracker", "anyspawn", diff --git a/crates/cachet/tests/cache.rs b/crates/cachet/tests/cache.rs index ceaa6dcbe..62902a9b7 100644 --- a/crates/cachet/tests/cache.rs +++ b/crates/cachet/tests/cache.rs @@ -1,3 +1,4 @@ + // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. From bb99884fc0039e159128e9ada60726d0337f139a Mon Sep 17 00:00:00 2001 From: "Ethiopia Mengesha (from Dev Box)" Date: Tue, 26 May 2026 13:03:04 -0700 Subject: [PATCH 03/27] add eviction telemetry --- crates/cachet/README.md | 22 ++--- crates/cachet/src/builder/buildable.rs | 10 ++- crates/cachet/src/builder/cache.rs | 34 ++++++++ crates/cachet/src/fallback.rs | 3 +- crates/cachet/src/lib.rs | 2 +- crates/cachet/src/refresh.rs | 13 ++- crates/cachet/src/telemetry/attributes.rs | 4 + crates/cachet/src/telemetry/cache.rs | 7 ++ crates/cachet/src/wrapper.rs | 97 +++++++++++++++++++---- crates/cachet/tests/cache.rs | 35 ++------ 10 files changed, 169 insertions(+), 58 deletions(-) diff --git a/crates/cachet/README.md b/crates/cachet/README.md index bca66ac12..30cd83670 100644 --- a/crates/cachet/README.md +++ b/crates/cachet/README.md @@ -256,7 +256,7 @@ See the `telemetry_subscriber` example for a complete demonstration. |Level|Events| |-----|------| |ERROR|`cache.get_error`, `cache.insert_error`, `cache.invalidate_error`, `cache.clear_error`| -|INFO|`cache.expired`, `cache.refresh_miss`, `cache.inserted`, `cache.insert_rejected`, `cache.invalidated`, `cache.fallback`| +|INFO|`cache.expired`, `cache.refresh_miss`, `cache.inserted`, `cache.insert_rejected`, `cache.invalidated`, `cache.fallback`, `cache.eviction`| |DEBUG|`cache.hit`, `cache.miss`, `cache.refresh_hit`, `cache.cleared`| @@ -265,26 +265,26 @@ See the `telemetry_subscriber` example for a complete demonstration. This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGkYW0CYXSEGy4k8ldDFPOhG2VNeXtD5nnKG6EPY6OfW5wBG8g18NOFNdxpYXKEG-n6c1asXb8uG6CeIkUqNVu-GxjETKbvQrZwG4I5xAXRHmeEYWSIgmhieXRlc2J1ZmUwLjUuMIJmY2FjaGV0ZTAuNS4wgm1jYWNoZXRfbWVtb3J5ZTAuMi4wgm5jYWNoZXRfc2VydmljZWUwLjEuMIJrY2FjaGV0X3RpZXJlMC4xLjCCZHRpY2tlMC4zLjCCZ3RyYWNpbmdmMC4xLjQ0gml1bmlmbGlnaHRlMC4yLjA - [__link0]: https://docs.rs/cachet/0.5.0/cachet/?search=TimeToRefresh + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbg_hDqE88LP4bMh0J5Y4y4Osb0zDJ1kwqOsoblCGrm49Rx2thZIiCaGJ5dGVzYnVmZTAuNS4wgmZjYWNoZXRlMC42LjCCbWNhY2hldF9tZW1vcnllMC4yLjCCbmNhY2hldF9zZXJ2aWNlZTAuMS4wgmtjYWNoZXRfdGllcmUwLjEuMIJkdGlja2UwLjMuMIJndHJhY2luZ2YwLjEuNDSCaXVuaWZsaWdodGUwLjIuMA + [__link0]: https://docs.rs/cachet/0.6.0/cachet/?search=TimeToRefresh [__link1]: https://crates.io/crates/uniflight/0.2.0 [__link10]: https://docs.rs/cachet_tier/0.1.0/cachet_tier/?search=CacheTier - [__link11]: https://docs.rs/cachet/0.5.0/cachet/?search=InsertPolicy - [__link12]: https://docs.rs/cachet/0.5.0/cachet/?search=TimeToRefresh + [__link11]: https://docs.rs/cachet/0.6.0/cachet/?search=InsertPolicy + [__link12]: https://docs.rs/cachet/0.6.0/cachet/?search=TimeToRefresh [__link13]: https://docs.rs/cachet_tier/0.1.0/cachet_tier/?search=Error [__link14]: https://crates.io/crates/cachet_tier/0.1.0 [__link15]: https://crates.io/crates/cachet_memory/0.2.0 [__link16]: https://docs.rs/moka [__link17]: https://crates.io/crates/cachet_service/0.1.0 - [__link18]: https://docs.rs/cachet/0.5.0/cachet/?search=telemetry::attributes + [__link18]: https://docs.rs/cachet/0.6.0/cachet/?search=telemetry::attributes [__link19]: https://docs.rs/bytesbuf/0.5.0/bytesbuf/?search=BytesView - [__link2]: https://docs.rs/cachet/0.5.0/cachet/?search=CacheBuilder::stampede_protection + [__link2]: https://docs.rs/cachet/0.6.0/cachet/?search=CacheBuilder::stampede_protection [__link20]: https://crates.io/crates/tracing/0.1.44 - [__link21]: https://docs.rs/cachet/0.5.0/cachet/?search=telemetry::attributes + [__link21]: https://docs.rs/cachet/0.6.0/cachet/?search=telemetry::attributes [__link3]: https://docs.rs/cachet_tier/0.1.0/cachet_tier/?search=CacheTier [__link4]: https://docs.rs/cachet_tier/0.1.0/cachet_tier/?search=DynamicCache - [__link5]: https://docs.rs/cachet/0.5.0/cachet/?search=InsertPolicy + [__link5]: https://docs.rs/cachet/0.6.0/cachet/?search=InsertPolicy [__link6]: https://docs.rs/tick/0.3.0/tick/?search=Clock - [__link7]: https://docs.rs/cachet/0.5.0/cachet/?search=Cache - [__link8]: https://docs.rs/cachet/0.5.0/cachet/?search=CacheBuilder + [__link7]: https://docs.rs/cachet/0.6.0/cachet/?search=Cache + [__link8]: https://docs.rs/cachet/0.6.0/cachet/?search=CacheBuilder [__link9]: https://docs.rs/cachet_tier/0.1.0/cachet_tier/?search=CacheEntry diff --git a/crates/cachet/src/builder/buildable.rs b/crates/cachet/src/builder/buildable.rs index 7c405b710..b4f928bb9 100644 --- a/crates/cachet/src/builder/buildable.rs +++ b/crates/cachet/src/builder/buildable.rs @@ -42,7 +42,15 @@ where } fn build_tier(self, clock: Clock, telemetry: CacheTelemetry) -> Self::TierOutput { - CacheWrapper::new(type_name::(self.name), self.storage, clock, self.ttl, telemetry, self.policy) + CacheWrapper::new( + type_name::(self.name), + self.storage, + clock, + self.ttl, + telemetry, + self.policy, + self.max_capacity, + ) } } diff --git a/crates/cachet/src/builder/cache.rs b/crates/cachet/src/builder/cache.rs index f9d05b583..c7289fa0b 100644 --- a/crates/cachet/src/builder/cache.rs +++ b/crates/cachet/src/builder/cache.rs @@ -44,6 +44,7 @@ pub struct CacheBuilder { pub(crate) clock: Clock, pub(crate) telemetry: CacheTelemetry, pub(crate) stampede_protection: bool, + pub(crate) max_capacity: Option, pub(crate) _phantom: PhantomData<(K, V)>, } @@ -57,6 +58,7 @@ impl CacheBuilder { clock, telemetry: CacheTelemetry::new(), stampede_protection: false, + max_capacity: None, _phantom: PhantomData, } } @@ -90,6 +92,7 @@ impl CacheBuilder { clock: self.clock, telemetry: self.telemetry, stampede_protection: self.stampede_protection, + max_capacity: self.max_capacity, _phantom: PhantomData, } } @@ -224,6 +227,37 @@ impl CacheBuilder { self } + /// Sets the maximum capacity for eviction telemetry. + /// + /// When set, a `cache.eviction` telemetry event is emitted whenever an + /// insert occurs while the cache is at or above this capacity, indicating + /// that an existing entry will be evicted to make room. + /// + /// This does **not** enforce capacity limits on the underlying storage; + /// it only enables eviction detection for telemetry purposes. Use the + /// storage backend's own capacity settings (e.g., + /// [`InMemoryCacheBuilder::max_capacity`](cachet_memory::InMemoryCacheBuilder::max_capacity)) + /// to actually bound the cache size. + /// + /// # Examples + /// + /// ```no_run + /// use cachet::Cache; + /// use cachet_memory::InMemoryCache; + /// use tick::Clock; + /// + /// let clock = Clock::new_tokio(); + /// let cache = Cache::builder::(clock) + /// .storage(InMemoryCache::with_max_capacity(1000)) + /// .max_capacity(1000) + /// .build(); + /// ``` + #[must_use] + pub fn max_capacity(mut self, capacity: u64) -> Self { + self.max_capacity = Some(capacity); + self + } + /// Sets the insert policy for this tier. /// /// The policy determines when values should be inserted into this tier. diff --git a/crates/cachet/src/fallback.rs b/crates/cachet/src/fallback.rs index 4a8d9ae52..c1f66496c 100644 --- a/crates/cachet/src/fallback.rs +++ b/crates/cachet/src/fallback.rs @@ -193,7 +193,7 @@ mod tests { fn make_primary() -> TestPrimary { let clock = Clock::new_frozen(); let telemetry = CacheTelemetry::new(); - CacheWrapper::new("primary", MockCache::new(), clock, None, telemetry, InsertPolicy::default()) + CacheWrapper::new("primary", MockCache::new(), clock, None, telemetry, InsertPolicy::default(), None) } fn make_fallback_cache() -> TestFallbackCache { @@ -446,6 +446,7 @@ mod tests { None, telemetry.clone(), InsertPolicy::default(), + None, ); let fc = FallbackCache::new("test", primary, fallback_mock, clock, Some(refresh), telemetry); diff --git a/crates/cachet/src/lib.rs b/crates/cachet/src/lib.rs index 61f8239c2..abe6a5562 100644 --- a/crates/cachet/src/lib.rs +++ b/crates/cachet/src/lib.rs @@ -254,7 +254,7 @@ //! | Level | Events | //! |-------|--------| //! | ERROR | `cache.get_error`, `cache.insert_error`, `cache.invalidate_error`, `cache.clear_error` | -//! | INFO | `cache.expired`, `cache.refresh_miss`, `cache.inserted`, `cache.insert_rejected`, `cache.invalidated`, `cache.fallback` | +//! | INFO | `cache.expired`, `cache.refresh_miss`, `cache.inserted`, `cache.insert_rejected`, `cache.invalidated`, `cache.fallback`, `cache.eviction` | //! | DEBUG | `cache.hit`, `cache.miss`, `cache.refresh_hit`, `cache.cleared` | mod builder; diff --git a/crates/cachet/src/refresh.rs b/crates/cachet/src/refresh.rs index d1eba0010..78a2ca99e 100644 --- a/crates/cachet/src/refresh.rs +++ b/crates/cachet/src/refresh.rs @@ -385,6 +385,7 @@ mod fetch_and_promote_tests { None, telemetry.clone(), InsertPolicy::never(), + None, ); let fc = FallbackCache::new("test", primary, fallback, clock, None, telemetry); @@ -475,7 +476,7 @@ mod fetch_and_promote_tests { fn make_wrapper(mock: MockCache) -> MockWrapper { let clock = Clock::new_frozen(); let telemetry = CacheTelemetry::new(); - CacheWrapper::new("test_primary", mock, clock, None, telemetry, InsertPolicy::default()) + CacheWrapper::new("test_primary", mock, clock, None, telemetry, InsertPolicy::default(), None) } fn build_mock_fallback_cache( @@ -507,7 +508,15 @@ mod fetch_and_promote_tests { let telemetry = CacheTelemetry::new(); let refresh = TimeToRefresh::new(Duration::from_secs(60), Spawner::new_tokio()); - let primary_wrapper = CacheWrapper::new("primary", primary, clock.clone(), None, telemetry.clone(), InsertPolicy::default()); + let primary_wrapper = CacheWrapper::new( + "primary", + primary, + clock.clone(), + None, + telemetry.clone(), + InsertPolicy::default(), + None, + ); let fc = FallbackCache::new("test", primary_wrapper, fallback, clock, Some(refresh), telemetry); let key = "key".to_string(); diff --git a/crates/cachet/src/telemetry/attributes.rs b/crates/cachet/src/telemetry/attributes.rs index f7f7733b7..e61310227 100644 --- a/crates/cachet/src/telemetry/attributes.rs +++ b/crates/cachet/src/telemetry/attributes.rs @@ -85,6 +85,9 @@ pub const EVENT_REFRESH_HIT: &str = "cache.refresh_hit"; /// A background refresh did not find data in the fallback tier. pub const EVENT_REFRESH_MISS: &str = "cache.refresh_miss"; +/// An entry was inserted while the cache was at capacity, causing an eviction. +pub const EVENT_EVICTION: &str = "cache.eviction"; + #[cfg(test)] mod tests { use super::*; @@ -114,6 +117,7 @@ mod tests { EVENT_CLEAR_ERROR, EVENT_REFRESH_HIT, EVENT_REFRESH_MISS, + EVENT_EVICTION, ]; for (i, a) in events.iter().enumerate() { diff --git a/crates/cachet/src/telemetry/cache.rs b/crates/cachet/src/telemetry/cache.rs index 2179f6a09..81a5fb925 100644 --- a/crates/cachet/src/telemetry/cache.rs +++ b/crates/cachet/src/telemetry/cache.rs @@ -165,6 +165,12 @@ impl CacheTelemetry { self.inner.info(cache_name, attributes::EVENT_INSERTED, duration); } + /// Records that an insert caused an eviction because the cache was at capacity. + #[inline] + pub(crate) fn cache_eviction(&self, cache_name: CacheName, duration: Duration) { + self.inner.info(cache_name, attributes::EVENT_EVICTION, duration); + } + /// Records a cache insert that was rejected by the insert policy. #[inline] pub(crate) fn insert_rejected(&self, cache_name: CacheName, duration: Duration) { @@ -290,6 +296,7 @@ mod tests { assert_emits(|t| t.refresh_hit("c", Duration::ZERO), attributes::EVENT_REFRESH_HIT); assert_emits(|t| t.refresh_miss("c", Duration::ZERO), attributes::EVENT_REFRESH_MISS); assert_emits(|t| t.cache_inserted("c", Duration::ZERO), attributes::EVENT_INSERTED); + assert_emits(|t| t.cache_eviction("c", Duration::ZERO), attributes::EVENT_EVICTION); assert_emits(|t| t.insert_rejected("c", Duration::ZERO), attributes::EVENT_INSERT_REJECTED); assert_emits(|t| t.insert_error("c", Duration::ZERO), attributes::EVENT_INSERT_ERROR); assert_emits(|t| t.cache_invalidated("c", Duration::ZERO), attributes::EVENT_INVALIDATED); diff --git a/crates/cachet/src/wrapper.rs b/crates/cachet/src/wrapper.rs index 679411570..bb5d87ca0 100644 --- a/crates/cachet/src/wrapper.rs +++ b/crates/cachet/src/wrapper.rs @@ -51,6 +51,7 @@ pub struct CacheWrapper { pub(crate) ttl: Option, pub(crate) telemetry: CacheTelemetry, pub(crate) policy: InsertPolicy, + pub(crate) max_capacity: Option, _phantom: PhantomData<(K, V)>, } @@ -62,6 +63,7 @@ impl CacheWrapper { ttl: Option, telemetry: CacheTelemetry, policy: InsertPolicy, + max_capacity: Option, ) -> Self { Self { name, @@ -70,6 +72,7 @@ impl CacheWrapper { ttl, telemetry, policy, + max_capacity, _phantom: PhantomData, } } @@ -144,9 +147,19 @@ where return Ok(()); } + // Check if inserting will cause an eviction (cache at capacity). + let at_capacity = if let Some(max_cap) = self.max_capacity { + self.inner.len().await.map_or(false, |len| len >= max_cap) + } else { + false + }; + let timed = self.clock.timed_async(self.inner.insert(key, entry)).await; match &timed.result { Ok(()) => { + if at_capacity { + self.telemetry.cache_eviction(self.name, timed.duration); + } self.telemetry.cache_inserted(self.name, timed.duration); } Err(_) => { @@ -198,7 +211,7 @@ mod tests { let clock = Clock::new_frozen(); let inner = MockCache::::new(); let telemetry = CacheTelemetry::new(); - let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default()); + let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default(), None); // Entry without TTL should not be expired let entry = CacheEntry::new(42); @@ -217,6 +230,7 @@ mod tests { Some(Duration::from_secs(60)), telemetry, InsertPolicy::default(), + None, ); // Entry without cached_at should be expired if TTL is configured (treat as expired to be safe) @@ -233,8 +247,15 @@ mod tests { let telemetry = CacheTelemetry::new(); let tier_ttl = Duration::from_secs(60); let entry_ttl = Duration::from_secs(30); - let wrapper: CacheWrapper = - CacheWrapper::new("test", inner, clock.clone(), Some(tier_ttl), telemetry, InsertPolicy::default()); + let wrapper: CacheWrapper = CacheWrapper::new( + "test", + inner, + clock.clone(), + Some(tier_ttl), + telemetry, + InsertPolicy::default(), + None, + ); let entry = CacheEntry::expires_at(42, entry_ttl, clock.system_time()); @@ -251,7 +272,7 @@ mod tests { let inner = MockCache::::new(); let inner_check = inner.clone(); let telemetry = CacheTelemetry::new(); - let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default()); + let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default(), None); let entry = CacheEntry::new(42); wrapper.insert("key".to_string(), entry).await.unwrap(); @@ -269,7 +290,7 @@ mod tests { let telemetry = CacheTelemetry::new(); let tier_ttl = Duration::from_secs(60); let wrapper: CacheWrapper = - CacheWrapper::new("test", inner, clock, Some(tier_ttl), telemetry, InsertPolicy::default()); + CacheWrapper::new("test", inner, clock, Some(tier_ttl), telemetry, InsertPolicy::default(), None); let entry = CacheEntry::new(42); wrapper.insert("key".to_string(), entry).await.unwrap(); @@ -290,6 +311,7 @@ mod tests { Some(Duration::from_secs(60)), telemetry, InsertPolicy::default(), + None, ); // Entry with cached_at in the future simulates clock going backward @@ -304,7 +326,7 @@ mod tests { let telemetry = CacheTelemetry::new(); let ttl = Duration::from_secs(60); let wrapper: CacheWrapper = - CacheWrapper::new("test", inner, clock.clone(), Some(ttl), telemetry, InsertPolicy::default()); + CacheWrapper::new("test", inner, clock.clone(), Some(ttl), telemetry, InsertPolicy::default(), None); // Entry cached exactly TTL ago → elapsed == ttl → should NOT be expired (uses >) let entry = CacheEntry::expires_at(42, ttl, clock.system_time() - ttl); @@ -316,7 +338,8 @@ mod tests { let clock = Clock::new_frozen(); let inner = MockCache::::new(); let telemetry = CacheTelemetry::new(); - let wrapper: CacheWrapper = CacheWrapper::new("mock_test", inner, clock, None, telemetry, InsertPolicy::default()); + let wrapper: CacheWrapper = + CacheWrapper::new("mock_test", inner, clock, None, telemetry, InsertPolicy::default(), None); assert_eq!(wrapper.name(), "mock_test"); } @@ -325,7 +348,7 @@ mod tests { let clock = Clock::new_frozen(); let inner = MockCache::::new(); let telemetry = CacheTelemetry::new(); - let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default()); + let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default(), None); let result = wrapper.handle_get_result(None, Duration::from_secs(0)); assert!(result.is_none()); } @@ -342,6 +365,7 @@ mod tests { Some(Duration::from_secs(60)), telemetry, InsertPolicy::default(), + None, ); // Entry without cached_at → considered expired let entry = CacheEntry::new(42); @@ -354,7 +378,7 @@ mod tests { let clock = Clock::new_frozen(); let inner = MockCache::::new(); let telemetry = CacheTelemetry::new(); - let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default()); + let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default(), None); let entry = CacheEntry::new(42); let result = wrapper.handle_get_result(Some(entry), Duration::from_secs(0)); assert!(result.is_some()); @@ -366,7 +390,7 @@ mod tests { let clock = Clock::new_frozen(); let inner = MockCache::::new(); let telemetry = CacheTelemetry::new(); - let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default()); + let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default(), None); // get miss assert!(wrapper.get(&"key".to_string()).await.unwrap().is_none()); @@ -392,7 +416,7 @@ mod tests { let clock = Clock::new_frozen(); let inner = MockCache::::new(); let telemetry = CacheTelemetry::new(); - let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default()); + let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default(), None); assert_eq!(wrapper.len().await.expect("len should return Ok"), 0); wrapper.insert("key".to_string(), CacheEntry::new(1)).await.unwrap(); assert_eq!(wrapper.len().await.expect("len should return Ok"), 1); @@ -405,7 +429,7 @@ mod tests { let inner = MockCache::::new(); inner.fail_when(|op| matches!(op, cachet_tier::CacheOp::Get(_))); let telemetry = CacheTelemetry::new(); - let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default()); + let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default(), None); let result = wrapper.get(&"key".to_string()).await; result.unwrap_err(); } @@ -417,7 +441,7 @@ mod tests { let inner = MockCache::::new(); inner.fail_when(|op| matches!(op, cachet_tier::CacheOp::Insert { .. })); let telemetry = CacheTelemetry::new(); - let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default()); + let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default(), None); let result = wrapper.insert("key".to_string(), CacheEntry::new(1)).await; result.unwrap_err(); } @@ -429,7 +453,7 @@ mod tests { let inner = MockCache::::new(); inner.fail_when(|op| matches!(op, cachet_tier::CacheOp::Invalidate(_))); let telemetry = CacheTelemetry::new(); - let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default()); + let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default(), None); let result = wrapper.invalidate(&"key".to_string()).await; result.unwrap_err(); } @@ -441,8 +465,51 @@ mod tests { let inner = MockCache::::new(); inner.fail_when(|op| matches!(op, cachet_tier::CacheOp::Clear)); let telemetry = CacheTelemetry::new(); - let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default()); + let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default(), None); let result = wrapper.clear().await; result.unwrap_err(); } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn insert_at_capacity_emits_eviction_event() { + use testing_aids::LogCapture; + + let capture = LogCapture::new(); + let _guard = tracing::subscriber::set_default(capture.subscriber()); + + let clock = Clock::new_frozen(); + let inner = MockCache::::new(); + let telemetry = CacheTelemetry::with_logging(); + let wrapper: CacheWrapper = + CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default(), Some(2)); + + // Below capacity: no eviction event. + wrapper.insert("a".to_string(), CacheEntry::new(1)).await.unwrap(); + wrapper.insert("b".to_string(), CacheEntry::new(2)).await.unwrap(); + assert!(!capture.output().contains(crate::telemetry::attributes::EVENT_EVICTION)); + + // At capacity: next insert should emit eviction. + wrapper.insert("c".to_string(), CacheEntry::new(3)).await.unwrap(); + capture.assert_contains(crate::telemetry::attributes::EVENT_EVICTION); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn insert_without_max_capacity_never_emits_eviction() { + use testing_aids::LogCapture; + + let capture = LogCapture::new(); + let _guard = tracing::subscriber::set_default(capture.subscriber()); + + let clock = Clock::new_frozen(); + let inner = MockCache::::new(); + let telemetry = CacheTelemetry::with_logging(); + let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default(), None); + + for i in 0..5 { + wrapper.insert(format!("k{i}"), CacheEntry::new(i)).await.unwrap(); + } + assert!(!capture.output().contains(crate::telemetry::attributes::EVENT_EVICTION)); + } } diff --git a/crates/cachet/tests/cache.rs b/crates/cachet/tests/cache.rs index 62902a9b7..eed00c9fe 100644 --- a/crates/cachet/tests/cache.rs +++ b/crates/cachet/tests/cache.rs @@ -1,4 +1,3 @@ - // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. @@ -361,10 +360,7 @@ async fn get_or_insert_with_computes_and_caches() { assert_eq!(entry.ttl(), Some(std::time::Duration::from_secs(300))); // Second call returns cached value, not the new closure result - let entry = cache - .get_or_insert_with(&key, || async { CacheEntry::new(100) }) - .await - .unwrap(); + let entry = cache.get_or_insert_with(&key, || async { CacheEntry::new(100) }).await.unwrap(); assert_eq!(*entry.value(), 42); } @@ -403,10 +399,7 @@ async fn stampede_protection_get_or_insert_with() { assert_eq!(*entry.value(), 42); // Second call returns cached value - let entry = cache - .get_or_insert_with(&key, || async { CacheEntry::new(100) }) - .await - .unwrap(); + let entry = cache.get_or_insert_with(&key, || async { CacheEntry::new(100) }).await.unwrap(); assert_eq!(*entry.value(), 42); } @@ -424,9 +417,7 @@ async fn try_get_or_insert_with_success() { let ttl = std::time::Duration::from_secs(600); let entry = cache - .try_get_or_insert_with(&key, || async { - Ok::<_, Error>(CacheEntry::expires_after(42, ttl)) - }) + .try_get_or_insert_with(&key, || async { Ok::<_, Error>(CacheEntry::expires_after(42, ttl)) }) .await .unwrap(); assert_eq!(*entry.value(), 42); @@ -434,9 +425,7 @@ async fn try_get_or_insert_with_success() { // Cached on second call let entry = cache - .try_get_or_insert_with(&key, || async { - Ok::<_, Error>(CacheEntry::new(100)) - }) + .try_get_or_insert_with(&key, || async { Ok::<_, Error>(CacheEntry::new(100)) }) .await .unwrap(); assert_eq!(*entry.value(), 42); @@ -451,17 +440,13 @@ async fn try_get_or_insert_with_error_not_cached() { let key = "key".to_string(); let result: Result, Error> = cache - .try_get_or_insert_with(&key, || async { - Err(Error::from_message("computation failed")) - }) + .try_get_or_insert_with(&key, || async { Err(Error::from_message("computation failed")) }) .await; result.expect_err("error should propagate"); // Not cached — second call with success should work let entry = cache - .try_get_or_insert_with(&key, || async { - Ok::<_, Error>(CacheEntry::new(99)) - }) + .try_get_or_insert_with(&key, || async { Ok::<_, Error>(CacheEntry::new(99)) }) .await .unwrap(); assert_eq!(*entry.value(), 99); @@ -485,9 +470,7 @@ async fn stampede_protection_try_get_or_insert_with_success() { // Cached on second call let entry = cache - .try_get_or_insert_with(&key, || async { - Ok::<_, Error>(CacheEntry::new(100)) - }) + .try_get_or_insert_with(&key, || async { Ok::<_, Error>(CacheEntry::new(100)) }) .await .unwrap(); assert_eq!(*entry.value(), 42); @@ -502,9 +485,7 @@ async fn stampede_protection_try_get_or_insert_with_error() { let key = "key".to_string(); let result: Result, Error> = cache - .try_get_or_insert_with(&key, || async { - Err(Error::from_message("test error")) - }) + .try_get_or_insert_with(&key, || async { Err(Error::from_message("test error")) }) .await; result.expect_err("error should propagate through stampede protection"); } From 72037472334879e5363d9f57ddea8333406660cb Mon Sep 17 00:00:00 2001 From: "Ethiopia Mengesha (from Dev Box)" Date: Tue, 26 May 2026 13:05:34 -0700 Subject: [PATCH 04/27] docs --- crates/cachet/src/cache.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cachet/src/cache.rs b/crates/cachet/src/cache.rs index c74cafeec..ead9ea083 100644 --- a/crates/cachet/src/cache.rs +++ b/crates/cachet/src/cache.rs @@ -448,7 +448,7 @@ where /// When the closure returns a `CacheEntry` with a TTL set (e.g., via /// [`CacheEntry::expires_after`]), that TTL takes precedence over any tier-level /// TTL configured on the cache. This is useful when the compute function returns - /// data with a known validity period (e.g., tokens with an expiry timestamp). + /// data with a known validity period. /// /// # Concurrency /// From 3ca69695284fd26608b5d42e8502328adb592c60 Mon Sep 17 00:00:00 2001 From: "Ethiopia Mengesha (from Dev Box)" Date: Tue, 26 May 2026 13:22:33 -0700 Subject: [PATCH 05/27] spellcheck fix --- crates/cachet/src/builder/cache.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cachet/src/builder/cache.rs b/crates/cachet/src/builder/cache.rs index c7289fa0b..5b4f96c58 100644 --- a/crates/cachet/src/builder/cache.rs +++ b/crates/cachet/src/builder/cache.rs @@ -235,7 +235,7 @@ impl CacheBuilder { /// /// This does **not** enforce capacity limits on the underlying storage; /// it only enables eviction detection for telemetry purposes. Use the - /// storage backend's own capacity settings (e.g., + /// storage back-end's own capacity settings (e.g., /// [`InMemoryCacheBuilder::max_capacity`](cachet_memory::InMemoryCacheBuilder::max_capacity)) /// to actually bound the cache size. /// From e39727ad82beb96c9231578919c15141a934381d Mon Sep 17 00:00:00 2001 From: "Ethiopia Mengesha (from Dev Box)" Date: Tue, 26 May 2026 13:24:33 -0700 Subject: [PATCH 06/27] clippy --- crates/cachet/src/wrapper.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cachet/src/wrapper.rs b/crates/cachet/src/wrapper.rs index bb5d87ca0..0bebaa347 100644 --- a/crates/cachet/src/wrapper.rs +++ b/crates/cachet/src/wrapper.rs @@ -149,7 +149,7 @@ where // Check if inserting will cause an eviction (cache at capacity). let at_capacity = if let Some(max_cap) = self.max_capacity { - self.inner.len().await.map_or(false, |len| len >= max_cap) + self.inner.len().await.is_ok_and(|len| len >= max_cap) } else { false }; From e981439f87d6876ff73ff417aea2873f0db45c21 Mon Sep 17 00:00:00 2001 From: "Ethiopia Mengesha (from Dev Box)" Date: Tue, 26 May 2026 13:36:18 -0700 Subject: [PATCH 07/27] bump workspace version --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 9f6b6b883..900cad6c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ homepage = "https://github.com/microsoft/oxidizer" anyspawn = { path = "crates/anyspawn", default-features = false, version = "0.5.0" } bytesbuf = { path = "crates/bytesbuf", default-features = false, version = "0.5.0" } bytesbuf_io = { path = "crates/bytesbuf_io", default-features = false, version = "0.5.0" } -cachet = { path = "crates/cachet", default-features = false, version = "0.5.0" } +cachet = { path = "crates/cachet", default-features = false, version = "0.6.0" } cachet_memory = { path = "crates/cachet_memory", default-features = false, version = "0.2.0" } cachet_service = { path = "crates/cachet_service", default-features = false, version = "0.1.0" } cachet_tier = { path = "crates/cachet_tier", default-features = false, version = "0.1.0" } From 52422ad64976345ae39edf5d9424c2d629ad28d5 Mon Sep 17 00:00:00 2001 From: codingsnoop <166542027+codingsnoop@users.noreply.github.com> Date: Tue, 26 May 2026 13:37:01 -0700 Subject: [PATCH 08/27] add nuance to docs Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- crates/cachet/src/builder/cache.rs | 9 +++++++-- crates/cachet/src/telemetry/attributes.rs | 3 ++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/cachet/src/builder/cache.rs b/crates/cachet/src/builder/cache.rs index 5b4f96c58..b28715c0e 100644 --- a/crates/cachet/src/builder/cache.rs +++ b/crates/cachet/src/builder/cache.rs @@ -229,9 +229,14 @@ impl CacheBuilder { /// Sets the maximum capacity for eviction telemetry. /// - /// When set, a `cache.eviction` telemetry event is emitted whenever an + /// When set, a `cache.eviction` telemetry event may be emitted whenever an /// insert occurs while the cache is at or above this capacity, indicating - /// that an existing entry will be evicted to make room. + /// that an insert likely caused or coincided with eviction based on the + /// cache tier's reported length. + /// + /// This signal is heuristic only: it can be inaccurate for key replacement + /// or cache tiers with approximate length reporting, and cache tiers that + /// do not support `len()` will not emit this event. /// /// This does **not** enforce capacity limits on the underlying storage; /// it only enables eviction detection for telemetry purposes. Use the diff --git a/crates/cachet/src/telemetry/attributes.rs b/crates/cachet/src/telemetry/attributes.rs index e61310227..5c8359414 100644 --- a/crates/cachet/src/telemetry/attributes.rs +++ b/crates/cachet/src/telemetry/attributes.rs @@ -85,7 +85,8 @@ pub const EVENT_REFRESH_HIT: &str = "cache.refresh_hit"; /// A background refresh did not find data in the fallback tier. pub const EVENT_REFRESH_MISS: &str = "cache.refresh_miss"; -/// An entry was inserted while the cache was at capacity, causing an eviction. +/// An entry was inserted while the cache was at or above capacity and may have +/// caused an eviction. pub const EVENT_EVICTION: &str = "cache.eviction"; #[cfg(test)] From 5e0d6e37d1172888fb63eae2bac81b4db6820ffe Mon Sep 17 00:00:00 2001 From: "Ethiopia Mengesha (from Dev Box)" Date: Wed, 27 May 2026 08:07:35 -0700 Subject: [PATCH 09/27] implement eviction listener threading --- crates/cachet/Cargo.toml | 4 + crates/cachet/src/builder/buildable.rs | 15 ++- crates/cachet/src/builder/cache.rs | 128 ++++++++++++++++------- crates/cachet/src/eviction.rs | 100 ++++++++++++++++++ crates/cachet/src/fallback.rs | 3 +- crates/cachet/src/lib.rs | 2 + crates/cachet/src/refresh.rs | 13 +-- crates/cachet/src/wrapper.rs | 85 ++++----------- crates/cachet/tests/eviction.rs | 58 ++++++++++ crates/cachet_memory/README.md | 2 +- crates/cachet_memory/src/builder.rs | 69 +++++++++++- crates/cachet_memory/src/lib.rs | 3 + crates/cachet_memory/src/notification.rs | 56 ++++++++++ crates/cachet_memory/src/tier.rs | 6 ++ crates/cachet_service/README.md | 2 +- crates/cachet_tier/README.md | 2 +- 16 files changed, 421 insertions(+), 127 deletions(-) create mode 100644 crates/cachet/src/eviction.rs create mode 100644 crates/cachet/tests/eviction.rs create mode 100644 crates/cachet_memory/src/notification.rs diff --git a/crates/cachet/Cargo.toml b/crates/cachet/Cargo.toml index 4566684d2..8d7da00b2 100644 --- a/crates/cachet/Cargo.toml +++ b/crates/cachet/Cargo.toml @@ -89,6 +89,10 @@ name = "operations" harness = false required-features = ["logs", "test-util"] +[[test]] +name = "eviction" +required-features = ["memory", "logs"] + [[bench]] name = "dynamic" harness = false diff --git a/crates/cachet/src/builder/buildable.rs b/crates/cachet/src/builder/buildable.rs index b4f928bb9..befc93b83 100644 --- a/crates/cachet/src/builder/buildable.rs +++ b/crates/cachet/src/builder/buildable.rs @@ -42,15 +42,12 @@ where } fn build_tier(self, clock: Clock, telemetry: CacheTelemetry) -> Self::TierOutput { - CacheWrapper::new( - type_name::(self.name), - self.storage, - clock, - self.ttl, - telemetry, - self.policy, - self.max_capacity, - ) + let name = type_name::(self.name); + #[cfg(feature = "memory")] + if let Some(hook) = &self.eviction_hook { + hook.init(telemetry.clone(), name); + } + CacheWrapper::new(name, self.storage, clock, self.ttl, telemetry, self.policy) } } diff --git a/crates/cachet/src/builder/cache.rs b/crates/cachet/src/builder/cache.rs index 5b4f96c58..e87a2384f 100644 --- a/crates/cachet/src/builder/cache.rs +++ b/crates/cachet/src/builder/cache.rs @@ -3,15 +3,19 @@ use std::hash::Hash; use std::marker::PhantomData; +#[cfg(feature = "memory")] +use std::sync::Arc; use std::time::Duration; #[cfg(feature = "memory")] -use cachet_memory::InMemoryCache; +use cachet_memory::{InMemoryCache, InMemoryCacheBuilder}; use tick::Clock; use super::buildable::Buildable; use super::fallback::FallbackBuilder; use super::sealed::{CacheTierBuilder, Sealed}; +#[cfg(feature = "memory")] +use crate::eviction::EvictionHook; use crate::policy::InsertPolicy; use crate::telemetry::CacheTelemetry; use crate::{Cache, CacheTier}; @@ -44,7 +48,10 @@ pub struct CacheBuilder { pub(crate) clock: Clock, pub(crate) telemetry: CacheTelemetry, pub(crate) stampede_protection: bool, - pub(crate) max_capacity: Option, + #[cfg(feature = "memory")] + pub(crate) eviction_hook: Option>, + #[cfg(feature = "memory")] + pub(crate) eviction_telemetry: bool, pub(crate) _phantom: PhantomData<(K, V)>, } @@ -58,7 +65,10 @@ impl CacheBuilder { clock, telemetry: CacheTelemetry::new(), stampede_protection: false, - max_capacity: None, + #[cfg(feature = "memory")] + eviction_hook: None, + #[cfg(feature = "memory")] + eviction_telemetry: false, _phantom: PhantomData, } } @@ -92,7 +102,10 @@ impl CacheBuilder { clock: self.clock, telemetry: self.telemetry, stampede_protection: self.stampede_protection, - max_capacity: self.max_capacity, + #[cfg(feature = "memory")] + eviction_hook: self.eviction_hook, + #[cfg(feature = "memory")] + eviction_telemetry: self.eviction_telemetry, _phantom: PhantomData, } } @@ -118,7 +131,50 @@ impl CacheBuilder { K: Hash + Eq + Clone + Send + Sync + 'static, V: Clone + Send + Sync + 'static, { - self.storage(InMemoryCache::::new()) + self.memory_with(|b| b) + } + + /// Configures the cache to use in-memory storage, exposing the inner + /// [`InMemoryCacheBuilder`] for additional configuration (capacity, TTL, + /// eviction policy, custom hasher, etc.). + /// + /// Eviction telemetry is *not* installed unless + /// [`with_eviction_telemetry`](Self::with_eviction_telemetry) was called + /// before this method. + /// + /// # Panics + /// + /// Panics if the configured [`InMemoryCacheBuilder`] fails validation + /// (for example, `max_capacity < initial_capacity`). + /// + /// # Examples + /// + /// ```no_run + /// use cachet::Cache; + /// use tick::Clock; + /// + /// let clock = Clock::new_tokio(); + /// let cache = Cache::builder::(clock) + /// .memory_with(|b| b.max_capacity(1_000)) + /// .build(); + /// ``` + #[cfg(feature = "memory")] + #[must_use] + pub fn memory_with(mut self, configure: F) -> CacheBuilder> + where + K: Hash + Eq + Clone + Send + Sync + 'static, + V: Clone + Send + Sync + 'static, + F: FnOnce(InMemoryCacheBuilder) -> InMemoryCacheBuilder, + { + let mut builder = configure(InMemoryCacheBuilder::::new()); + if self.eviction_telemetry { + let hook = Arc::new(EvictionHook::new()); + let hook_for_listener = Arc::clone(&hook); + builder = builder.on_eviction(move |cause| hook_for_listener.handle(cause)); + self.eviction_hook = Some(hook); + } + let storage = builder.build().expect("InMemoryCacheBuilder configuration must be valid"); + self.storage(storage) } /// Configures the cache to use a service as the storage backend. @@ -175,6 +231,37 @@ impl CacheBuilder { self } + /// Enables `cache.eviction` telemetry for the in-memory tier. + /// + /// When enabled, the next call to [`memory`](Self::memory) / + /// [`memory_with`](Self::memory_with) installs a moka eviction listener + /// that reports `Size`- and `Expired`-cause evictions through the cache's + /// telemetry sink. `Explicit` and `Replaced` causes are *not* reported as + /// evictions, since they are already covered by the `cache.invalidated` + /// and `cache.inserted` events. + /// + /// Must be called *before* `memory`/`memory_with`; calling it afterwards + /// has no effect because the storage tier is constructed at that point. + /// + /// # Examples + /// + /// ```no_run + /// use cachet::Cache; + /// use tick::Clock; + /// + /// let clock = Clock::new_tokio(); + /// let cache = Cache::builder::(clock) + /// .with_eviction_telemetry() + /// .memory_with(|b| b.max_capacity(1_000)) + /// .build(); + /// ``` + #[cfg(feature = "memory")] + #[must_use] + pub fn with_eviction_telemetry(mut self) -> Self { + self.eviction_telemetry = true; + self + } + /// Enables stampede protection for cache reads. /// /// When enabled, concurrent requests for the same key will be merged @@ -227,37 +314,6 @@ impl CacheBuilder { self } - /// Sets the maximum capacity for eviction telemetry. - /// - /// When set, a `cache.eviction` telemetry event is emitted whenever an - /// insert occurs while the cache is at or above this capacity, indicating - /// that an existing entry will be evicted to make room. - /// - /// This does **not** enforce capacity limits on the underlying storage; - /// it only enables eviction detection for telemetry purposes. Use the - /// storage back-end's own capacity settings (e.g., - /// [`InMemoryCacheBuilder::max_capacity`](cachet_memory::InMemoryCacheBuilder::max_capacity)) - /// to actually bound the cache size. - /// - /// # Examples - /// - /// ```no_run - /// use cachet::Cache; - /// use cachet_memory::InMemoryCache; - /// use tick::Clock; - /// - /// let clock = Clock::new_tokio(); - /// let cache = Cache::builder::(clock) - /// .storage(InMemoryCache::with_max_capacity(1000)) - /// .max_capacity(1000) - /// .build(); - /// ``` - #[must_use] - pub fn max_capacity(mut self, capacity: u64) -> Self { - self.max_capacity = Some(capacity); - self - } - /// Sets the insert policy for this tier. /// /// The policy determines when values should be inserted into this tier. diff --git a/crates/cachet/src/eviction.rs b/crates/cachet/src/eviction.rs new file mode 100644 index 000000000..ca2e5052a --- /dev/null +++ b/crates/cachet/src/eviction.rs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Bridge between an in-memory tier's eviction listener and cache telemetry. +//! +//! The cache builder is configured incrementally: storage is selected before +//! `name`/`enable_logs` may be called. We therefore install a stable listener +//! at storage-construction time that defers to a [`OnceLock`] populated when +//! the cache is finally built. + +use std::sync::OnceLock; +use std::time::Duration; + +use cachet_memory::RemovalCause; + +use crate::cache::CacheName; +use crate::telemetry::CacheTelemetry; + +/// Bridges moka's eviction listener to the cachet telemetry layer. +#[derive(Debug)] +pub(crate) struct EvictionHook { + state: OnceLock, +} + +#[derive(Debug)] +struct HookState { + telemetry: CacheTelemetry, + name: CacheName, +} + +impl EvictionHook { + pub(crate) fn new() -> Self { + Self { state: OnceLock::new() } + } + + /// Binds the hook to a telemetry sink and cache name. + /// + /// Called once during `build_tier`. Subsequent calls are silently ignored + /// because the hook is keyed to the first build of a builder. + pub(crate) fn init(&self, telemetry: CacheTelemetry, name: CacheName) { + let _ = self.state.set(HookState { telemetry, name }); + } + + /// Invoked by the in-memory tier on each removal. + /// + /// Only `Size` and `Expired` causes are reported as evictions; `Explicit` + /// and `Replaced` are user-initiated and already accounted for by the + /// `cache.invalidated` / `cache.inserted` events. + pub(crate) fn handle(&self, cause: RemovalCause) { + if !matches!(cause, RemovalCause::Size | RemovalCause::Expired) { + return; + } + if let Some(state) = self.state.get() { + state.telemetry.cache_eviction(state.name, Duration::ZERO); + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use testing_aids::LogCapture; + + use super::*; + use crate::telemetry::attributes; + + #[cfg_attr(miri, ignore)] + #[test] + fn handle_before_init_is_noop() { + let capture = LogCapture::new(); + let _guard = tracing::subscriber::set_default(capture.subscriber()); + + let hook = EvictionHook::new(); + hook.handle(RemovalCause::Size); + + assert!(capture.output().is_empty(), "no event should fire before init"); + } + + #[cfg_attr(miri, ignore)] + #[test] + fn handle_after_init_emits_size_and_expired_only() { + let capture = LogCapture::new(); + let _guard = tracing::subscriber::set_default(capture.subscriber()); + + let hook = Arc::new(EvictionHook::new()); + hook.init(CacheTelemetry::with_logging(), "hook_test"); + + hook.handle(RemovalCause::Explicit); + hook.handle(RemovalCause::Replaced); + assert!( + !capture.output().contains(attributes::EVENT_EVICTION), + "Explicit/Replaced must not emit eviction events" + ); + + hook.handle(RemovalCause::Size); + hook.handle(RemovalCause::Expired); + capture.assert_contains(attributes::EVENT_EVICTION); + } +} diff --git a/crates/cachet/src/fallback.rs b/crates/cachet/src/fallback.rs index c1f66496c..4a8d9ae52 100644 --- a/crates/cachet/src/fallback.rs +++ b/crates/cachet/src/fallback.rs @@ -193,7 +193,7 @@ mod tests { fn make_primary() -> TestPrimary { let clock = Clock::new_frozen(); let telemetry = CacheTelemetry::new(); - CacheWrapper::new("primary", MockCache::new(), clock, None, telemetry, InsertPolicy::default(), None) + CacheWrapper::new("primary", MockCache::new(), clock, None, telemetry, InsertPolicy::default()) } fn make_fallback_cache() -> TestFallbackCache { @@ -446,7 +446,6 @@ mod tests { None, telemetry.clone(), InsertPolicy::default(), - None, ); let fc = FallbackCache::new("test", primary, fallback_mock, clock, Some(refresh), telemetry); diff --git a/crates/cachet/src/lib.rs b/crates/cachet/src/lib.rs index abe6a5562..a90f727e3 100644 --- a/crates/cachet/src/lib.rs +++ b/crates/cachet/src/lib.rs @@ -259,6 +259,8 @@ mod builder; mod cache; +#[cfg(feature = "memory")] +mod eviction; mod fallback; mod policy; mod refresh; diff --git a/crates/cachet/src/refresh.rs b/crates/cachet/src/refresh.rs index 78a2ca99e..d1eba0010 100644 --- a/crates/cachet/src/refresh.rs +++ b/crates/cachet/src/refresh.rs @@ -385,7 +385,6 @@ mod fetch_and_promote_tests { None, telemetry.clone(), InsertPolicy::never(), - None, ); let fc = FallbackCache::new("test", primary, fallback, clock, None, telemetry); @@ -476,7 +475,7 @@ mod fetch_and_promote_tests { fn make_wrapper(mock: MockCache) -> MockWrapper { let clock = Clock::new_frozen(); let telemetry = CacheTelemetry::new(); - CacheWrapper::new("test_primary", mock, clock, None, telemetry, InsertPolicy::default(), None) + CacheWrapper::new("test_primary", mock, clock, None, telemetry, InsertPolicy::default()) } fn build_mock_fallback_cache( @@ -508,15 +507,7 @@ mod fetch_and_promote_tests { let telemetry = CacheTelemetry::new(); let refresh = TimeToRefresh::new(Duration::from_secs(60), Spawner::new_tokio()); - let primary_wrapper = CacheWrapper::new( - "primary", - primary, - clock.clone(), - None, - telemetry.clone(), - InsertPolicy::default(), - None, - ); + let primary_wrapper = CacheWrapper::new("primary", primary, clock.clone(), None, telemetry.clone(), InsertPolicy::default()); let fc = FallbackCache::new("test", primary_wrapper, fallback, clock, Some(refresh), telemetry); let key = "key".to_string(); diff --git a/crates/cachet/src/wrapper.rs b/crates/cachet/src/wrapper.rs index 0bebaa347..141d17a38 100644 --- a/crates/cachet/src/wrapper.rs +++ b/crates/cachet/src/wrapper.rs @@ -51,7 +51,6 @@ pub struct CacheWrapper { pub(crate) ttl: Option, pub(crate) telemetry: CacheTelemetry, pub(crate) policy: InsertPolicy, - pub(crate) max_capacity: Option, _phantom: PhantomData<(K, V)>, } @@ -63,7 +62,6 @@ impl CacheWrapper { ttl: Option, telemetry: CacheTelemetry, policy: InsertPolicy, - max_capacity: Option, ) -> Self { Self { name, @@ -72,7 +70,6 @@ impl CacheWrapper { ttl, telemetry, policy, - max_capacity, _phantom: PhantomData, } } @@ -147,19 +144,9 @@ where return Ok(()); } - // Check if inserting will cause an eviction (cache at capacity). - let at_capacity = if let Some(max_cap) = self.max_capacity { - self.inner.len().await.is_ok_and(|len| len >= max_cap) - } else { - false - }; - let timed = self.clock.timed_async(self.inner.insert(key, entry)).await; match &timed.result { Ok(()) => { - if at_capacity { - self.telemetry.cache_eviction(self.name, timed.duration); - } self.telemetry.cache_inserted(self.name, timed.duration); } Err(_) => { @@ -211,7 +198,7 @@ mod tests { let clock = Clock::new_frozen(); let inner = MockCache::::new(); let telemetry = CacheTelemetry::new(); - let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default(), None); + let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default()); // Entry without TTL should not be expired let entry = CacheEntry::new(42); @@ -230,7 +217,6 @@ mod tests { Some(Duration::from_secs(60)), telemetry, InsertPolicy::default(), - None, ); // Entry without cached_at should be expired if TTL is configured (treat as expired to be safe) @@ -247,15 +233,8 @@ mod tests { let telemetry = CacheTelemetry::new(); let tier_ttl = Duration::from_secs(60); let entry_ttl = Duration::from_secs(30); - let wrapper: CacheWrapper = CacheWrapper::new( - "test", - inner, - clock.clone(), - Some(tier_ttl), - telemetry, - InsertPolicy::default(), - None, - ); + let wrapper: CacheWrapper = + CacheWrapper::new("test", inner, clock.clone(), Some(tier_ttl), telemetry, InsertPolicy::default()); let entry = CacheEntry::expires_at(42, entry_ttl, clock.system_time()); @@ -272,7 +251,7 @@ mod tests { let inner = MockCache::::new(); let inner_check = inner.clone(); let telemetry = CacheTelemetry::new(); - let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default(), None); + let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default()); let entry = CacheEntry::new(42); wrapper.insert("key".to_string(), entry).await.unwrap(); @@ -290,7 +269,7 @@ mod tests { let telemetry = CacheTelemetry::new(); let tier_ttl = Duration::from_secs(60); let wrapper: CacheWrapper = - CacheWrapper::new("test", inner, clock, Some(tier_ttl), telemetry, InsertPolicy::default(), None); + CacheWrapper::new("test", inner, clock, Some(tier_ttl), telemetry, InsertPolicy::default()); let entry = CacheEntry::new(42); wrapper.insert("key".to_string(), entry).await.unwrap(); @@ -311,7 +290,6 @@ mod tests { Some(Duration::from_secs(60)), telemetry, InsertPolicy::default(), - None, ); // Entry with cached_at in the future simulates clock going backward @@ -326,7 +304,7 @@ mod tests { let telemetry = CacheTelemetry::new(); let ttl = Duration::from_secs(60); let wrapper: CacheWrapper = - CacheWrapper::new("test", inner, clock.clone(), Some(ttl), telemetry, InsertPolicy::default(), None); + CacheWrapper::new("test", inner, clock.clone(), Some(ttl), telemetry, InsertPolicy::default()); // Entry cached exactly TTL ago → elapsed == ttl → should NOT be expired (uses >) let entry = CacheEntry::expires_at(42, ttl, clock.system_time() - ttl); @@ -338,8 +316,7 @@ mod tests { let clock = Clock::new_frozen(); let inner = MockCache::::new(); let telemetry = CacheTelemetry::new(); - let wrapper: CacheWrapper = - CacheWrapper::new("mock_test", inner, clock, None, telemetry, InsertPolicy::default(), None); + let wrapper: CacheWrapper = CacheWrapper::new("mock_test", inner, clock, None, telemetry, InsertPolicy::default()); assert_eq!(wrapper.name(), "mock_test"); } @@ -348,7 +325,7 @@ mod tests { let clock = Clock::new_frozen(); let inner = MockCache::::new(); let telemetry = CacheTelemetry::new(); - let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default(), None); + let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default()); let result = wrapper.handle_get_result(None, Duration::from_secs(0)); assert!(result.is_none()); } @@ -365,7 +342,6 @@ mod tests { Some(Duration::from_secs(60)), telemetry, InsertPolicy::default(), - None, ); // Entry without cached_at → considered expired let entry = CacheEntry::new(42); @@ -378,7 +354,7 @@ mod tests { let clock = Clock::new_frozen(); let inner = MockCache::::new(); let telemetry = CacheTelemetry::new(); - let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default(), None); + let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default()); let entry = CacheEntry::new(42); let result = wrapper.handle_get_result(Some(entry), Duration::from_secs(0)); assert!(result.is_some()); @@ -390,7 +366,7 @@ mod tests { let clock = Clock::new_frozen(); let inner = MockCache::::new(); let telemetry = CacheTelemetry::new(); - let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default(), None); + let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default()); // get miss assert!(wrapper.get(&"key".to_string()).await.unwrap().is_none()); @@ -416,7 +392,7 @@ mod tests { let clock = Clock::new_frozen(); let inner = MockCache::::new(); let telemetry = CacheTelemetry::new(); - let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default(), None); + let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default()); assert_eq!(wrapper.len().await.expect("len should return Ok"), 0); wrapper.insert("key".to_string(), CacheEntry::new(1)).await.unwrap(); assert_eq!(wrapper.len().await.expect("len should return Ok"), 1); @@ -429,7 +405,7 @@ mod tests { let inner = MockCache::::new(); inner.fail_when(|op| matches!(op, cachet_tier::CacheOp::Get(_))); let telemetry = CacheTelemetry::new(); - let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default(), None); + let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default()); let result = wrapper.get(&"key".to_string()).await; result.unwrap_err(); } @@ -441,7 +417,7 @@ mod tests { let inner = MockCache::::new(); inner.fail_when(|op| matches!(op, cachet_tier::CacheOp::Insert { .. })); let telemetry = CacheTelemetry::new(); - let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default(), None); + let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default()); let result = wrapper.insert("key".to_string(), CacheEntry::new(1)).await; result.unwrap_err(); } @@ -453,7 +429,7 @@ mod tests { let inner = MockCache::::new(); inner.fail_when(|op| matches!(op, cachet_tier::CacheOp::Invalidate(_))); let telemetry = CacheTelemetry::new(); - let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default(), None); + let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default()); let result = wrapper.invalidate(&"key".to_string()).await; result.unwrap_err(); } @@ -465,47 +441,26 @@ mod tests { let inner = MockCache::::new(); inner.fail_when(|op| matches!(op, cachet_tier::CacheOp::Clear)); let telemetry = CacheTelemetry::new(); - let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default(), None); + let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default()); let result = wrapper.clear().await; result.unwrap_err(); } #[cfg_attr(miri, ignore)] #[tokio::test] - async fn insert_at_capacity_emits_eviction_event() { - use testing_aids::LogCapture; - - let capture = LogCapture::new(); - let _guard = tracing::subscriber::set_default(capture.subscriber()); - - let clock = Clock::new_frozen(); - let inner = MockCache::::new(); - let telemetry = CacheTelemetry::with_logging(); - let wrapper: CacheWrapper = - CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default(), Some(2)); - - // Below capacity: no eviction event. - wrapper.insert("a".to_string(), CacheEntry::new(1)).await.unwrap(); - wrapper.insert("b".to_string(), CacheEntry::new(2)).await.unwrap(); - assert!(!capture.output().contains(crate::telemetry::attributes::EVENT_EVICTION)); - - // At capacity: next insert should emit eviction. - wrapper.insert("c".to_string(), CacheEntry::new(3)).await.unwrap(); - capture.assert_contains(crate::telemetry::attributes::EVENT_EVICTION); - } - - #[cfg_attr(miri, ignore)] - #[tokio::test] - async fn insert_without_max_capacity_never_emits_eviction() { + async fn wrapper_does_not_emit_eviction_event_directly() { use testing_aids::LogCapture; let capture = LogCapture::new(); let _guard = tracing::subscriber::set_default(capture.subscriber()); + // Eviction telemetry is now produced by the storage tier's eviction listener + // (see `crate::eviction::EvictionHook`), not by the wrapper itself. The wrapper + // wrapping a MockCache must therefore never synthesize eviction events. let clock = Clock::new_frozen(); let inner = MockCache::::new(); let telemetry = CacheTelemetry::with_logging(); - let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default(), None); + let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default()); for i in 0..5 { wrapper.insert(format!("k{i}"), CacheEntry::new(i)).await.unwrap(); diff --git a/crates/cachet/tests/eviction.rs b/crates/cachet/tests/eviction.rs new file mode 100644 index 000000000..c5c6d378d --- /dev/null +++ b/crates/cachet/tests/eviction.rs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for eviction telemetry emitted by the in-memory tier's +//! eviction listener. + +#![cfg(feature = "memory")] + +use std::time::Duration; + +use cachet::{Cache, CacheEntry}; +use testing_aids::LogCapture; +use tick::Clock; +use tracing_subscriber::Registry; +use tracing_subscriber::layer::SubscriberExt; + +/// Inserting past the configured `max_capacity` of the underlying moka cache +/// must eventually emit a `cache.eviction` event for the size-based removals. +#[cfg_attr(miri, ignore)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn memory_size_eviction_emits_telemetry() { + let capture = LogCapture::new(); + // moka runs the eviction listener on its own background task / worker thread, + // so a thread-local subscriber (`set_default`) won't see those events. This + // test owns its own test binary, so we can safely install a process-global + // subscriber. + let subscriber = Registry::default().with(tracing_subscriber::fmt::layer().with_writer(capture.clone()).with_ansi(false)); + tracing::subscriber::set_global_default(subscriber).expect("no other global subscriber should be installed in this test binary"); + + let clock = Clock::new_tokio(); + let cache: Cache = Cache::builder::(clock) + .name("eviction-test") + .enable_logs() + .with_eviction_telemetry() + .memory_with(|b| b.max_capacity(2)) + .build(); + + // Drive enough churn to force size-based evictions. Moka's housekeeping + // runs periodically (and as a side effect of cache operations), so we keep + // exercising the cache while waiting for an eviction event to surface. + let deadline = std::time::Instant::now() + Duration::from_secs(10); + let mut i: i32 = 0; + while std::time::Instant::now() < deadline { + for _ in 0..256 { + cache.insert(format!("k{i}"), CacheEntry::new(i)).await.unwrap(); + i += 1; + } + if capture.output().contains(cachet::telemetry::attributes::EVENT_EVICTION) { + return; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + panic!( + "expected `{}` event after exceeding max_capacity; captured output:\n{}", + cachet::telemetry::attributes::EVENT_EVICTION, + capture.output() + ); +} diff --git a/crates/cachet_memory/README.md b/crates/cachet_memory/README.md index 7383c256c..9dcab350c 100644 --- a/crates/cachet_memory/README.md +++ b/crates/cachet_memory/README.md @@ -73,7 +73,7 @@ TTL/TTI unset or set them to a sufficiently high ceiling. This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGkYW0CYXSEGy4k8ldDFPOhG2VNeXtD5nnKG6EPY6OfW5wBG8g18NOFNdxpYXKEGx97UkpE8tyEG0w3jevrQF8SG5D28UVlbZVEG3A-UY200y_0YWSCgm1jYWNoZXRfbWVtb3J5ZTAuMi4wgmtjYWNoZXRfdGllcmUwLjEuMA + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbH3tSSkTy3IQbTDeN6-tAXxIbkPbxRWVtlUQbcD5RjbTTL_RhZIKCbWNhY2hldF9tZW1vcnllMC4yLjCCa2NhY2hldF90aWVyZTAuMS4w [__link0]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=InMemoryCache [__link1]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=InMemoryCacheBuilder [__link2]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=policy::EvictionPolicy diff --git a/crates/cachet_memory/src/builder.rs b/crates/cachet_memory/src/builder.rs index 2edfb1e66..c892d6d49 100644 --- a/crates/cachet_memory/src/builder.rs +++ b/crates/cachet_memory/src/builder.rs @@ -7,15 +7,21 @@ //! the underlying cache configuration, providing a stable API surface //! without exposing implementation details. +use std::fmt; use std::hash::{BuildHasher, Hash}; use std::marker::PhantomData; +use std::sync::Arc; use std::time::Duration; use foldhash::fast::RandomState; +use crate::notification::RemovalCause; use crate::policy::EvictionPolicy; use crate::tier::InMemoryCache; +/// Type-erased eviction listener. +pub(crate) type EvictionListener = Arc; + /// Builder for configuring an `InMemoryCache`. /// /// This builder provides a stable API for common cache configuration @@ -36,7 +42,6 @@ use crate::tier::InMemoryCache; /// .name("my-cache") /// .build(); /// ``` -#[derive(Debug)] pub struct InMemoryCacheBuilder { pub(crate) max_capacity: Option, pub(crate) initial_capacity: Option, @@ -44,10 +49,26 @@ pub struct InMemoryCacheBuilder { pub(crate) time_to_idle: Option, pub(crate) name: Option<&'static str>, pub(crate) eviction_policy: EvictionPolicy, + pub(crate) eviction_listener: Option, pub(crate) hasher: H, _phantom: PhantomData<(K, V)>, } +impl fmt::Debug for InMemoryCacheBuilder { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("InMemoryCacheBuilder") + .field("max_capacity", &self.max_capacity) + .field("initial_capacity", &self.initial_capacity) + .field("time_to_live", &self.time_to_live) + .field("time_to_idle", &self.time_to_idle) + .field("name", &self.name) + .field("eviction_policy", &self.eviction_policy) + .field("eviction_listener", &self.eviction_listener.as_ref().map(|_| "")) + .field("hasher", &self.hasher) + .finish() + } +} + impl Default for InMemoryCacheBuilder { fn default() -> Self { Self::new() @@ -68,6 +89,7 @@ impl InMemoryCacheBuilder { time_to_idle: None, name: None, eviction_policy: EvictionPolicy::default(), + eviction_listener: None, hasher: RandomState::default(), _phantom: PhantomData, } @@ -218,6 +240,50 @@ impl InMemoryCacheBuilder { self } + /// Registers a listener that is called when an entry is removed from the cache. + /// + /// The listener receives a [`RemovalCause`] indicating why the entry was removed: + /// `Size` for capacity-driven evictions, `Expired` for TTL/TTI expirations, + /// `Explicit` for [`invalidate`](cachet_tier::CacheTier::invalidate) or + /// [`clear`](cachet_tier::CacheTier::clear) calls, and `Replaced` for inserts + /// that overwrote an existing key. + /// + /// The listener runs on the cache's background maintenance task. Keep the + /// closure cheap; expensive work should be offloaded to a separate task. + /// + /// Replacing an already-registered listener is supported; only the most + /// recently configured listener is invoked. + /// + /// # Examples + /// + /// ```no_run + /// use std::sync::atomic::{AtomicUsize, Ordering}; + /// use std::sync::Arc; + /// + /// use cachet_memory::{InMemoryCache, RemovalCause}; + /// + /// let evictions = Arc::new(AtomicUsize::new(0)); + /// let counter = Arc::clone(&evictions); + /// + /// let cache = InMemoryCache::::builder() + /// .max_capacity(100) + /// .on_eviction(move |cause| { + /// if matches!(cause, RemovalCause::Size | RemovalCause::Expired) { + /// counter.fetch_add(1, Ordering::Relaxed); + /// } + /// }) + /// .build() + /// .expect("Failed to build cache"); + /// ``` + #[must_use] + pub fn on_eviction(mut self, listener: F) -> Self + where + F: Fn(RemovalCause) + Send + Sync + 'static, + { + self.eviction_listener = Some(Arc::new(listener)); + self + } + /// Sets a custom hash builder for the cache. /// /// By default, the cache uses [`foldhash::fast::RandomState`] for high-performance @@ -244,6 +310,7 @@ impl InMemoryCacheBuilder { time_to_idle: self.time_to_idle, name: self.name, eviction_policy: self.eviction_policy, + eviction_listener: self.eviction_listener, hasher, _phantom: PhantomData, } diff --git a/crates/cachet_memory/src/lib.rs b/crates/cachet_memory/src/lib.rs index 112a28c84..b00c3a769 100644 --- a/crates/cachet_memory/src/lib.rs +++ b/crates/cachet_memory/src/lib.rs @@ -61,10 +61,13 @@ //! TTL/TTI unset or set them to a sufficiently high ceiling. mod builder; +pub mod notification; pub mod policy; mod tier; #[doc(inline)] pub use builder::InMemoryCacheBuilder; #[doc(inline)] +pub use notification::RemovalCause; +#[doc(inline)] pub use tier::InMemoryCache; diff --git a/crates/cachet_memory/src/notification.rs b/crates/cachet_memory/src/notification.rs new file mode 100644 index 000000000..434f0f24e --- /dev/null +++ b/crates/cachet_memory/src/notification.rs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Notifications emitted by the in-memory cache. +//! +//! Currently, this module exposes [`RemovalCause`] which classifies why an +//! entry was removed from the cache. It is delivered to listeners registered +//! via [`InMemoryCacheBuilder::on_eviction`](crate::InMemoryCacheBuilder::on_eviction). + +/// The reason an entry was removed from the cache. +/// +/// Mirrors moka's removal-cause classification, kept as a local type so that +/// the underlying cache implementation is not exposed in the public API. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum RemovalCause { + /// The entry's TTL or TTI passed and it was removed by the cache. + /// + /// This is distinct from a get-time expiration check: this variant fires + /// when the cache's background maintenance proactively reaps the entry. + Expired, + + /// The entry was removed because the cache was at capacity and the + /// eviction policy selected it for removal. + Size, + + /// The entry was removed by an explicit + /// [`invalidate`](cachet_tier::CacheTier::invalidate) or + /// [`clear`](cachet_tier::CacheTier::clear) call. + Explicit, + + /// The entry's value was replaced by a subsequent + /// [`insert`](cachet_tier::CacheTier::insert) with the same key. + Replaced, +} + +pub(crate) fn from_moka(cause: moka::notification::RemovalCause) -> RemovalCause { + match cause { + moka::notification::RemovalCause::Expired => RemovalCause::Expired, + moka::notification::RemovalCause::Size => RemovalCause::Size, + moka::notification::RemovalCause::Explicit => RemovalCause::Explicit, + moka::notification::RemovalCause::Replaced => RemovalCause::Replaced, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_moka_maps_all_variants() { + assert_eq!(from_moka(moka::notification::RemovalCause::Expired), RemovalCause::Expired); + assert_eq!(from_moka(moka::notification::RemovalCause::Size), RemovalCause::Size); + assert_eq!(from_moka(moka::notification::RemovalCause::Explicit), RemovalCause::Explicit); + assert_eq!(from_moka(moka::notification::RemovalCause::Replaced), RemovalCause::Replaced); + } +} diff --git a/crates/cachet_memory/src/tier.rs b/crates/cachet_memory/src/tier.rs index 9ab8042c6..1af08e731 100644 --- a/crates/cachet_memory/src/tier.rs +++ b/crates/cachet_memory/src/tier.rs @@ -163,6 +163,12 @@ where moka_builder = moka_builder.name(name); } + if let Some(listener) = builder.eviction_listener { + moka_builder = moka_builder.eviction_listener(move |_key, _value, cause| { + listener(crate::notification::from_moka(cause)); + }); + } + Self { inner: Arc::from_unaware( moka_builder diff --git a/crates/cachet_service/README.md b/crates/cachet_service/README.md index 36412f5d9..feae87876 100644 --- a/crates/cachet_service/README.md +++ b/crates/cachet_service/README.md @@ -45,7 +45,7 @@ let tier = ServiceAdapter::new(my_service); This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGkYW0CYXSEGy4k8ldDFPOhG2VNeXtD5nnKG6EPY6OfW5wBG8g18NOFNdxpYXKEG3K5S_LB5wBuG9aH2I-oE91BG6p757n6ShIyG2QJsgO5MU4kYWSCgm5jYWNoZXRfc2VydmljZWUwLjEuMIJrY2FjaGV0X3RpZXJlMC4xLjA + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbcrlL8sHnAG4b1ofYj6gT3UEbqnvnufpKEjIbZAmyA7kxTiRhZIKCbmNhY2hldF9zZXJ2aWNlZTAuMS4wgmtjYWNoZXRfdGllcmUwLjEuMA [__link0]: https://docs.rs/cachet_service/0.1.0/cachet_service/?search=ServiceAdapter [__link1]: https://docs.rs/cachet_tier/0.1.0/cachet_tier/?search=CacheTier [__link2]: https://docs.rs/cachet_service/0.1.0/cachet_service/?search=ServiceAdapter diff --git a/crates/cachet_tier/README.md b/crates/cachet_tier/README.md index 92a8caa71..b88b74933 100644 --- a/crates/cachet_tier/README.md +++ b/crates/cachet_tier/README.md @@ -74,7 +74,7 @@ for multi-tier caches with heterogeneous storage backends. This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGkYW0CYXSEGy4k8ldDFPOhG2VNeXtD5nnKG6EPY6OfW5wBG8g18NOFNdxpYXKEG0hRqDfWg1oDG5BT1ZI-3omTG5WE4GB0Mg57G-G4ebzGeSk5YWSBgmtjYWNoZXRfdGllcmUwLjEuMA + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbSFGoN9aDWgMbkFPVkj7eiZMblYTgYHQyDnsb4bh5vMZ5KTlhZIGCa2NhY2hldF90aWVyZTAuMS4w [__link0]: https://docs.rs/cachet_tier/0.1.0/cachet_tier/?search=CacheTier [__link1]: https://docs.rs/cachet_tier/0.1.0/cachet_tier/?search=CacheEntry [__link2]: https://docs.rs/cachet_tier/0.1.0/cachet_tier/?search=Error From 1301827fd95c6bd4861d444bd4d3f0ab31235239 Mon Sep 17 00:00:00 2001 From: "Ethiopia Mengesha (from Dev Box)" Date: Wed, 27 May 2026 08:35:10 -0700 Subject: [PATCH 10/27] add impl unwindsafe and refunwindsafe --- crates/cachet_memory/src/builder.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/cachet_memory/src/builder.rs b/crates/cachet_memory/src/builder.rs index c892d6d49..2da02c07f 100644 --- a/crates/cachet_memory/src/builder.rs +++ b/crates/cachet_memory/src/builder.rs @@ -10,6 +10,7 @@ use std::fmt; use std::hash::{BuildHasher, Hash}; use std::marker::PhantomData; +use std::panic::{RefUnwindSafe, UnwindSafe}; use std::sync::Arc; use std::time::Duration; @@ -69,6 +70,15 @@ impl fmt::Debug for InMemoryCacheBuilder { } } +// The `eviction_listener` field stores a `dyn Fn`, which is not auto-`UnwindSafe` +// / `RefUnwindSafe`. We assert these traits explicitly so adding the listener does +// not silently break downstream code that relied on the auto-trait impls +// (flagged by `cargo semver-checks` as `auto_trait_impl_removed`). The closure +// is invoked by moka as a fire-and-forget side effect; a panic inside it cannot +// leave any observable state in the builder, so the assertion is sound. +impl UnwindSafe for InMemoryCacheBuilder {} +impl RefUnwindSafe for InMemoryCacheBuilder {} + impl Default for InMemoryCacheBuilder { fn default() -> Self { Self::new() From 61de5b6b79634a3a11948e9e4340b9c90737a986 Mon Sep 17 00:00:00 2001 From: "Ethiopia Mengesha (from Dev Box)" Date: Wed, 27 May 2026 08:58:08 -0700 Subject: [PATCH 11/27] sort cargo.toml --- crates/cachet/Cargo.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/cachet/Cargo.toml b/crates/cachet/Cargo.toml index 8d7da00b2..bad74c02c 100644 --- a/crates/cachet/Cargo.toml +++ b/crates/cachet/Cargo.toml @@ -89,10 +89,6 @@ name = "operations" harness = false required-features = ["logs", "test-util"] -[[test]] -name = "eviction" -required-features = ["memory", "logs"] - [[bench]] name = "dynamic" harness = false @@ -103,6 +99,10 @@ name = "refresh" harness = false required-features = ["test-util"] +[[test]] +name = "eviction" +required-features = ["memory", "logs"] + [[example]] name = "simple" required-features = ["memory"] From b3c73f1721afe4546e80c3d979e5ccc289db8d80 Mon Sep 17 00:00:00 2001 From: "Ethiopia Mengesha (from Dev Box)" Date: Wed, 27 May 2026 12:23:39 -0700 Subject: [PATCH 12/27] docs, version fixes --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/cachet/CHANGELOG.md | 3 ++- crates/cachet/Cargo.toml | 2 +- crates/cachet/src/eviction.rs | 28 +++++++++++++++++----------- crates/cachet/src/telemetry/cache.rs | 4 +++- 6 files changed, 25 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0c424e188..c91e7baf5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -433,7 +433,7 @@ dependencies = [ [[package]] name = "cachet" -version = "0.6.0" +version = "0.5.1" dependencies = [ "alloc_tracker", "anyspawn", diff --git a/Cargo.toml b/Cargo.toml index 900cad6c7..5899c8565 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ homepage = "https://github.com/microsoft/oxidizer" anyspawn = { path = "crates/anyspawn", default-features = false, version = "0.5.0" } bytesbuf = { path = "crates/bytesbuf", default-features = false, version = "0.5.0" } bytesbuf_io = { path = "crates/bytesbuf_io", default-features = false, version = "0.5.0" } -cachet = { path = "crates/cachet", default-features = false, version = "0.6.0" } +cachet = { path = "crates/cachet", default-features = false, version = "0.5.1" } cachet_memory = { path = "crates/cachet_memory", default-features = false, version = "0.2.0" } cachet_service = { path = "crates/cachet_service", default-features = false, version = "0.1.0" } cachet_tier = { path = "crates/cachet_tier", default-features = false, version = "0.1.0" } diff --git a/crates/cachet/CHANGELOG.md b/crates/cachet/CHANGELOG.md index 0ed595ed2..1514f13fd 100644 --- a/crates/cachet/CHANGELOG.md +++ b/crates/cachet/CHANGELOG.md @@ -1,10 +1,11 @@ # Changelog -## [0.6.0] - 2026-05-21 +## [0.5.1] - 2026-05-21 - ✨ Features - Add `get_or_insert_with` and `try_get_or_insert_with` methods that accept closures returning `CacheEntry`, enabling per-entry TTL control on cache-miss computations. + - Add eviction telemetry via `cache.eviction`, along with builder support to enable it through APIs such as `with_eviction_telemetry` and `memory_with`. ## [0.5.0] - 2026-05-19 diff --git a/crates/cachet/Cargo.toml b/crates/cachet/Cargo.toml index bad74c02c..9b35c18f3 100644 --- a/crates/cachet/Cargo.toml +++ b/crates/cachet/Cargo.toml @@ -4,7 +4,7 @@ [package] name = "cachet" description = "A composable, customizable multi-tier caching library with rich feature support." -version = "0.6.0" +version = "0.5.1" readme = "README.md" keywords = ["oxidizer", "caching", "concurrency"] categories = ["caching", "concurrency"] diff --git a/crates/cachet/src/eviction.rs b/crates/cachet/src/eviction.rs index ca2e5052a..3015e14ac 100644 --- a/crates/cachet/src/eviction.rs +++ b/crates/cachet/src/eviction.rs @@ -43,15 +43,18 @@ impl EvictionHook { /// Invoked by the in-memory tier on each removal. /// - /// Only `Size` and `Expired` causes are reported as evictions; `Explicit` - /// and `Replaced` are user-initiated and already accounted for by the - /// `cache.invalidated` / `cache.inserted` events. + /// `Size` removals are reported as `cache.eviction` (capacity pressure) and + /// `Expired` removals as `cache.expired` (TTL/TTI expiry) so consumers can + /// distinguish the two. `Explicit` and `Replaced` are user-initiated and + /// already accounted for by `cache.invalidated` / `cache.inserted`. pub(crate) fn handle(&self, cause: RemovalCause) { - if !matches!(cause, RemovalCause::Size | RemovalCause::Expired) { + let Some(state) = self.state.get() else { return; - } - if let Some(state) = self.state.get() { - state.telemetry.cache_eviction(state.name, Duration::ZERO); + }; + match cause { + RemovalCause::Size => state.telemetry.cache_eviction(state.name, Duration::ZERO), + RemovalCause::Expired => state.telemetry.cache_expired(state.name, Duration::ZERO), + RemovalCause::Explicit | RemovalCause::Replaced => {} } } } @@ -79,7 +82,7 @@ mod tests { #[cfg_attr(miri, ignore)] #[test] - fn handle_after_init_emits_size_and_expired_only() { + fn handle_after_init_routes_by_cause() { let capture = LogCapture::new(); let _guard = tracing::subscriber::set_default(capture.subscriber()); @@ -89,12 +92,15 @@ mod tests { hook.handle(RemovalCause::Explicit); hook.handle(RemovalCause::Replaced); assert!( - !capture.output().contains(attributes::EVENT_EVICTION), - "Explicit/Replaced must not emit eviction events" + !capture.output().contains(attributes::EVENT_EVICTION) + && !capture.output().contains(attributes::EVENT_EXPIRED), + "Explicit/Replaced must not emit eviction or expired events" ); hook.handle(RemovalCause::Size); - hook.handle(RemovalCause::Expired); capture.assert_contains(attributes::EVENT_EVICTION); + + hook.handle(RemovalCause::Expired); + capture.assert_contains(attributes::EVENT_EXPIRED); } } diff --git a/crates/cachet/src/telemetry/cache.rs b/crates/cachet/src/telemetry/cache.rs index 81a5fb925..c93fff32a 100644 --- a/crates/cachet/src/telemetry/cache.rs +++ b/crates/cachet/src/telemetry/cache.rs @@ -165,7 +165,9 @@ impl CacheTelemetry { self.inner.info(cache_name, attributes::EVENT_INSERTED, duration); } - /// Records that an insert caused an eviction because the cache was at capacity. + /// Records that an entry was evicted from the cache because it reached + /// the configured capacity limit. + #[cfg(any(feature = "memory", test))] #[inline] pub(crate) fn cache_eviction(&self, cache_name: CacheName, duration: Duration) { self.inner.info(cache_name, attributes::EVENT_EVICTION, duration); From edcd67f262dac15e1ce27c3fd6a09db4ec18145e Mon Sep 17 00:00:00 2001 From: "Ethiopia Mengesha (from Dev Box)" Date: Wed, 27 May 2026 12:34:18 -0700 Subject: [PATCH 13/27] spelling --- crates/cachet/src/eviction.rs | 5 ++--- crates/cachet_memory/src/builder.rs | 2 +- crates/cachet_memory/src/notification.rs | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/cachet/src/eviction.rs b/crates/cachet/src/eviction.rs index 3015e14ac..9eca59fd5 100644 --- a/crates/cachet/src/eviction.rs +++ b/crates/cachet/src/eviction.rs @@ -16,7 +16,7 @@ use cachet_memory::RemovalCause; use crate::cache::CacheName; use crate::telemetry::CacheTelemetry; -/// Bridges moka's eviction listener to the cachet telemetry layer. +/// Bridges moka crate's eviction listener to the cachet telemetry layer. #[derive(Debug)] pub(crate) struct EvictionHook { state: OnceLock, @@ -92,8 +92,7 @@ mod tests { hook.handle(RemovalCause::Explicit); hook.handle(RemovalCause::Replaced); assert!( - !capture.output().contains(attributes::EVENT_EVICTION) - && !capture.output().contains(attributes::EVENT_EXPIRED), + !capture.output().contains(attributes::EVENT_EVICTION) && !capture.output().contains(attributes::EVENT_EXPIRED), "Explicit/Replaced must not emit eviction or expired events" ); diff --git a/crates/cachet_memory/src/builder.rs b/crates/cachet_memory/src/builder.rs index 2da02c07f..fcca3da76 100644 --- a/crates/cachet_memory/src/builder.rs +++ b/crates/cachet_memory/src/builder.rs @@ -253,7 +253,7 @@ impl InMemoryCacheBuilder { /// Registers a listener that is called when an entry is removed from the cache. /// /// The listener receives a [`RemovalCause`] indicating why the entry was removed: - /// `Size` for capacity-driven evictions, `Expired` for TTL/TTI expirations, + /// `Size` for capacity-driven evictions, `Expired` for TTL/TTI expiration, /// `Explicit` for [`invalidate`](cachet_tier::CacheTier::invalidate) or /// [`clear`](cachet_tier::CacheTier::clear) calls, and `Replaced` for inserts /// that overwrote an existing key. diff --git a/crates/cachet_memory/src/notification.rs b/crates/cachet_memory/src/notification.rs index 434f0f24e..e7b0c4aab 100644 --- a/crates/cachet_memory/src/notification.rs +++ b/crates/cachet_memory/src/notification.rs @@ -9,7 +9,7 @@ /// The reason an entry was removed from the cache. /// -/// Mirrors moka's removal-cause classification, kept as a local type so that +/// Mirrors moka crate's removal-cause classification, kept as a local type so that /// the underlying cache implementation is not exposed in the public API. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum RemovalCause { From a486e05772618f69d123517e1ec4cb7cee2cd817 Mon Sep 17 00:00:00 2001 From: "Ethiopia Mengesha (from Dev Box)" Date: Wed, 27 May 2026 12:51:29 -0700 Subject: [PATCH 14/27] pare docs --- crates/cachet/src/builder/cache.rs | 13 ++++--------- crates/cachet/src/eviction.rs | 13 ++++--------- crates/cachet/src/wrapper.rs | 5 ++--- crates/cachet_memory/src/builder.rs | 13 ++++--------- crates/cachet_memory/src/notification.rs | 17 ++++------------- 5 files changed, 18 insertions(+), 43 deletions(-) diff --git a/crates/cachet/src/builder/cache.rs b/crates/cachet/src/builder/cache.rs index e87a2384f..3fe427728 100644 --- a/crates/cachet/src/builder/cache.rs +++ b/crates/cachet/src/builder/cache.rs @@ -231,17 +231,12 @@ impl CacheBuilder { self } - /// Enables `cache.eviction` telemetry for the in-memory tier. + /// Enables eviction telemetry for the in-memory tier. /// /// When enabled, the next call to [`memory`](Self::memory) / - /// [`memory_with`](Self::memory_with) installs a moka eviction listener - /// that reports `Size`- and `Expired`-cause evictions through the cache's - /// telemetry sink. `Explicit` and `Replaced` causes are *not* reported as - /// evictions, since they are already covered by the `cache.invalidated` - /// and `cache.inserted` events. - /// - /// Must be called *before* `memory`/`memory_with`; calling it afterwards - /// has no effect because the storage tier is constructed at that point. + /// [`memory_with`](Self::memory_with) installs a listener that emits + /// `cache.eviction` on capacity evictions and `cache.expired` on + /// background TTL/TTI expiry. Must be called *before* `memory`/`memory_with`. /// /// # Examples /// diff --git a/crates/cachet/src/eviction.rs b/crates/cachet/src/eviction.rs index 9eca59fd5..f586546d1 100644 --- a/crates/cachet/src/eviction.rs +++ b/crates/cachet/src/eviction.rs @@ -33,20 +33,15 @@ impl EvictionHook { Self { state: OnceLock::new() } } - /// Binds the hook to a telemetry sink and cache name. - /// - /// Called once during `build_tier`. Subsequent calls are silently ignored - /// because the hook is keyed to the first build of a builder. + /// Binds the hook to a telemetry sink and cache name. Subsequent calls are no-ops. pub(crate) fn init(&self, telemetry: CacheTelemetry, name: CacheName) { let _ = self.state.set(HookState { telemetry, name }); } - /// Invoked by the in-memory tier on each removal. + /// Routes a removal cause to the appropriate telemetry event. /// - /// `Size` removals are reported as `cache.eviction` (capacity pressure) and - /// `Expired` removals as `cache.expired` (TTL/TTI expiry) so consumers can - /// distinguish the two. `Explicit` and `Replaced` are user-initiated and - /// already accounted for by `cache.invalidated` / `cache.inserted`. + /// `Explicit` and `Replaced` are ignored because they are already covered + /// by the wrapper's `cache.invalidated` / `cache.inserted` events. pub(crate) fn handle(&self, cause: RemovalCause) { let Some(state) = self.state.get() else { return; diff --git a/crates/cachet/src/wrapper.rs b/crates/cachet/src/wrapper.rs index 141d17a38..13cb53937 100644 --- a/crates/cachet/src/wrapper.rs +++ b/crates/cachet/src/wrapper.rs @@ -454,9 +454,8 @@ mod tests { let capture = LogCapture::new(); let _guard = tracing::subscriber::set_default(capture.subscriber()); - // Eviction telemetry is now produced by the storage tier's eviction listener - // (see `crate::eviction::EvictionHook`), not by the wrapper itself. The wrapper - // wrapping a MockCache must therefore never synthesize eviction events. + // The wrapper must not synthesize eviction events; those come from the + // storage tier's eviction listener (see `crate::eviction::EvictionHook`). let clock = Clock::new_frozen(); let inner = MockCache::::new(); let telemetry = CacheTelemetry::with_logging(); diff --git a/crates/cachet_memory/src/builder.rs b/crates/cachet_memory/src/builder.rs index fcca3da76..f140c380e 100644 --- a/crates/cachet_memory/src/builder.rs +++ b/crates/cachet_memory/src/builder.rs @@ -70,12 +70,10 @@ impl fmt::Debug for InMemoryCacheBuilder { } } -// The `eviction_listener` field stores a `dyn Fn`, which is not auto-`UnwindSafe` -// / `RefUnwindSafe`. We assert these traits explicitly so adding the listener does -// not silently break downstream code that relied on the auto-trait impls -// (flagged by `cargo semver-checks` as `auto_trait_impl_removed`). The closure -// is invoked by moka as a fire-and-forget side effect; a panic inside it cannot -// leave any observable state in the builder, so the assertion is sound. +// `eviction_listener` holds a `dyn Fn`, which is not auto-`UnwindSafe`/`RefUnwindSafe`. +// Assert both explicitly so adding the listener doesn't break downstream code that +// relied on the auto impls. The closure is invoked by moka as a fire-and-forget +// callback; a panic inside it cannot leave observable state in the builder. impl UnwindSafe for InMemoryCacheBuilder {} impl RefUnwindSafe for InMemoryCacheBuilder {} @@ -261,9 +259,6 @@ impl InMemoryCacheBuilder { /// The listener runs on the cache's background maintenance task. Keep the /// closure cheap; expensive work should be offloaded to a separate task. /// - /// Replacing an already-registered listener is supported; only the most - /// recently configured listener is invoked. - /// /// # Examples /// /// ```no_run diff --git a/crates/cachet_memory/src/notification.rs b/crates/cachet_memory/src/notification.rs index e7b0c4aab..3762ec8fb 100644 --- a/crates/cachet_memory/src/notification.rs +++ b/crates/cachet_memory/src/notification.rs @@ -2,25 +2,16 @@ // Licensed under the MIT License. //! Notifications emitted by the in-memory cache. -//! -//! Currently, this module exposes [`RemovalCause`] which classifies why an -//! entry was removed from the cache. It is delivered to listeners registered -//! via [`InMemoryCacheBuilder::on_eviction`](crate::InMemoryCacheBuilder::on_eviction). /// The reason an entry was removed from the cache. -/// -/// Mirrors moka crate's removal-cause classification, kept as a local type so that -/// the underlying cache implementation is not exposed in the public API. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum RemovalCause { - /// The entry's TTL or TTI passed and it was removed by the cache. - /// - /// This is distinct from a get-time expiration check: this variant fires - /// when the cache's background maintenance proactively reaps the entry. + /// The entry's TTL or TTI passed and the cache's background maintenance + /// reaped it. This is distinct from a get-time expiration check. Expired, - /// The entry was removed because the cache was at capacity and the - /// eviction policy selected it for removal. + /// The cache was at capacity and the eviction policy selected this entry + /// for removal. Size, /// The entry was removed by an explicit From b2fe69854e0cf41ee5ecd16fbb886a137d3d787d Mon Sep 17 00:00:00 2001 From: "Ethiopia Mengesha (from Dev Box)" Date: Wed, 27 May 2026 13:32:28 -0700 Subject: [PATCH 15/27] move telemetry out of cache and into inmemorycache --- crates/cachet/CHANGELOG.md | 2 +- crates/cachet/src/builder/cache.rs | 42 ++++------------------------- crates/cachet/src/wrapper.rs | 21 --------------- crates/cachet/tests/eviction.rs | 3 +-- crates/cachet_memory/src/builder.rs | 23 ++++++++++++++++ 5 files changed, 30 insertions(+), 61 deletions(-) diff --git a/crates/cachet/CHANGELOG.md b/crates/cachet/CHANGELOG.md index 1514f13fd..ce0d644d1 100644 --- a/crates/cachet/CHANGELOG.md +++ b/crates/cachet/CHANGELOG.md @@ -5,7 +5,7 @@ - ✨ Features - Add `get_or_insert_with` and `try_get_or_insert_with` methods that accept closures returning `CacheEntry`, enabling per-entry TTL control on cache-miss computations. - - Add eviction telemetry via `cache.eviction`, along with builder support to enable it through APIs such as `with_eviction_telemetry` and `memory_with`. + - Add eviction telemetry via `cache.eviction` and `cache.expired`, opt-in through `InMemoryCacheBuilder::with_eviction_telemetry` together with the new `CacheBuilder::memory_with` helper. ## [0.5.0] - 2026-05-19 diff --git a/crates/cachet/src/builder/cache.rs b/crates/cachet/src/builder/cache.rs index 3fe427728..df092b376 100644 --- a/crates/cachet/src/builder/cache.rs +++ b/crates/cachet/src/builder/cache.rs @@ -50,8 +50,6 @@ pub struct CacheBuilder { pub(crate) stampede_protection: bool, #[cfg(feature = "memory")] pub(crate) eviction_hook: Option>, - #[cfg(feature = "memory")] - pub(crate) eviction_telemetry: bool, pub(crate) _phantom: PhantomData<(K, V)>, } @@ -67,8 +65,6 @@ impl CacheBuilder { stampede_protection: false, #[cfg(feature = "memory")] eviction_hook: None, - #[cfg(feature = "memory")] - eviction_telemetry: false, _phantom: PhantomData, } } @@ -104,8 +100,6 @@ impl CacheBuilder { stampede_protection: self.stampede_protection, #[cfg(feature = "memory")] eviction_hook: self.eviction_hook, - #[cfg(feature = "memory")] - eviction_telemetry: self.eviction_telemetry, _phantom: PhantomData, } } @@ -138,9 +132,9 @@ impl CacheBuilder { /// [`InMemoryCacheBuilder`] for additional configuration (capacity, TTL, /// eviction policy, custom hasher, etc.). /// - /// Eviction telemetry is *not* installed unless - /// [`with_eviction_telemetry`](Self::with_eviction_telemetry) was called - /// before this method. + /// Call [`InMemoryCacheBuilder::with_eviction_telemetry`] inside the + /// closure to emit `cache.eviction` on capacity evictions and + /// `cache.expired` on background TTL/TTI expiry. /// /// # Panics /// @@ -155,7 +149,7 @@ impl CacheBuilder { /// /// let clock = Clock::new_tokio(); /// let cache = Cache::builder::(clock) - /// .memory_with(|b| b.max_capacity(1_000)) + /// .memory_with(|b| b.max_capacity(1_000).with_eviction_telemetry()) /// .build(); /// ``` #[cfg(feature = "memory")] @@ -167,7 +161,7 @@ impl CacheBuilder { F: FnOnce(InMemoryCacheBuilder) -> InMemoryCacheBuilder, { let mut builder = configure(InMemoryCacheBuilder::::new()); - if self.eviction_telemetry { + if builder.eviction_telemetry_enabled() { let hook = Arc::new(EvictionHook::new()); let hook_for_listener = Arc::clone(&hook); builder = builder.on_eviction(move |cause| hook_for_listener.handle(cause)); @@ -231,32 +225,6 @@ impl CacheBuilder { self } - /// Enables eviction telemetry for the in-memory tier. - /// - /// When enabled, the next call to [`memory`](Self::memory) / - /// [`memory_with`](Self::memory_with) installs a listener that emits - /// `cache.eviction` on capacity evictions and `cache.expired` on - /// background TTL/TTI expiry. Must be called *before* `memory`/`memory_with`. - /// - /// # Examples - /// - /// ```no_run - /// use cachet::Cache; - /// use tick::Clock; - /// - /// let clock = Clock::new_tokio(); - /// let cache = Cache::builder::(clock) - /// .with_eviction_telemetry() - /// .memory_with(|b| b.max_capacity(1_000)) - /// .build(); - /// ``` - #[cfg(feature = "memory")] - #[must_use] - pub fn with_eviction_telemetry(mut self) -> Self { - self.eviction_telemetry = true; - self - } - /// Enables stampede protection for cache reads. /// /// When enabled, concurrent requests for the same key will be merged diff --git a/crates/cachet/src/wrapper.rs b/crates/cachet/src/wrapper.rs index 13cb53937..679411570 100644 --- a/crates/cachet/src/wrapper.rs +++ b/crates/cachet/src/wrapper.rs @@ -445,25 +445,4 @@ mod tests { let result = wrapper.clear().await; result.unwrap_err(); } - - #[cfg_attr(miri, ignore)] - #[tokio::test] - async fn wrapper_does_not_emit_eviction_event_directly() { - use testing_aids::LogCapture; - - let capture = LogCapture::new(); - let _guard = tracing::subscriber::set_default(capture.subscriber()); - - // The wrapper must not synthesize eviction events; those come from the - // storage tier's eviction listener (see `crate::eviction::EvictionHook`). - let clock = Clock::new_frozen(); - let inner = MockCache::::new(); - let telemetry = CacheTelemetry::with_logging(); - let wrapper: CacheWrapper = CacheWrapper::new("test", inner, clock, None, telemetry, InsertPolicy::default()); - - for i in 0..5 { - wrapper.insert(format!("k{i}"), CacheEntry::new(i)).await.unwrap(); - } - assert!(!capture.output().contains(crate::telemetry::attributes::EVENT_EVICTION)); - } } diff --git a/crates/cachet/tests/eviction.rs b/crates/cachet/tests/eviction.rs index c5c6d378d..7ba307844 100644 --- a/crates/cachet/tests/eviction.rs +++ b/crates/cachet/tests/eviction.rs @@ -31,8 +31,7 @@ async fn memory_size_eviction_emits_telemetry() { let cache: Cache = Cache::builder::(clock) .name("eviction-test") .enable_logs() - .with_eviction_telemetry() - .memory_with(|b| b.max_capacity(2)) + .memory_with(|b| b.max_capacity(2).with_eviction_telemetry()) .build(); // Drive enough churn to force size-based evictions. Moka's housekeeping diff --git a/crates/cachet_memory/src/builder.rs b/crates/cachet_memory/src/builder.rs index f140c380e..6e0966902 100644 --- a/crates/cachet_memory/src/builder.rs +++ b/crates/cachet_memory/src/builder.rs @@ -51,6 +51,7 @@ pub struct InMemoryCacheBuilder { pub(crate) name: Option<&'static str>, pub(crate) eviction_policy: EvictionPolicy, pub(crate) eviction_listener: Option, + pub(crate) eviction_telemetry: bool, pub(crate) hasher: H, _phantom: PhantomData<(K, V)>, } @@ -65,6 +66,7 @@ impl fmt::Debug for InMemoryCacheBuilder { .field("name", &self.name) .field("eviction_policy", &self.eviction_policy) .field("eviction_listener", &self.eviction_listener.as_ref().map(|_| "")) + .field("eviction_telemetry", &self.eviction_telemetry) .field("hasher", &self.hasher) .finish() } @@ -98,6 +100,7 @@ impl InMemoryCacheBuilder { name: None, eviction_policy: EvictionPolicy::default(), eviction_listener: None, + eviction_telemetry: false, hasher: RandomState::default(), _phantom: PhantomData, } @@ -289,6 +292,25 @@ impl InMemoryCacheBuilder { self } + /// Requests that the host crate install eviction telemetry for this cache. + /// + /// This is a marker for [`cachet::CacheBuilder::memory_with`] to recognize: + /// when set, the host installs a listener that emits `cache.eviction` on + /// capacity evictions and `cache.expired` on background TTL/TTI expiry. + /// When `InMemoryCache` is constructed directly via [`Self::build`] without + /// a host, this flag has no effect — use [`Self::on_eviction`] instead. + #[must_use] + pub fn with_eviction_telemetry(mut self) -> Self { + self.eviction_telemetry = true; + self + } + + /// Returns whether [`Self::with_eviction_telemetry`] was called on this builder. + #[must_use] + pub fn eviction_telemetry_enabled(&self) -> bool { + self.eviction_telemetry + } + /// Sets a custom hash builder for the cache. /// /// By default, the cache uses [`foldhash::fast::RandomState`] for high-performance @@ -316,6 +338,7 @@ impl InMemoryCacheBuilder { name: self.name, eviction_policy: self.eviction_policy, eviction_listener: self.eviction_listener, + eviction_telemetry: self.eviction_telemetry, hasher, _phantom: PhantomData, } From 9bb9341822bacbf2344cdf8f8dba9343efd09b46 Mon Sep 17 00:00:00 2001 From: "Ethiopia Mengesha (from Dev Box)" Date: Wed, 27 May 2026 13:37:46 -0700 Subject: [PATCH 16/27] doc to specify eviction telemetry details in memory api --- crates/cachet_memory/src/builder.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/cachet_memory/src/builder.rs b/crates/cachet_memory/src/builder.rs index 6e0966902..668ddd12e 100644 --- a/crates/cachet_memory/src/builder.rs +++ b/crates/cachet_memory/src/builder.rs @@ -296,7 +296,12 @@ impl InMemoryCacheBuilder { /// /// This is a marker for [`cachet::CacheBuilder::memory_with`] to recognize: /// when set, the host installs a listener that emits `cache.eviction` on - /// capacity evictions and `cache.expired` on background TTL/TTI expiry. + /// capacity evictions ([`RemovalCause::Size`]) and `cache.expired` on + /// background TTL/TTI expiry ([`RemovalCause::Expired`]). + /// [`RemovalCause::Explicit`] and [`RemovalCause::Replaced`] are + /// intentionally not surfaced, as they are already covered by the host's + /// `cache.invalidated` and `cache.inserted` events. + /// /// When `InMemoryCache` is constructed directly via [`Self::build`] without /// a host, this flag has no effect — use [`Self::on_eviction`] instead. #[must_use] From b8e56fcdbf4ec55ac3500e50f9135339e8b59f51 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 21:10:33 +0000 Subject: [PATCH 17/27] fix: replace broken intra-doc link with plain code formatting in cachet_memory builder --- crates/cachet_memory/src/builder.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cachet_memory/src/builder.rs b/crates/cachet_memory/src/builder.rs index 668ddd12e..081701255 100644 --- a/crates/cachet_memory/src/builder.rs +++ b/crates/cachet_memory/src/builder.rs @@ -294,7 +294,7 @@ impl InMemoryCacheBuilder { /// Requests that the host crate install eviction telemetry for this cache. /// - /// This is a marker for [`cachet::CacheBuilder::memory_with`] to recognize: + /// This is a marker for `cachet::CacheBuilder::memory_with` to recognize: /// when set, the host installs a listener that emits `cache.eviction` on /// capacity evictions ([`RemovalCause::Size`]) and `cache.expired` on /// background TTL/TTI expiry ([`RemovalCause::Expired`]). From f692df2ea8be0c1db7a75fcce670ccf37ff91d80 Mon Sep 17 00:00:00 2001 From: codingsnoop <166542027+codingsnoop@users.noreply.github.com> Date: Wed, 27 May 2026 14:10:51 -0700 Subject: [PATCH 18/27] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- crates/cachet/src/telemetry/attributes.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/cachet/src/telemetry/attributes.rs b/crates/cachet/src/telemetry/attributes.rs index 5c8359414..24946216b 100644 --- a/crates/cachet/src/telemetry/attributes.rs +++ b/crates/cachet/src/telemetry/attributes.rs @@ -85,8 +85,8 @@ pub const EVENT_REFRESH_HIT: &str = "cache.refresh_hit"; /// A background refresh did not find data in the fallback tier. pub const EVENT_REFRESH_MISS: &str = "cache.refresh_miss"; -/// An entry was inserted while the cache was at or above capacity and may have -/// caused an eviction. +/// An entry was evicted/removed due to cache size constraints. +/// Only emitted when eviction telemetry is enabled. pub const EVENT_EVICTION: &str = "cache.eviction"; #[cfg(test)] From e9f2d223818d5317ebb0b7fa0ae4ec6719d625c3 Mon Sep 17 00:00:00 2001 From: "Ethiopia Mengesha (from Dev Box)" Date: Wed, 27 May 2026 14:13:46 -0700 Subject: [PATCH 19/27] update readme --- crates/cachet/README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/cachet/README.md b/crates/cachet/README.md index 30cd83670..7c1a0d9cc 100644 --- a/crates/cachet/README.md +++ b/crates/cachet/README.md @@ -265,26 +265,26 @@ See the `telemetry_subscriber` example for a complete demonstration. This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbg_hDqE88LP4bMh0J5Y4y4Osb0zDJ1kwqOsoblCGrm49Rx2thZIiCaGJ5dGVzYnVmZTAuNS4wgmZjYWNoZXRlMC42LjCCbWNhY2hldF9tZW1vcnllMC4yLjCCbmNhY2hldF9zZXJ2aWNlZTAuMS4wgmtjYWNoZXRfdGllcmUwLjEuMIJkdGlja2UwLjMuMIJndHJhY2luZ2YwLjEuNDSCaXVuaWZsaWdodGUwLjIuMA - [__link0]: https://docs.rs/cachet/0.6.0/cachet/?search=TimeToRefresh + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbg_hDqE88LP4bMh0J5Y4y4Osb0zDJ1kwqOsoblCGrm49Rx2thZIiCaGJ5dGVzYnVmZTAuNS4wgmZjYWNoZXRlMC41LjGCbWNhY2hldF9tZW1vcnllMC4yLjCCbmNhY2hldF9zZXJ2aWNlZTAuMS4wgmtjYWNoZXRfdGllcmUwLjEuMIJkdGlja2UwLjMuMIJndHJhY2luZ2YwLjEuNDSCaXVuaWZsaWdodGUwLjIuMA + [__link0]: https://docs.rs/cachet/0.5.1/cachet/?search=TimeToRefresh [__link1]: https://crates.io/crates/uniflight/0.2.0 [__link10]: https://docs.rs/cachet_tier/0.1.0/cachet_tier/?search=CacheTier - [__link11]: https://docs.rs/cachet/0.6.0/cachet/?search=InsertPolicy - [__link12]: https://docs.rs/cachet/0.6.0/cachet/?search=TimeToRefresh + [__link11]: https://docs.rs/cachet/0.5.1/cachet/?search=InsertPolicy + [__link12]: https://docs.rs/cachet/0.5.1/cachet/?search=TimeToRefresh [__link13]: https://docs.rs/cachet_tier/0.1.0/cachet_tier/?search=Error [__link14]: https://crates.io/crates/cachet_tier/0.1.0 [__link15]: https://crates.io/crates/cachet_memory/0.2.0 [__link16]: https://docs.rs/moka [__link17]: https://crates.io/crates/cachet_service/0.1.0 - [__link18]: https://docs.rs/cachet/0.6.0/cachet/?search=telemetry::attributes + [__link18]: https://docs.rs/cachet/0.5.1/cachet/?search=telemetry::attributes [__link19]: https://docs.rs/bytesbuf/0.5.0/bytesbuf/?search=BytesView - [__link2]: https://docs.rs/cachet/0.6.0/cachet/?search=CacheBuilder::stampede_protection + [__link2]: https://docs.rs/cachet/0.5.1/cachet/?search=CacheBuilder::stampede_protection [__link20]: https://crates.io/crates/tracing/0.1.44 - [__link21]: https://docs.rs/cachet/0.6.0/cachet/?search=telemetry::attributes + [__link21]: https://docs.rs/cachet/0.5.1/cachet/?search=telemetry::attributes [__link3]: https://docs.rs/cachet_tier/0.1.0/cachet_tier/?search=CacheTier [__link4]: https://docs.rs/cachet_tier/0.1.0/cachet_tier/?search=DynamicCache - [__link5]: https://docs.rs/cachet/0.6.0/cachet/?search=InsertPolicy + [__link5]: https://docs.rs/cachet/0.5.1/cachet/?search=InsertPolicy [__link6]: https://docs.rs/tick/0.3.0/tick/?search=Clock - [__link7]: https://docs.rs/cachet/0.6.0/cachet/?search=Cache - [__link8]: https://docs.rs/cachet/0.6.0/cachet/?search=CacheBuilder + [__link7]: https://docs.rs/cachet/0.5.1/cachet/?search=Cache + [__link8]: https://docs.rs/cachet/0.5.1/cachet/?search=CacheBuilder [__link9]: https://docs.rs/cachet_tier/0.1.0/cachet_tier/?search=CacheEntry From 57fa37a5361c02193af36e0c81e5ae1bcbf02aa8 Mon Sep 17 00:00:00 2001 From: "Ethiopia Mengesha (from Dev Box)" Date: Wed, 27 May 2026 14:25:07 -0700 Subject: [PATCH 20/27] add tests to in memory builder --- crates/cachet_memory/src/builder.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/cachet_memory/src/builder.rs b/crates/cachet_memory/src/builder.rs index 081701255..e0455e3c7 100644 --- a/crates/cachet_memory/src/builder.rs +++ b/crates/cachet_memory/src/builder.rs @@ -451,6 +451,26 @@ mod tests { assert_eq!(builder.name, Some("test")); } + #[test] + fn eviction_telemetry_defaults_false() { + let builder = InMemoryCacheBuilder::::new(); + assert!(!builder.eviction_telemetry_enabled()); + } + + #[test] + fn with_eviction_telemetry_sets_flag() { + let builder = InMemoryCacheBuilder::::new().with_eviction_telemetry(); + assert!(builder.eviction_telemetry_enabled()); + } + + #[test] + fn with_hasher_preserves_eviction_telemetry_flag() { + let builder = InMemoryCacheBuilder::::new() + .with_eviction_telemetry() + .with_hasher(std::collections::hash_map::RandomState::new()); + assert!(builder.eviction_telemetry_enabled()); + } + #[test] fn build_max_capacity_lt_initial_capacity_returns_validation_error() { let result = InMemoryCacheBuilder::::new() From 732c4adf6809bd7463665ee1f87cce84b4f8088c Mon Sep 17 00:00:00 2001 From: "Ethiopia Mengesha (from Dev Box)" Date: Wed, 27 May 2026 14:26:36 -0700 Subject: [PATCH 21/27] nit --- crates/cachet_memory/src/builder.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cachet_memory/src/builder.rs b/crates/cachet_memory/src/builder.rs index e0455e3c7..9226cc659 100644 --- a/crates/cachet_memory/src/builder.rs +++ b/crates/cachet_memory/src/builder.rs @@ -265,8 +265,8 @@ impl InMemoryCacheBuilder { /// # Examples /// /// ```no_run - /// use std::sync::atomic::{AtomicUsize, Ordering}; /// use std::sync::Arc; + /// use std::sync::atomic::{AtomicUsize, Ordering}; /// /// use cachet_memory::{InMemoryCache, RemovalCause}; /// From 94cfeb1569175ca4e112646ffe78aee70d10d339 Mon Sep 17 00:00:00 2001 From: "Ethiopia Mengesha (from Dev Box)" Date: Thu, 28 May 2026 11:53:32 -0700 Subject: [PATCH 22/27] fix up testing --- crates/cachet/tests/eviction.rs | 4 ++-- crates/cachet_memory/src/builder.rs | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/cachet/tests/eviction.rs b/crates/cachet/tests/eviction.rs index 7ba307844..aa0f3ff33 100644 --- a/crates/cachet/tests/eviction.rs +++ b/crates/cachet/tests/eviction.rs @@ -9,7 +9,7 @@ use std::time::Duration; use cachet::{Cache, CacheEntry}; -use testing_aids::LogCapture; +use testing_aids::{LogCapture, TEST_TIMEOUT}; use tick::Clock; use tracing_subscriber::Registry; use tracing_subscriber::layer::SubscriberExt; @@ -37,7 +37,7 @@ async fn memory_size_eviction_emits_telemetry() { // Drive enough churn to force size-based evictions. Moka's housekeeping // runs periodically (and as a side effect of cache operations), so we keep // exercising the cache while waiting for an eviction event to surface. - let deadline = std::time::Instant::now() + Duration::from_secs(10); + let deadline = std::time::Instant::now() + TEST_TIMEOUT; let mut i: i32 = 0; while std::time::Instant::now() < deadline { for _ in 0..256 { diff --git a/crates/cachet_memory/src/builder.rs b/crates/cachet_memory/src/builder.rs index 9226cc659..3c78b4274 100644 --- a/crates/cachet_memory/src/builder.rs +++ b/crates/cachet_memory/src/builder.rs @@ -471,6 +471,27 @@ mod tests { assert!(builder.eviction_telemetry_enabled()); } + #[test] + fn debug_impl_renders_all_fields() { + let builder = InMemoryCacheBuilder::::new() + .max_capacity(100) + .initial_capacity(10) + .time_to_live(Duration::from_secs(60)) + .time_to_idle(Duration::from_secs(30)) + .name("my_cache") + .with_eviction_telemetry() + .on_eviction(|_| {}); + let rendered = format!("{builder:?}"); + assert!(rendered.contains("InMemoryCacheBuilder")); + assert!(rendered.contains("max_capacity: Some(100)")); + assert!(rendered.contains("initial_capacity: Some(10)")); + assert!(rendered.contains("time_to_live: Some(60s)")); + assert!(rendered.contains("time_to_idle: Some(30s)")); + assert!(rendered.contains("name: Some(\"my_cache\")")); + assert!(rendered.contains("eviction_telemetry: true")); + assert!(rendered.contains("eviction_listener: Some(\"\")")); + } + #[test] fn build_max_capacity_lt_initial_capacity_returns_validation_error() { let result = InMemoryCacheBuilder::::new() From 816638fa6ab03979c386ff06c2d65243710fa4b6 Mon Sep 17 00:00:00 2001 From: "Ethiopia Mengesha (from Dev Box)" Date: Thu, 28 May 2026 12:13:31 -0700 Subject: [PATCH 23/27] fix cached at bug --- crates/cachet/src/cache.rs | 15 ++++++++++----- crates/cachet/tests/cache.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/crates/cachet/src/cache.rs b/crates/cachet/src/cache.rs index ead9ea083..9b3c653d3 100644 --- a/crates/cachet/src/cache.rs +++ b/crates/cachet/src/cache.rs @@ -432,7 +432,8 @@ where return Ok(entry); } let value = f().await; - let entry = CacheEntry::new(value); + let mut entry = CacheEntry::new(value); + entry.ensure_cached_at(self.clock.system_time()); self.insert(key.clone(), entry.clone()).await?; Ok(entry) } @@ -514,7 +515,8 @@ where if let Some(entry) = self.storage.get(key).await? { return Ok(entry); } - let entry = f().await; + let mut entry = f().await; + entry.ensure_cached_at(self.clock.system_time()); self.insert(key.clone(), entry.clone()).await?; Ok(entry) } @@ -594,7 +596,8 @@ where if let Some(entry) = self.storage.get(key).await? { return Ok(entry); } - let entry = f().await.map_err(Error::from_source)?; + let mut entry = f().await.map_err(Error::from_source)?; + entry.ensure_cached_at(self.clock.system_time()); self.insert(key.clone(), entry.clone()).await?; Ok(entry) } @@ -668,7 +671,8 @@ where return Ok(entry); } let value = f().await.map_err(Error::from_source)?; - let entry = CacheEntry::new(value); + let mut entry = CacheEntry::new(value); + entry.ensure_cached_at(self.clock.system_time()); self.insert(key.clone(), entry.clone()).await?; Ok(entry) } @@ -745,7 +749,8 @@ where } match f().await { Some(value) => { - let entry = CacheEntry::new(value); + let mut entry = CacheEntry::new(value); + entry.ensure_cached_at(self.clock.system_time()); self.insert(key.clone(), entry.clone()).await?; Ok(Some(entry)) } diff --git a/crates/cachet/tests/cache.rs b/crates/cachet/tests/cache.rs index eed00c9fe..51a31dcf0 100644 --- a/crates/cachet/tests/cache.rs +++ b/crates/cachet/tests/cache.rs @@ -382,6 +382,42 @@ async fn get_or_insert_with_preserves_per_entry_ttl() { assert_eq!(entry.ttl(), Some(ttl)); } +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn or_insert_family_populates_cached_at_on_miss() { + let clock = Clock::new_frozen(); + let now = clock.system_time(); + let cache = Cache::builder::(clock).memory().build(); + + let entry = cache.get_or_insert(&"a".to_string(), || async { 1 }).await.unwrap(); + assert_eq!(entry.cached_at(), Some(now)); + + let entry = cache + .get_or_insert_with(&"b".to_string(), || async { CacheEntry::new(2) }) + .await + .unwrap(); + assert_eq!(entry.cached_at(), Some(now)); + + let entry = cache + .try_get_or_insert(&"c".to_string(), || async { Ok::<_, Error>(3) }) + .await + .unwrap(); + assert_eq!(entry.cached_at(), Some(now)); + + let entry = cache + .try_get_or_insert_with(&"d".to_string(), || async { Ok::<_, Error>(CacheEntry::new(4)) }) + .await + .unwrap(); + assert_eq!(entry.cached_at(), Some(now)); + + let entry = cache + .optionally_get_or_insert(&"e".to_string(), || async { Some(5) }) + .await + .unwrap() + .unwrap(); + assert_eq!(entry.cached_at(), Some(now)); +} + #[cfg_attr(miri, ignore)] #[tokio::test] async fn stampede_protection_get_or_insert_with() { From 676b2e96120c52f1c0253a9680e9155c3d1cf79f Mon Sep 17 00:00:00 2001 From: "Ethiopia Mengesha (from Dev Box)" Date: Thu, 28 May 2026 12:16:55 -0700 Subject: [PATCH 24/27] update docs --- crates/cachet_memory/README.md | 38 +++++++++++++++++++++++++++------ crates/cachet_memory/src/lib.rs | 18 ++++++++++++++++ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/crates/cachet_memory/README.md b/crates/cachet_memory/README.md index 9dcab350c..82950c1d5 100644 --- a/crates/cachet_memory/README.md +++ b/crates/cachet_memory/README.md @@ -50,17 +50,35 @@ assert_eq!(*value.unwrap().value(), 42); * **TTL/TTI**: Configure time-to-live and time-to-idle expiration * **Per-entry TTL**: Honors [`CacheEntry::expires_after`][__link3] for per-entry expiration +* **Eviction notifications**: Observe removals via + [`InMemoryCacheBuilder::on_eviction`][__link4] or opt into host-side telemetry with + [`InMemoryCacheBuilder::with_eviction_telemetry`][__link5] * **Thread-safe**: Safe for concurrent access from multiple tasks * **Zero external types**: Builder API keeps implementation details private +## Eviction Notifications + +Two complementary hooks are available for observing entry removals: + +* [`InMemoryCacheBuilder::on_eviction`][__link6] takes a closure invoked with a + [`RemovalCause`][__link7] for every removal (capacity, expiry, explicit, or replace). + Use this for custom side effects. +* [`InMemoryCacheBuilder::with_eviction_telemetry`][__link8] is a marker that the host + crate (`cachet`) recognizes via `CacheBuilder::memory_with` and uses to + install a built-in listener that emits `cache.eviction` for capacity + removals and `cache.expired` for background TTL/TTI expiry. Explicit and + replaced removals are intentionally not surfaced — they are already covered + by the host’s `cache.invalidated` and `cache.inserted` events. The marker + has no effect when [`InMemoryCache`][__link9] is built directly without a host. + ## Expiration Behavior This tier supports three independent expiration mechanisms. When multiple are active, the **shortest duration wins** - an entry is evicted at the earliest of: -1. The per-entry TTL from [`CacheEntry::expires_after`][__link4] -1. The cache-wide TTL from [`InMemoryCacheBuilder::time_to_live`][__link5] -1. The cache-wide TTI from [`InMemoryCacheBuilder::time_to_idle`][__link6] +1. The per-entry TTL from [`CacheEntry::expires_after`][__link10] +1. The cache-wide TTL from [`InMemoryCacheBuilder::time_to_live`][__link11] +1. The cache-wide TTI from [`InMemoryCacheBuilder::time_to_idle`][__link12] This means the builder-level TTL/TTI acts as an **upper bound** on per-entry TTL. A per-entry TTL longer than the builder TTL will be silently clamped to the @@ -73,11 +91,17 @@ TTL/TTI unset or set them to a sufficiently high ceiling. This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbH3tSSkTy3IQbTDeN6-tAXxIbkPbxRWVtlUQbcD5RjbTTL_RhZIKCbWNhY2hldF9tZW1vcnllMC4yLjCCa2NhY2hldF90aWVyZTAuMS4w + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbN0kpRlU_G9QbWC713oa4KjsbRG6BIsW3BU8bzI21NivEBVphZIKCbWNhY2hldF9tZW1vcnllMC4yLjCCa2NhY2hldF90aWVyZTAuMS4w [__link0]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=InMemoryCache [__link1]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=InMemoryCacheBuilder + [__link10]: https://docs.rs/cachet_tier/0.1.0/cachet_tier/?search=CacheEntry::expires_after + [__link11]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=InMemoryCacheBuilder::time_to_live + [__link12]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=InMemoryCacheBuilder::time_to_idle [__link2]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=policy::EvictionPolicy [__link3]: https://docs.rs/cachet_tier/0.1.0/cachet_tier/?search=CacheEntry::expires_after - [__link4]: https://docs.rs/cachet_tier/0.1.0/cachet_tier/?search=CacheEntry::expires_after - [__link5]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=InMemoryCacheBuilder::time_to_live - [__link6]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=InMemoryCacheBuilder::time_to_idle + [__link4]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=InMemoryCacheBuilder::on_eviction + [__link5]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=InMemoryCacheBuilder::with_eviction_telemetry + [__link6]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=InMemoryCacheBuilder::on_eviction + [__link7]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=notification::RemovalCause + [__link8]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=InMemoryCacheBuilder::with_eviction_telemetry + [__link9]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=InMemoryCache diff --git a/crates/cachet_memory/src/lib.rs b/crates/cachet_memory/src/lib.rs index b00c3a769..5851aaee3 100644 --- a/crates/cachet_memory/src/lib.rs +++ b/crates/cachet_memory/src/lib.rs @@ -43,9 +43,27 @@ //! - **TTL/TTI**: Configure time-to-live and time-to-idle expiration //! - **Per-entry TTL**: Honors [`CacheEntry::expires_after`][cachet_tier::CacheEntry::expires_after] //! for per-entry expiration +//! - **Eviction notifications**: Observe removals via +//! [`InMemoryCacheBuilder::on_eviction`] or opt into host-side telemetry with +//! [`InMemoryCacheBuilder::with_eviction_telemetry`] //! - **Thread-safe**: Safe for concurrent access from multiple tasks //! - **Zero external types**: Builder API keeps implementation details private //! +//! # Eviction Notifications +//! +//! Two complementary hooks are available for observing entry removals: +//! +//! - [`InMemoryCacheBuilder::on_eviction`] takes a closure invoked with a +//! [`RemovalCause`] for every removal (capacity, expiry, explicit, or replace). +//! Use this for custom side effects. +//! - [`InMemoryCacheBuilder::with_eviction_telemetry`] is a marker that the host +//! crate (`cachet`) recognizes via `CacheBuilder::memory_with` and uses to +//! install a built-in listener that emits `cache.eviction` for capacity +//! removals and `cache.expired` for background TTL/TTI expiry. Explicit and +//! replaced removals are intentionally not surfaced — they are already covered +//! by the host's `cache.invalidated` and `cache.inserted` events. The marker +//! has no effect when [`InMemoryCache`] is built directly without a host. +//! //! # Expiration Behavior //! //! This tier supports three independent expiration mechanisms. When multiple are From 8a3442d921a27a9702898caecb7e293786a663bf Mon Sep 17 00:00:00 2001 From: "Ethiopia Mengesha (from Dev Box)" Date: Fri, 29 May 2026 08:25:19 -0700 Subject: [PATCH 25/27] bump cachet_memory --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/cachet/README.md | 4 ++-- crates/cachet_memory/CHANGELOG.md | 7 +++++++ crates/cachet_memory/Cargo.toml | 2 +- crates/cachet_memory/README.md | 24 ++++++++++++------------ 6 files changed, 24 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 708e66e93..7c1e9375b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -505,7 +505,7 @@ dependencies = [ [[package]] name = "cachet_memory" -version = "0.2.0" +version = "0.2.1" dependencies = [ "cachet_tier", "criterion", diff --git a/Cargo.toml b/Cargo.toml index b31d92b66..fed0d4304 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ anyspawn = { path = "crates/anyspawn", default-features = false, version = "0.5. bytesbuf = { path = "crates/bytesbuf", default-features = false, version = "0.5.0" } bytesbuf_io = { path = "crates/bytesbuf_io", default-features = false, version = "0.5.0" } cachet = { path = "crates/cachet", default-features = false, version = "0.5.1" } -cachet_memory = { path = "crates/cachet_memory", default-features = false, version = "0.2.0" } +cachet_memory = { path = "crates/cachet_memory", default-features = false, version = "0.2.1" } cachet_service = { path = "crates/cachet_service", default-features = false, version = "0.1.0" } cachet_tier = { path = "crates/cachet_tier", default-features = false, version = "0.1.0" } data_privacy = { path = "crates/data_privacy", default-features = false, version = "0.11.0" } diff --git a/crates/cachet/README.md b/crates/cachet/README.md index 7c1a0d9cc..ec68cd21b 100644 --- a/crates/cachet/README.md +++ b/crates/cachet/README.md @@ -265,7 +265,7 @@ See the `telemetry_subscriber` example for a complete demonstration. This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbg_hDqE88LP4bMh0J5Y4y4Osb0zDJ1kwqOsoblCGrm49Rx2thZIiCaGJ5dGVzYnVmZTAuNS4wgmZjYWNoZXRlMC41LjGCbWNhY2hldF9tZW1vcnllMC4yLjCCbmNhY2hldF9zZXJ2aWNlZTAuMS4wgmtjYWNoZXRfdGllcmUwLjEuMIJkdGlja2UwLjMuMIJndHJhY2luZ2YwLjEuNDSCaXVuaWZsaWdodGUwLjIuMA + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbg_hDqE88LP4bMh0J5Y4y4Osb0zDJ1kwqOsoblCGrm49Rx2thZIiCaGJ5dGVzYnVmZTAuNS4wgmZjYWNoZXRlMC41LjGCbWNhY2hldF9tZW1vcnllMC4yLjGCbmNhY2hldF9zZXJ2aWNlZTAuMS4wgmtjYWNoZXRfdGllcmUwLjEuMIJkdGlja2UwLjMuMIJndHJhY2luZ2YwLjEuNDSCaXVuaWZsaWdodGUwLjIuMA [__link0]: https://docs.rs/cachet/0.5.1/cachet/?search=TimeToRefresh [__link1]: https://crates.io/crates/uniflight/0.2.0 [__link10]: https://docs.rs/cachet_tier/0.1.0/cachet_tier/?search=CacheTier @@ -273,7 +273,7 @@ This crate was developed as part of The Oxidizer Project. Br [__link12]: https://docs.rs/cachet/0.5.1/cachet/?search=TimeToRefresh [__link13]: https://docs.rs/cachet_tier/0.1.0/cachet_tier/?search=Error [__link14]: https://crates.io/crates/cachet_tier/0.1.0 - [__link15]: https://crates.io/crates/cachet_memory/0.2.0 + [__link15]: https://crates.io/crates/cachet_memory/0.2.1 [__link16]: https://docs.rs/moka [__link17]: https://crates.io/crates/cachet_service/0.1.0 [__link18]: https://docs.rs/cachet/0.5.1/cachet/?search=telemetry::attributes diff --git a/crates/cachet_memory/CHANGELOG.md b/crates/cachet_memory/CHANGELOG.md index 3c21ff451..a9b87b941 100644 --- a/crates/cachet_memory/CHANGELOG.md +++ b/crates/cachet_memory/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.2.1] + +- ✨ Features + + - Add `InMemoryCacheBuilder::on_eviction` for observing entry removals, along with the new public [`RemovalCause`] enum. + - Add `InMemoryCacheBuilder::with_eviction_telemetry` as a marker for the `cachet` host crate to install built-in eviction telemetry via `CacheBuilder::memory_with`. + ## [0.2.0] - 2026-05-19 - ✔️ Tasks diff --git a/crates/cachet_memory/Cargo.toml b/crates/cachet_memory/Cargo.toml index 56aaff6dd..83e4e206f 100644 --- a/crates/cachet_memory/Cargo.toml +++ b/crates/cachet_memory/Cargo.toml @@ -4,7 +4,7 @@ [package] name = "cachet_memory" description = "In-memory cache tier backed by Moka for the cachet caching library." -version = "0.2.0" +version = "0.2.1" readme = "README.md" keywords = ["oxidizer", "caching", "concurrency"] categories = ["caching", "concurrency"] diff --git a/crates/cachet_memory/README.md b/crates/cachet_memory/README.md index 82950c1d5..0cac02680 100644 --- a/crates/cachet_memory/README.md +++ b/crates/cachet_memory/README.md @@ -91,17 +91,17 @@ TTL/TTI unset or set them to a sufficiently high ceiling. This crate was developed as part of The Oxidizer Project. Browse this crate's source code. - [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbN0kpRlU_G9QbWC713oa4KjsbRG6BIsW3BU8bzI21NivEBVphZIKCbWNhY2hldF9tZW1vcnllMC4yLjCCa2NhY2hldF90aWVyZTAuMS4w - [__link0]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=InMemoryCache - [__link1]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=InMemoryCacheBuilder + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbN0kpRlU_G9QbWC713oa4KjsbRG6BIsW3BU8bzI21NivEBVphZIKCbWNhY2hldF9tZW1vcnllMC4yLjGCa2NhY2hldF90aWVyZTAuMS4w + [__link0]: https://docs.rs/cachet_memory/0.2.1/cachet_memory/?search=InMemoryCache + [__link1]: https://docs.rs/cachet_memory/0.2.1/cachet_memory/?search=InMemoryCacheBuilder [__link10]: https://docs.rs/cachet_tier/0.1.0/cachet_tier/?search=CacheEntry::expires_after - [__link11]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=InMemoryCacheBuilder::time_to_live - [__link12]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=InMemoryCacheBuilder::time_to_idle - [__link2]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=policy::EvictionPolicy + [__link11]: https://docs.rs/cachet_memory/0.2.1/cachet_memory/?search=InMemoryCacheBuilder::time_to_live + [__link12]: https://docs.rs/cachet_memory/0.2.1/cachet_memory/?search=InMemoryCacheBuilder::time_to_idle + [__link2]: https://docs.rs/cachet_memory/0.2.1/cachet_memory/?search=policy::EvictionPolicy [__link3]: https://docs.rs/cachet_tier/0.1.0/cachet_tier/?search=CacheEntry::expires_after - [__link4]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=InMemoryCacheBuilder::on_eviction - [__link5]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=InMemoryCacheBuilder::with_eviction_telemetry - [__link6]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=InMemoryCacheBuilder::on_eviction - [__link7]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=notification::RemovalCause - [__link8]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=InMemoryCacheBuilder::with_eviction_telemetry - [__link9]: https://docs.rs/cachet_memory/0.2.0/cachet_memory/?search=InMemoryCache + [__link4]: https://docs.rs/cachet_memory/0.2.1/cachet_memory/?search=InMemoryCacheBuilder::on_eviction + [__link5]: https://docs.rs/cachet_memory/0.2.1/cachet_memory/?search=InMemoryCacheBuilder::with_eviction_telemetry + [__link6]: https://docs.rs/cachet_memory/0.2.1/cachet_memory/?search=InMemoryCacheBuilder::on_eviction + [__link7]: https://docs.rs/cachet_memory/0.2.1/cachet_memory/?search=notification::RemovalCause + [__link8]: https://docs.rs/cachet_memory/0.2.1/cachet_memory/?search=InMemoryCacheBuilder::with_eviction_telemetry + [__link9]: https://docs.rs/cachet_memory/0.2.1/cachet_memory/?search=InMemoryCache From 2a86cc739d61633ebfebc817bc6cb1d4f8bd3e24 Mon Sep 17 00:00:00 2001 From: "Ethiopia Mengesha (from Dev Box)" Date: Fri, 29 May 2026 08:37:42 -0700 Subject: [PATCH 26/27] chain listeners instead of overwrite with eviction listner --- crates/cachet_memory/src/builder.rs | 47 ++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/cachet_memory/src/builder.rs b/crates/cachet_memory/src/builder.rs index 3c78b4274..2fd81067f 100644 --- a/crates/cachet_memory/src/builder.rs +++ b/crates/cachet_memory/src/builder.rs @@ -262,6 +262,12 @@ impl InMemoryCacheBuilder { /// The listener runs on the cache's background maintenance task. Keep the /// closure cheap; expensive work should be offloaded to a separate task. /// + /// If a listener was already registered (for example via an earlier + /// `on_eviction` call, or by the host crate when + /// [`with_eviction_telemetry`](Self::with_eviction_telemetry) is enabled), + /// the new listener is chained — both run on every removal, in registration + /// order. + /// /// # Examples /// /// ```no_run @@ -288,7 +294,13 @@ impl InMemoryCacheBuilder { where F: Fn(RemovalCause) + Send + Sync + 'static, { - self.eviction_listener = Some(Arc::new(listener)); + self.eviction_listener = Some(match self.eviction_listener.take() { + Some(previous) => Arc::new(move |cause| { + previous(cause); + listener(cause); + }), + None => Arc::new(listener), + }); self } @@ -471,6 +483,39 @@ mod tests { assert!(builder.eviction_telemetry_enabled()); } + #[test] + fn on_eviction_chains_existing_listener() { + use std::sync::Mutex; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + let first_count = Arc::new(AtomicUsize::new(0)); + let second_count = Arc::new(AtomicUsize::new(0)); + let order: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let first_count_cb = Arc::clone(&first_count); + let second_count_cb = Arc::clone(&second_count); + let order_first = Arc::clone(&order); + let order_second = Arc::clone(&order); + + let builder = InMemoryCacheBuilder::::new() + .on_eviction(move |_| { + first_count_cb.fetch_add(1, Ordering::Relaxed); + order_first.lock().unwrap().push("first"); + }) + .on_eviction(move |_| { + second_count_cb.fetch_add(1, Ordering::Relaxed); + order_second.lock().unwrap().push("second"); + }); + + let listener = builder.eviction_listener.expect("listener should be installed"); + listener(RemovalCause::Size); + + assert_eq!(first_count.load(Ordering::Relaxed), 1); + assert_eq!(second_count.load(Ordering::Relaxed), 1); + assert_eq!(*order.lock().unwrap(), vec!["first", "second"]); + } + #[test] fn debug_impl_renders_all_fields() { let builder = InMemoryCacheBuilder::::new() From 40d712afc7c73147f6f89601a6127cbb0712757a Mon Sep 17 00:00:00 2001 From: "Ethiopia Mengesha (from Dev Box)" Date: Fri, 29 May 2026 08:49:40 -0700 Subject: [PATCH 27/27] fmt --- crates/cachet_memory/src/builder.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/cachet_memory/src/builder.rs b/crates/cachet_memory/src/builder.rs index 2fd81067f..0e1e8684b 100644 --- a/crates/cachet_memory/src/builder.rs +++ b/crates/cachet_memory/src/builder.rs @@ -486,8 +486,7 @@ mod tests { #[test] fn on_eviction_chains_existing_listener() { use std::sync::Mutex; - use std::sync::atomic::AtomicUsize; - use std::sync::atomic::Ordering; + use std::sync::atomic::{AtomicUsize, Ordering}; let first_count = Arc::new(AtomicUsize::new(0)); let second_count = Arc::new(AtomicUsize::new(0));