cachekit/client.rs
1use std::sync::Arc;
2use std::time::Duration;
3
4use bytes::Bytes;
5
6use serde::{de::DeserializeOwned, Serialize};
7
8use crate::backend::Backend;
9#[cfg(feature = "l1")]
10use crate::backend::{ttl_wire_secs, Freshness};
11use crate::error::CachekitError;
12use crate::metrics::{CacheCounters, L1Stats, ReadOutcome};
13use crate::serializer;
14
15// ── SharedBackend type alias ──────────────────────────────────────────────────
16
17/// Reference-counted pointer to a heap-allocated backend.
18///
19/// On native targets (without `unsync`) we require `Send + Sync` via `Arc`.
20/// On `wasm32` or with the `unsync` feature, `Rc` is used instead — the runtime
21/// is single-threaded so `Send` bounds are unnecessary.
22#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
23pub type SharedBackend = std::sync::Arc<dyn Backend>;
24
25/// Reference-counted pointer to a heap-allocated backend (`?Send` variant).
26#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
27pub type SharedBackend = std::rc::Rc<dyn Backend>;
28
29// ── SharedFlight type alias ──────────────────────────────────────────────────
30
31/// Reference-counted pointer to the single-flight map, so client clones share
32/// fill-dedup state (two clones racing a cold miss must collapse to one fill).
33#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
34pub(crate) type SharedFlight = std::sync::Arc<crate::flight::FlightMap>;
35
36#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
37pub(crate) type SharedFlight = std::rc::Rc<crate::flight::FlightMap>;
38
39/// Separate same-key ordering from single-flight: a refresh holds the flight
40/// lock while computing, then takes this lock only for its version-checked
41/// commit. Direct reads/writes/deletes take the same mutation lock.
42#[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
43type SharedMutations = std::sync::Arc<crate::flight::MutationMap>;
44
45// ── SharedEncryption type alias ──────────────────────────────────────────────
46
47/// Reference-counted pointer to the encryption layer.
48///
49/// On native targets (without `unsync`) `Arc` is used (requires `Sync`).
50/// On `wasm32` or with `unsync`, `Rc` is used — avoids the `!Sync` problem
51/// caused by `Cell<u64>` inside cachekit-core's nonce counter.
52#[cfg(all(
53 feature = "encryption",
54 not(any(target_arch = "wasm32", feature = "unsync"))
55))]
56type SharedEncryption = std::sync::Arc<crate::encryption::EncryptionLayer>;
57
58#[cfg(all(
59 feature = "encryption",
60 any(target_arch = "wasm32", feature = "unsync")
61))]
62type SharedEncryption = std::rc::Rc<crate::encryption::EncryptionLayer>;
63
64// ── Key validation ────────────────────────────────────────────────────────────
65
66const MAX_KEY_BYTES: usize = 1024;
67
68/// Maximum TTL for L1 entries populated from L2 cache hits.
69/// Uses a short ceiling to limit staleness when the original TTL is unknown.
70/// A server `X-CacheKit-Fresh-For` bounds it further, and a stale read or a
71/// zero bound is not backfilled at all — see [`l1_backfill_ttl`].
72///
73/// Reconciliation with stale-while-revalidate: a backfilled entry's SWR
74/// freshness window derives from this capped TTL (window = ratio × entry
75/// TTL), **not** from the write-path TTL — the cap is the staleness bound
76/// for L2-derived data, deliberately kept. SWR removes the cap's expiry
77/// cliff instead: past ~ratio × 30 s the entry is served stale while one
78/// background refresh re-executes the origin. If no newer mutation replaced
79/// the entry, that refresh writes both layers and renews L1 hard expiry with
80/// the caller's full TTL. Without SWR the entry simply hard-expires at the cap
81/// and the next read blocks on L2, as before.
82#[cfg(feature = "l1")]
83const L1_BACKFILL_TTL_SECS: u64 = 30;
84
85/// L1 lifetime for a backfill from an L2 hit, or `None` when the read must not
86/// be backfilled (`spec/saas-api.md` § Remaining Freshness). A stale label or a
87/// zero `Fresh-For` forbids it; a present `Fresh-For` is a hard bound under the
88/// 30 s cap, which SWR cannot outlive because moka drops the entry at its TTL.
89/// With no header the cap applies unchanged.
90#[cfg(feature = "l1")]
91fn l1_backfill_ttl(default_ttl: Duration, freshness: Freshness) -> Option<Duration> {
92 let cap = std::cmp::min(default_ttl, Duration::from_secs(L1_BACKFILL_TTL_SECS));
93 let ttl = freshness
94 .fresh_for
95 .map_or(cap, |fresh_for| cap.min(fresh_for));
96 (!freshness.is_stale && !ttl.is_zero()).then_some(ttl)
97}
98
99fn validate_key(key: &str) -> Result<(), CachekitError> {
100 if key.is_empty() {
101 return Err(CachekitError::InvalidKey(
102 "key must not be empty".to_owned(),
103 ));
104 }
105 if key.len() > MAX_KEY_BYTES {
106 return Err(CachekitError::InvalidKey(format!(
107 "key is {} bytes (limit: {MAX_KEY_BYTES})",
108 key.len()
109 )));
110 }
111 for b in key.bytes() {
112 if b < 0x20 || b == 0x7F {
113 return Err(CachekitError::InvalidKey(format!(
114 "key contains illegal control character 0x{b:02X}"
115 )));
116 }
117 }
118 Ok(())
119}
120
121// ── Stale-while-revalidate ───────────────────────────────────────────────────
122//
123// SWR needs an L1 to age entries in and a spawnable (`Send`) runtime for the
124// background refresh — native, non-`unsync` targets with the `l1` feature.
125// Everywhere else the SWR read path degrades to the plain read path and
126// `SwrRead::Stale` is never produced.
127
128/// Outcome of an SWR-aware typed read — see [`CacheKit::interop_get_swr`].
129#[derive(Debug, Clone, PartialEq)]
130pub enum SwrRead<T> {
131 /// Cache hit within the freshness window (or an L2 hit): use directly.
132 Fresh(T),
133 /// L1 hit past the freshness threshold but before hard expiry: use the
134 /// value now, and schedule a background refresh (the `#[cachekit]` macro
135 /// re-executes the function, deduplicated on the [`CacheKit::single_flight`]
136 /// locks without waiting on them). The token
137 /// makes refresh completion conditional: a newer set or delete wins.
138 Stale(T, SwrToken),
139 /// No usable entry: fall through to a normal blocking miss + fill.
140 Miss,
141}
142
143/// Mutation version captured by an SWR stale read.
144///
145/// Pass this back only through the `#[cachekit]`-generated refresh path. It
146/// prevents an older background computation from overwriting a newer write
147/// or resurrecting a deleted entry on this client or any of its clones.
148#[derive(Clone)]
149pub struct SwrToken {
150 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
151 state: std::sync::Arc<crate::flight::MutationState>,
152 version: u64,
153}
154
155impl std::fmt::Debug for SwrToken {
156 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157 f.debug_struct("SwrToken")
158 .field("version", &self.version)
159 .finish_non_exhaustive()
160 }
161}
162
163impl PartialEq for SwrToken {
164 fn eq(&self, other: &Self) -> bool {
165 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
166 {
167 self.version == other.version && std::sync::Arc::ptr_eq(&self.state, &other.state)
168 }
169 #[cfg(not(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32"))))]
170 {
171 self.version == other.version
172 }
173 }
174}
175
176impl Eq for SwrToken {}
177
178// ── CacheKit ─────────────────────────────────────────────────────────────────
179
180/// Cache client with optional L1 in-process cache layer.
181///
182/// `Clone` is cheap and shares everything: backend, L1 cache, single-flight
183/// state, and encryption layer. Clones exist so `'static` background work
184/// (e.g. the SWR refresh spawned by `#[cachekit]`) can hold the client
185/// without borrowing it.
186///
187/// # Encryption
188///
189/// A client built with encryption configured (by any of the builder's
190/// encryption methods, the `secure` / `secure_from_env` preset, or
191/// [`CacheKit::from_env`] with `CACHEKIT_MASTER_KEY` set) encrypts every
192/// value it writes and decrypts every value it reads — [`Self::get`],
193/// [`Self::interop_get`], [`Self::interop_get_swr`], [`Self::set`] and
194/// [`Self::set_with_ttl`] — and L1 holds ciphertext. Such a client has no
195/// plaintext value path, and the `SecureCache` handle from `secure_cache()`
196/// reads and writes the same entries. [`Self::delete`] and [`Self::exists`]
197/// carry no value and are the same either way.
198#[derive(Clone)]
199pub struct CacheKit {
200 backend: SharedBackend,
201 default_ttl: Duration,
202 namespace: Option<String>,
203 max_payload_bytes: usize,
204 flight: SharedFlight,
205 /// Read counters behind [`Self::stats`]; the backend holds a
206 /// `MetricsProvider` over the same allocation.
207 counters: Arc<CacheCounters>,
208
209 /// The reliability stack's breaker, when configured — kept so
210 /// [`Self::circuit_state`] can read it through the type-erased backend.
211 #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
212 breaker: Option<Arc<crate::reliability::CircuitBreaker>>,
213
214 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
215 mutations: SharedMutations,
216
217 #[cfg(feature = "l1")]
218 pub(crate) l1: Option<crate::l1::L1Cache>,
219
220 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
221 pub(crate) swr_enabled: bool,
222
223 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
224 swr_threshold_ratio: f64,
225
226 #[cfg(feature = "encryption")]
227 encryption: Option<SharedEncryption>,
228}
229
230impl CacheKit {
231 /// Create a new builder.
232 pub fn builder() -> CacheKitBuilder {
233 CacheKitBuilder::default()
234 }
235
236 /// Build from environment variables via [`crate::config::CachekitConfig::from_env`].
237 ///
238 /// Creates a [`crate::backend::cachekitio::CachekitIO`] backend from the
239 /// config. Requires the `cachekitio` feature. With the `encryption`
240 /// feature on and `CACHEKIT_MASTER_KEY` set, the client encrypts every
241 /// value read and write (see [Encryption](CacheKit#encryption)); without
242 /// the feature, a set `CACHEKIT_MASTER_KEY` is a [`CachekitError::Config`].
243 #[cfg(all(feature = "cachekitio", not(target_arch = "wasm32")))]
244 pub fn from_env() -> Result<CacheKitBuilder, CachekitError> {
245 use crate::backend::cachekitio::CachekitIO;
246 use crate::config::CachekitConfig;
247
248 let config = CachekitConfig::from_env()?;
249
250 // The same environment encrypts on a build with the feature, so
251 // ignoring the key here would be a silent plaintext downgrade.
252 #[cfg(not(feature = "encryption"))]
253 if config.master_key.is_some() {
254 return Err(encryption_feature_missing(
255 "CacheKit::from_env() with CACHEKIT_MASTER_KEY set",
256 ));
257 }
258
259 let api_key_z = config
260 .api_key
261 .ok_or_else(|| CachekitError::Config("CACHEKIT_API_KEY is required".to_owned()))?;
262
263 let backend = CachekitIO::builder()
264 .api_key(api_key_z.as_str())
265 .api_url(config.api_url)
266 .build()
267 .map_err(|e| CachekitError::Config(e.to_string()))?;
268
269 #[cfg(not(feature = "unsync"))]
270 let shared: SharedBackend = std::sync::Arc::new(backend);
271 #[cfg(feature = "unsync")]
272 let shared: SharedBackend = std::rc::Rc::new(backend);
273
274 let mut builder = CacheKitBuilder::default()
275 .backend(shared)
276 .default_ttl(config.default_ttl)
277 .max_payload_bytes(config.max_payload_bytes)
278 .l1_capacity(config.l1_capacity);
279
280 if let Some(ns) = config.namespace.clone() {
281 builder = builder.namespace(ns);
282 }
283
284 // Wire up encryption if master key is configured
285 #[cfg(feature = "encryption")]
286 if let Some(ref master_key) = config.master_key {
287 let namespace = config.namespace.as_deref().unwrap_or("default");
288 let previous: Vec<&[u8]> = config
289 .previous_master_keys
290 .iter()
291 .map(|key| key.as_slice())
292 .collect();
293 builder = builder.encryption_from_hex_decoded(master_key, &previous, namespace)?;
294 }
295
296 Ok(builder)
297 }
298
299 // ── Namespacing ───────────────────────────────────────────────────────────
300
301 fn namespaced_key(&self, key: &str) -> String {
302 match &self.namespace {
303 Some(ns) => format!("{ns}:{key}"),
304 None => key.to_owned(),
305 }
306 }
307
308 /// Validate key and return the namespaced version.
309 fn resolve_key(&self, key: &str) -> Result<String, CachekitError> {
310 validate_key(key)?;
311 Ok(self.namespaced_key(key))
312 }
313
314 // ── L1 helpers ───────────────────────────────────────────────────────────
315
316 /// Try L1 cache first. Returns Some(bytes) on hit.
317 #[cfg(feature = "l1")]
318 fn l1_get(&self, full_key: &str) -> Option<Bytes> {
319 self.l1.as_ref().and_then(|l1| l1.get_shared(full_key))
320 }
321
322 /// Populate L1 from an L2 hit, for no longer than [`l1_backfill_ttl`] allows.
323 #[cfg(feature = "l1")]
324 fn l1_backfill(&self, full_key: &str, bytes: Bytes, freshness: Freshness) {
325 if let (Some(l1), Some(ttl)) = (&self.l1, l1_backfill_ttl(self.default_ttl, freshness)) {
326 l1.set_shared(full_key, bytes, ttl);
327 }
328 }
329
330 /// Write-through to L1 after a write the backend accepted. The copy lives
331 /// no longer than the TTL the write sent on the wire ([`ttl_wire_secs`]):
332 /// `spec/saas-api.md` forbids serving it past `min(local_ttl, effective_ttl)`,
333 /// and the wire truncates a fractional TTL from 1 s up (1.5 s goes as 1).
334 #[cfg(feature = "l1")]
335 fn l1_set(&self, full_key: &str, bytes: Bytes, ttl: Duration) {
336 if let Some(ref l1) = self.l1 {
337 let wire = Duration::from_secs(ttl_wire_secs(ttl));
338 l1.set_shared(full_key, bytes, ttl.min(wire));
339 }
340 }
341
342 /// Invalidate L1 entry.
343 #[cfg(feature = "l1")]
344 fn l1_delete(&self, full_key: &str) {
345 if let Some(ref l1) = self.l1 {
346 l1.delete(full_key);
347 }
348 }
349
350 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
351 async fn lock_l1_mutation(&self, full_key: &str) -> Option<crate::flight::MutationGuard> {
352 if self.l1.is_some() {
353 Some(self.mutations.lock(full_key).await)
354 } else {
355 None
356 }
357 }
358
359 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
360 async fn complete_swr_bytes(
361 &self,
362 key: &str,
363 bytes: Vec<u8>,
364 ttl: Duration,
365 token: SwrToken,
366 ) -> Result<bool, CachekitError> {
367 Self::validate_ttl(ttl)?;
368 self.check_payload_size(bytes.len())?;
369 let full_key = self.resolve_key(key)?;
370 let Some(mutation) = self.lock_l1_mutation(&full_key).await else {
371 return Ok(false);
372 };
373
374 // Check before touching L2. The mutation state lives independently of
375 // the moka entry, so hard expiry or capacity eviction does not look
376 // like an explicit set/delete and waste a valid origin result.
377 if !mutation.is_current(&token.state, token.version) {
378 return Ok(false);
379 }
380 let l1_bytes = Bytes::copy_from_slice(&bytes);
381 self.backend.set(&full_key, bytes, Some(ttl)).await?;
382 self.l1_set(&full_key, l1_bytes, ttl);
383 mutation.advance();
384 crate::metrics::trace_write(&full_key, ttl);
385 Ok(true)
386 }
387
388 #[cfg(not(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32"))))]
389 async fn complete_swr_bytes(
390 &self,
391 _key: &str,
392 _bytes: Vec<u8>,
393 _ttl: Duration,
394 _token: SwrToken,
395 ) -> Result<bool, CachekitError> {
396 // `SwrRead::Stale` is unreachable on this build, so there is no
397 // versioned entry a caller could legitimately complete.
398 Ok(false)
399 }
400
401 /// Rejects a zero TTL.
402 fn validate_ttl(ttl: Duration) -> Result<(), CachekitError> {
403 if ttl.is_zero() {
404 return Err(CachekitError::Config(
405 "TTL must be greater than zero".to_owned(),
406 ));
407 }
408 Ok(())
409 }
410
411 // ── Public operations ─────────────────────────────────────────────────────
412
413 /// Retrieve and deserialize a value stored under `key`.
414 ///
415 /// Returns `None` if the key does not exist.
416 /// Checks L1 cache before hitting the backend.
417 ///
418 /// # Errors
419 ///
420 /// On a client with encryption configured, returns
421 /// [`CachekitError::Encryption`] — not a miss — if the stored entry fails
422 /// decryption. The key's L1 copy is dropped first and the backend entry
423 /// is left in place, so the next read reaches the backend: a hit once the
424 /// entry is replaced with ciphertext this client can decrypt, a miss once
425 /// it expires or is deleted. A decrypted entry that does not decode as `T`
426 /// is [`CachekitError::Serialization`] naming only `T`, never quoting the
427 /// value.
428 pub async fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, CachekitError> {
429 #[cfg(feature = "encryption")]
430 if let Some(secure) = self.encrypting() {
431 return secure.get(key).await;
432 }
433 match self.get_bytes(key).await? {
434 Some(bytes) => Ok(Some(serializer::deserialize(&bytes)?)),
435 None => Ok(None),
436 }
437 }
438
439 /// Retrieve and deserialize an interop-mode value stored under `key`.
440 ///
441 /// Identical to [`Self::get`] except the payload is decoded with
442 /// [`crate::interop::deserialize`]. Both consume exactly one MessagePack
443 /// document and reject trailing bytes (interop/v1 spec MUST); this one
444 /// also names a Python-SDK-internal CK frame with a specific diagnostic
445 /// instead of a generic trailing-bytes error.
446 ///
447 /// Use with keys from [`crate::interop::interop_key`] on a client
448 /// **without** a namespace prefix. There is no interop-specific write
449 /// method: [`Self::set`] already writes plain MessagePack (no ByteStorage
450 /// envelope), which is the interop value format. On a client with
451 /// encryption configured the stored bytes are AES-GCM ciphertext of that
452 /// MessagePack, decrypted here before decoding: in interop mode the
453 /// AES-GCM plaintext is the plain MessagePack value bytes, so the AAD
454 /// (v0x03, `format="msgpack"`, `compressed="False"`) verifies cross-SDK
455 /// unchanged.
456 ///
457 /// Unlike this read, [`Self::set`] / [`Self::set_with_ttl`] do not check
458 /// the client namespace: on a client with [`CacheKitBuilder::namespace`]
459 /// set they return `Ok` and store the entry under
460 /// `{namespace}:{interop_key}`, which no interop reader addresses. Interop
461 /// writes need a client without a namespace.
462 ///
463 /// # Errors
464 ///
465 /// Returns [`CachekitError::Config`] if the client was built with
466 /// [`CacheKitBuilder::namespace`]: the prefix
467 /// would rewrite the storage key to `{prefix}:{interop_key}`, which no
468 /// other SDK computes — every cross-SDK entry would silently miss. Interop
469 /// keys carry their own namespace segment; failing loudly here beats a
470 /// 100% miss rate that looks like a cold cache. With encryption
471 /// configured, an entry that fails decryption is
472 /// [`CachekitError::Encryption`], with the key's L1 copy dropped as for
473 /// [`Self::get`].
474 pub async fn interop_get<T: DeserializeOwned>(
475 &self,
476 key: &str,
477 ) -> Result<Option<T>, CachekitError> {
478 #[cfg(feature = "encryption")]
479 if let Some(secure) = self.encrypting() {
480 return secure.interop_get(key).await;
481 }
482 self.reject_namespaced_interop()?;
483 match self.get_bytes(key).await? {
484 Some(bytes) => Ok(Some(crate::interop::deserialize(&bytes)?)),
485 None => Ok(None),
486 }
487 }
488
489 /// Interop keys must reach the backend verbatim; a client namespace prefix
490 /// would silently produce storage keys no other SDK computes.
491 fn reject_namespaced_interop(&self) -> Result<(), CachekitError> {
492 match self.namespace {
493 None => Ok(()),
494 Some(_) => Err(CachekitError::Config(
495 "interop reads require a client without a namespace prefix: .namespace() \
496 would store interop entries under {prefix}:{interop_key}, \
497 which other SDKs never compute (interop keys already carry a namespace \
498 segment) — use a dedicated non-namespaced client for interop entries"
499 .to_owned(),
500 )),
501 }
502 }
503
504 /// Retrieve and deserialize an interop-mode value with SWR classification.
505 ///
506 /// Identical to [`Self::interop_get`] except an L1 hit is classified
507 /// against the client's stale-while-revalidate freshness window:
508 ///
509 /// - [`SwrRead::Fresh`] — L1 hit within `swr_threshold_ratio` of the
510 /// entry's TTL (±10% jitter), or any L2 hit. Use directly.
511 /// - [`SwrRead::Stale`] — L1 hit past the threshold but **before hard
512 /// expiry**: the value is returned without touching the backend or
513 /// origin, and the caller should schedule exactly one background
514 /// refresh. The `#[cachekit]` macro dedups it on the cold-miss
515 /// [`Self::single_flight`] locks without waiting for another worker's
516 /// fill: a refresh that finds either lock held, or whose lock call
517 /// fails, stands down and the stale value keeps being served. The
518 /// accompanying [`SwrToken`] makes completion conditional,
519 /// so a newer set/delete always wins.
520 /// - [`SwrRead::Miss`] — nothing usable anywhere: normal blocking miss.
521 ///
522 /// A hard-expired L1 entry is a [`SwrRead::Miss`], never `Stale` — moka
523 /// drops entries at their TTL, so SWR cannot serve past hard expiry.
524 ///
525 /// With SWR disabled ([`CacheKitBuilder::swr_enabled`]`(false)`), without
526 /// the `l1` feature, on wasm32, or under `unsync`, this behaves exactly
527 /// like [`Self::interop_get`]: hits are `Fresh`, `Stale` is never
528 /// produced.
529 ///
530 /// On a client with encryption configured, staleness is judged on the L1
531 /// **ciphertext** entry (freshness metadata never exposes plaintext),
532 /// then the value is decrypted as for [`Self::interop_get`].
533 ///
534 /// # Errors
535 ///
536 /// Same as [`Self::interop_get`] (including the namespaced-client
537 /// rejection).
538 pub async fn interop_get_swr<T: DeserializeOwned>(
539 &self,
540 key: &str,
541 ) -> Result<SwrRead<T>, CachekitError> {
542 #[cfg(feature = "encryption")]
543 if let Some(secure) = self.encrypting() {
544 return secure.interop_get_swr(key).await;
545 }
546 self.reject_namespaced_interop()?;
547 match self.get_bytes_swr(key).await? {
548 SwrRead::Fresh(b) => Ok(SwrRead::Fresh(crate::interop::deserialize(&b)?)),
549 SwrRead::Stale(b, token) => Ok(SwrRead::Stale(crate::interop::deserialize(&b)?, token)),
550 SwrRead::Miss => Ok(SwrRead::Miss),
551 }
552 }
553
554 /// Fetch raw payload bytes with SWR classification: an L1 hit is split
555 /// into fresh vs stale against the configured freshness window; on L1
556 /// miss this defers to [`Self::get_bytes`] (L2 + backfill), whose hit is
557 /// always fresh.
558 async fn get_bytes_swr(&self, key: &str) -> Result<SwrRead<Bytes>, CachekitError> {
559 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
560 if self.swr_enabled {
561 if let Some(ref l1) = self.l1 {
562 let full_key = self.resolve_key(key)?;
563 match l1.get_with_swr_shared(&full_key, self.swr_threshold_ratio) {
564 crate::l1::SharedSwrRead::Fresh(bytes) => {
565 self.counters.record(ReadOutcome::L1Hit, &full_key);
566 self.check_payload_size(bytes.len())?;
567 return Ok(SwrRead::Fresh(bytes));
568 }
569 crate::l1::SharedSwrRead::Stale(_) => {
570 // Token capture and the stale snapshot must be atomic
571 // relative to explicit mutations. Re-check after
572 // taking the per-key guard; a write may have landed
573 // between the optimistic classification and here.
574 let mutation = self.mutations.lock(&full_key).await;
575 match l1.get_with_swr_shared(&full_key, self.swr_threshold_ratio) {
576 crate::l1::SharedSwrRead::Fresh(bytes) => {
577 self.counters.record(ReadOutcome::L1Hit, &full_key);
578 self.check_payload_size(bytes.len())?;
579 return Ok(SwrRead::Fresh(bytes));
580 }
581 crate::l1::SharedSwrRead::Stale(bytes) => {
582 self.counters.record(ReadOutcome::L1Stale, &full_key);
583 self.check_payload_size(bytes.len())?;
584 let (state, version) = mutation.snapshot();
585 return Ok(SwrRead::Stale(bytes, SwrToken { state, version }));
586 }
587 crate::l1::SharedSwrRead::Miss => {}
588 }
589 }
590 // Absent or hard-expired: fall through to the normal
591 // read path (the redundant L1 re-check there is a cheap
592 // in-process miss).
593 crate::l1::SharedSwrRead::Miss => {}
594 }
595 }
596 }
597
598 Ok(match self.get_bytes(key).await? {
599 Some(bytes) => SwrRead::Fresh(bytes),
600 None => SwrRead::Miss,
601 })
602 }
603
604 /// Fetch raw payload bytes for `key` (L1, then L2 with L1 backfill).
605 async fn get_bytes(&self, key: &str) -> Result<Option<Bytes>, CachekitError> {
606 let full_key = self.resolve_key(key)?;
607
608 // L1 hit
609 #[cfg(feature = "l1")]
610 if let Some(bytes) = self.l1_get(&full_key) {
611 self.counters.record(ReadOutcome::L1Hit, &full_key);
612 self.check_payload_size(bytes.len())?;
613 return Ok(Some(bytes));
614 }
615
616 // Serialize an L2 read/backfill with same-key writes. Re-check L1
617 // after taking the lock because another operation may have filled it
618 // between the optimistic read above and lock acquisition.
619 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
620 let _mutation = self.lock_l1_mutation(&full_key).await;
621
622 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
623 if let Some(bytes) = self.l1_get(&full_key) {
624 self.counters.record(ReadOutcome::L1Hit, &full_key);
625 self.check_payload_size(bytes.len())?;
626 return Ok(Some(bytes));
627 }
628
629 // L2 backend
630 let (bytes, freshness) = match self.backend.get_with_freshness(&full_key).await? {
631 Some(hit) => {
632 self.counters.record(ReadOutcome::L2Hit, &full_key);
633 hit
634 }
635 None => {
636 self.counters.record(ReadOutcome::Miss, &full_key);
637 return Ok(None);
638 }
639 };
640
641 self.check_payload_size(bytes.len())?;
642 // Takes over the Vec's allocation: no copy, and L1 shares it below.
643 let bytes = Bytes::from(bytes);
644
645 // Populate L1 on L2 hit, bounded by the cap and the server's freshness
646 #[cfg(feature = "l1")]
647 self.l1_backfill(&full_key, bytes.clone(), freshness);
648 #[cfg(not(feature = "l1"))]
649 let _ = freshness;
650
651 Ok(Some(bytes))
652 }
653
654 /// Serialize and store `value` under `key` using the client's default TTL.
655 ///
656 /// Encrypts first on a client with encryption configured — see
657 /// [`Self::set_with_ttl`].
658 pub async fn set<T: Serialize>(&self, key: &str, value: &T) -> Result<(), CachekitError> {
659 self.set_with_ttl(key, value, self.default_ttl).await
660 }
661
662 /// Serialize and store `value` under `key` with an explicit `ttl`.
663 ///
664 /// On a client with encryption configured the value is encrypted first:
665 /// the backend and L1 receive only the AES-256-GCM ciphertext, and the
666 /// payload limit applies to the ciphertext.
667 ///
668 /// A positive sub-second `ttl` is sent to the backend as 1 second.
669 ///
670 /// Returns [`CachekitError::Config`] if `ttl` is zero.
671 ///
672 /// The client namespace is not checked here: an interop key written on a
673 /// namespaced client lands under `{namespace}:{interop_key}`, which no
674 /// interop reader addresses — see [`Self::interop_get`].
675 pub async fn set_with_ttl<T: Serialize>(
676 &self,
677 key: &str,
678 value: &T,
679 ttl: Duration,
680 ) -> Result<(), CachekitError> {
681 #[cfg(feature = "encryption")]
682 if let Some(secure) = self.encrypting() {
683 return secure.set_with_ttl(key, value, ttl).await;
684 }
685 Self::validate_ttl(ttl)?;
686
687 let bytes = serializer::serialize(value)?;
688 self.check_payload_size(bytes.len())?;
689
690 let full_key = self.resolve_key(key)?;
691
692 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
693 let mutation = self.lock_l1_mutation(&full_key).await;
694
695 // Invalidate older refresh tokens before the first backend await, so
696 // cancellation cannot leave an applied/attempted write vulnerable to
697 // a stale background result.
698 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
699 if let Some(ref mutation) = mutation {
700 mutation.advance();
701 }
702
703 // L1 needs its own copy: the backend consumes the Vec.
704 #[cfg(feature = "l1")]
705 {
706 let l1_bytes = Bytes::copy_from_slice(&bytes);
707 self.backend.set(&full_key, bytes, Some(ttl)).await?;
708 self.l1_set(&full_key, l1_bytes, ttl);
709 }
710 #[cfg(not(feature = "l1"))]
711 {
712 self.backend.set(&full_key, bytes, Some(ttl)).await?;
713 }
714
715 crate::metrics::trace_write(&full_key, ttl);
716 Ok(())
717 }
718
719 /// Commit an SWR refresh only if its stale-read token is still current,
720 /// encrypted when the client has encryption configured. Macro plumbing;
721 /// ordinary writes use [`Self::set_with_ttl`].
722 #[doc(hidden)]
723 pub async fn __complete_swr_refresh<T: Serialize>(
724 &self,
725 key: &str,
726 value: &T,
727 ttl: Duration,
728 token: SwrToken,
729 ) -> Result<bool, CachekitError> {
730 #[cfg(feature = "encryption")]
731 if let Some(secure) = self.encrypting() {
732 return secure.__complete_swr_refresh(key, value, ttl, token).await;
733 }
734 let bytes = serializer::serialize(value)?;
735 self.complete_swr_bytes(key, bytes, ttl, token).await
736 }
737
738 /// Delete `key` and return `true` if it existed.
739 ///
740 /// `CachekitIO` and `WorkersCachekitIO` return `true` on every successful
741 /// delete, whether or not the key existed: the server does not report
742 /// existence on `DELETE`.
743 ///
744 /// Invalidates the L1 entry regardless of the backend result.
745 pub async fn delete(&self, key: &str) -> Result<bool, CachekitError> {
746 let full_key = self.resolve_key(key)?;
747
748 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
749 let mutation = self.lock_l1_mutation(&full_key).await;
750
751 // Invalidate before L1 changes or backend I/O so task cancellation
752 // cannot let an older refresh resurrect this key.
753 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
754 if let Some(ref mutation) = mutation {
755 mutation.advance();
756 }
757
758 // Invalidate L1 first so callers never read a stale value even if the
759 // backend delete fails partway through.
760 #[cfg(feature = "l1")]
761 self.l1_delete(&full_key);
762
763 let existed = self.backend.delete(&full_key).await?;
764 crate::metrics::trace_delete(&full_key, existed);
765 Ok(existed)
766 }
767
768 /// Return `true` if `key` exists without fetching the value.
769 ///
770 /// A live L1 entry answers without a backend call. That check does not
771 /// count as a use of the entry for L1 eviction; only reads do.
772 pub async fn exists(&self, key: &str) -> Result<bool, CachekitError> {
773 let full_key = self.resolve_key(key)?;
774
775 // Check L1 first — avoids a network round-trip for warm entries.
776 #[cfg(feature = "l1")]
777 if self.l1.as_ref().is_some_and(|l1| l1.contains(&full_key)) {
778 return Ok(true);
779 }
780
781 Ok(self.backend.exists(&full_key).await?)
782 }
783
784 // ── Observability ─────────────────────────────────────────────────────────
785
786 /// Live hit/miss counters for this client and all of its clones.
787 ///
788 /// Every value read — `get`, `interop_get`, their SWR variants, and the
789 /// [`SecureCache`] equivalents — is counted by where it was served from.
790 /// `exists` and reads that fail with a backend error are not counted. The
791 /// same counters feed the cachekit.io backends' `X-CacheKit-*` telemetry
792 /// headers, so for a backend used by this client alone, what you see here
793 /// is what the SaaS sees (a backend shared by several clients reports the
794 /// first client built over it — see [`Backend::attach_metrics`]).
795 ///
796 /// ```no_run
797 /// # fn example(cache: &cachekit::CacheKit) {
798 /// let stats = cache.stats();
799 /// let reads = stats.l1_hits + stats.l2_hits + stats.misses;
800 /// eprintln!("L1 hit rate {:.1}% over {reads} reads", stats.l1_hit_rate() * 100.0);
801 /// # }
802 /// ```
803 pub fn stats(&self) -> L1Stats {
804 self.counters.snapshot()
805 }
806
807 /// Number of entries currently held in L1, or `None` when L1 is disabled
808 /// (or the `l1` feature is off).
809 ///
810 /// Exact at the time of the call: moka's pending expirations and
811 /// evictions are applied first, which makes this a housekeeping call, not
812 /// a free counter read — poll it, don't put it on a hot path.
813 pub fn l1_entry_count(&self) -> Option<u64> {
814 #[cfg(feature = "l1")]
815 let count = self.l1.as_ref().map(crate::l1::L1Cache::entry_count);
816 #[cfg(not(feature = "l1"))]
817 let count = None;
818 count
819 }
820
821 /// Current circuit-breaker state, or `None` when this client has no
822 /// breaker (reliability disabled, or configured with
823 /// `circuit_breaker: None`).
824 ///
825 /// Reads the live state: an open circuit whose `open_timeout` has elapsed
826 /// reports [`CircuitState::HalfOpen`](crate::CircuitState::HalfOpen). With
827 /// the `tracing` feature every transition is also emitted on the
828 /// `cachekit::reliability` target — `warn` when the circuit opens, `info`
829 /// for half-open and closed.
830 #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
831 pub fn circuit_state(&self) -> Option<crate::reliability::CircuitState> {
832 self.breaker.as_ref().map(|breaker| breaker.state())
833 }
834
835 // ── Single-flight ─────────────────────────────────────────────────────────
836
837 /// Begin a cold-miss single-flight for `key` (see [`crate::flight`]).
838 ///
839 /// Call after a cache miss, before computing the value. Concurrent
840 /// in-process fills of the same key are collapsed to one; with the
841 /// `reliability` feature and a lock-capable backend (CachekitIO, Redis),
842 /// fills are also suppressed across processes via a distributed fill
843 /// lock. The `#[cachekit]` macro does this automatically.
844 ///
845 /// The key is namespaced like every cache operation but not validated —
846 /// this call is infallible; an invalid key simply fails later at the
847 /// actual cache operation.
848 pub async fn single_flight(&self, key: &str) -> crate::flight::SingleFlight {
849 let full_key = self.namespaced_key(key);
850 crate::flight::SingleFlight::acquire(&self.flight, &self.backend, &full_key).await
851 }
852
853 /// Begin the single-flight for an SWR background refresh, or `None` when
854 /// another worker already holds this key's in-process flight or
855 /// distributed fill lock, or the lock call fails. Unlike
856 /// [`Self::single_flight`] it never waits for another worker's fill.
857 /// Macro plumbing for `#[cachekit]` — not public API.
858 #[doc(hidden)]
859 pub async fn __refresh_flight(&self, key: &str) -> Option<crate::flight::SingleFlight> {
860 let full_key = self.namespaced_key(key);
861 crate::flight::SingleFlight::try_lead(&self.flight, &self.backend, &full_key).await
862 }
863
864 // ── Secure cache ─────────────────────────────────────────────────────────
865
866 /// Return a [`SecureCache`] handle that encrypts all values before storage.
867 ///
868 /// L1 stores **ciphertext** (not plaintext) to preserve the zero-knowledge
869 /// property across all cache layers.
870 ///
871 /// The handle encrypts exactly what this client's own value methods
872 /// already encrypt (see [Encryption](CacheKit#encryption)), and the two
873 /// read each other's entries. What it adds is the guarantee: code that
874 /// must never run unencrypted asks for the handle, which fails on a
875 /// client without encryption, and it carries the
876 /// [`previous_key_hits`](SecureCache::previous_key_hits) rotation signal.
877 ///
878 /// # Errors
879 /// Returns `CachekitError::Config` if no encryption layer is configured.
880 /// Configure encryption via [`CacheKitBuilder::encryption`] /
881 /// [`CacheKitBuilder::encryption_from_bytes`], or start from the
882 /// `CacheKit::secure(url, master_key_hex)` /
883 /// `CacheKit::secure_from_env(url)` preset (`redis` feature).
884 #[cfg(feature = "encryption")]
885 pub fn secure_cache(&self) -> Result<SecureCache<'_>, CachekitError> {
886 self.encrypting().ok_or_else(|| {
887 CachekitError::Config(
888 "encryption not configured: use the CacheKit::secure(url, master_key_hex) \
889 preset, CacheKit::secure_from_env(url) to read the hex key from \
890 CACHEKIT_MASTER_KEY, or call .encryption() on the builder"
891 .to_owned(),
892 )
893 })
894 }
895
896 /// The encrypting handle when encryption is configured. Every value
897 /// method routes through it first, so a configured key leaves no
898 /// plaintext path (protocol `spec/intent-presets.md` § Encryption
899 /// Activation, rule 1).
900 #[cfg(feature = "encryption")]
901 fn encrypting(&self) -> Option<SecureCache<'_>> {
902 self.encryption.as_deref().map(|encryption| SecureCache {
903 client: self,
904 encryption,
905 })
906 }
907
908 // ── Private helpers ───────────────────────────────────────────────────────
909
910 fn check_payload_size(&self, size: usize) -> Result<(), CachekitError> {
911 if size > self.max_payload_bytes {
912 return Err(CachekitError::PayloadTooLarge {
913 size,
914 limit: self.max_payload_bytes,
915 });
916 }
917 Ok(())
918 }
919}
920
921// ── SecureCache ──────────────────────────────────────────────────────────────
922
923/// Encrypted cache handle returned by [`CacheKit::secure_cache()`].
924///
925/// All values are serialized, then encrypted with AES-256-GCM before storage.
926/// L1 stores ciphertext to maintain zero-knowledge guarantees. The client's
927/// own value methods route through these same methods once encryption is
928/// configured, so the handle and the client read each other's entries.
929#[cfg(feature = "encryption")]
930pub struct SecureCache<'a> {
931 client: &'a CacheKit,
932 encryption: &'a crate::encryption::EncryptionLayer,
933}
934
935#[cfg(feature = "encryption")]
936impl std::fmt::Debug for SecureCache<'_> {
937 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
938 f.debug_struct("SecureCache")
939 .field("tenant_id", &self.encryption.tenant_id())
940 .finish()
941 }
942}
943
944#[cfg(feature = "encryption")]
945impl SecureCache<'_> {
946 /// Rotation drain signal; see
947 /// [`EncryptionLayer::previous_key_hits`](crate::EncryptionLayer::previous_key_hits).
948 pub fn previous_key_hits(&self) -> Vec<u64> {
949 self.encryption.previous_key_hits()
950 }
951
952 /// Whether AES is hardware-accelerated on this host (informational); see
953 /// [`EncryptionLayer::hardware_acceleration_enabled`](crate::EncryptionLayer::hardware_acceleration_enabled).
954 pub fn hardware_acceleration_enabled(&self) -> bool {
955 self.encryption.hardware_acceleration_enabled()
956 }
957
958 /// Encrypt and store `value` under `key` using the client's default TTL.
959 pub async fn set<T: Serialize>(&self, key: &str, value: &T) -> Result<(), CachekitError> {
960 self.set_with_ttl(key, value, self.client.default_ttl).await
961 }
962
963 /// Encrypt and store `value` under `key` with an explicit `ttl`.
964 ///
965 /// The client namespace is not checked here: an interop key written on a
966 /// namespaced client lands under `{namespace}:{interop_key}`, which no
967 /// interop reader addresses — see [`CacheKit::interop_get`].
968 pub async fn set_with_ttl<T: Serialize>(
969 &self,
970 key: &str,
971 value: &T,
972 ttl: Duration,
973 ) -> Result<(), CachekitError> {
974 CacheKit::validate_ttl(ttl)?;
975 let (full_key, ciphertext) = self.seal(key, value)?;
976 // Size-check what is actually persisted (nonce + ciphertext + tag).
977 // The get paths check the stored ciphertext length, so checking the
978 // plaintext here would let a value within 28 bytes of the limit write
979 // successfully and then fail EVERY subsequent read with PayloadTooLarge.
980 self.client.check_payload_size(ciphertext.len())?;
981
982 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
983 let mutation = self.client.lock_l1_mutation(&full_key).await;
984
985 // Match the plain write path: invalidate older refresh tokens before
986 // the first backend await, including on cancellation or failure.
987 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
988 if let Some(ref mutation) = mutation {
989 mutation.advance();
990 }
991
992 // L1 needs its own copy: the backend consumes the Vec.
993 #[cfg(feature = "l1")]
994 {
995 let l1_bytes = Bytes::copy_from_slice(&ciphertext);
996 self.client
997 .backend
998 .set(&full_key, ciphertext, Some(ttl))
999 .await?;
1000 self.client.l1_set(&full_key, l1_bytes, ttl);
1001 }
1002 #[cfg(not(feature = "l1"))]
1003 {
1004 self.client
1005 .backend
1006 .set(&full_key, ciphertext, Some(ttl))
1007 .await?;
1008 }
1009
1010 crate::metrics::trace_write(&full_key, ttl);
1011 Ok(())
1012 }
1013
1014 /// Commit an encrypted SWR refresh only if its stale-read token is still
1015 /// current. Macro plumbing; ordinary writes use [`Self::set_with_ttl`].
1016 #[doc(hidden)]
1017 pub async fn __complete_swr_refresh<T: Serialize>(
1018 &self,
1019 key: &str,
1020 value: &T,
1021 ttl: Duration,
1022 token: SwrToken,
1023 ) -> Result<bool, CachekitError> {
1024 let (_, ciphertext) = self.seal(key, value)?;
1025 self.client
1026 .complete_swr_bytes(key, ciphertext, ttl, token)
1027 .await
1028 }
1029
1030 /// Serialize and encrypt `value` for `key`, returning the key this client
1031 /// passes to its backend (`.namespace()` prefix included) with the
1032 /// ciphertext. Every write encrypts here, so the AAD binds that key and an
1033 /// entry copied to another namespace fails to decrypt.
1034 fn seal<T: Serialize>(&self, key: &str, value: &T) -> Result<(String, Vec<u8>), CachekitError> {
1035 let full_key = self.client.resolve_key(key)?;
1036 let plaintext = serializer::serialize(value)?;
1037 let ciphertext = self.encryption.encrypt(&plaintext, &full_key)?;
1038 Ok((full_key, ciphertext))
1039 }
1040
1041 /// Retrieve, decrypt, and deserialize a value stored under `key`.
1042 ///
1043 /// Checks L1 (which holds ciphertext) before the backend.
1044 ///
1045 /// # Errors
1046 ///
1047 /// Returns [`CachekitError::Encryption`] if the stored entry fails
1048 /// decryption. The key's L1 copy is dropped first and the backend entry
1049 /// is left in place, so the next read reaches the backend: a hit once the
1050 /// entry is replaced with ciphertext this client can decrypt, a miss once
1051 /// it expires or is deleted. A decrypted entry that does not decode as `T`
1052 /// is [`CachekitError::Serialization`] naming only `T`: the decoder's own
1053 /// message would quote the plaintext.
1054 pub async fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, CachekitError> {
1055 match self.get_plaintext(key).await? {
1056 Some(plaintext) => Ok(Some(redact_decode(serializer::deserialize(&plaintext))?)),
1057 None => Ok(None),
1058 }
1059 }
1060
1061 /// Retrieve, decrypt, and deserialize an interop-mode value stored under `key`.
1062 ///
1063 /// Identical to [`Self::get`] except the decrypted plaintext is decoded
1064 /// with [`crate::interop::deserialize`] — exactly one MessagePack document,
1065 /// trailing bytes rejected (interop/v1 spec MUST), as in [`Self::get`]. In interop mode the
1066 /// AES-GCM plaintext is the plain MessagePack value bytes, so the AAD
1067 /// (v0x03, `format="msgpack"`, `compressed="False"`) verifies cross-SDK
1068 /// unchanged.
1069 ///
1070 /// # Errors
1071 ///
1072 /// Returns [`CachekitError::Config`] on a namespace-prefixed client — see
1073 /// [`CacheKit::interop_get`] — and [`CachekitError::Encryption`] if the
1074 /// stored entry fails decryption, with the key's L1 copy dropped as for
1075 /// [`Self::get`].
1076 pub async fn interop_get<T: DeserializeOwned>(
1077 &self,
1078 key: &str,
1079 ) -> Result<Option<T>, CachekitError> {
1080 self.client.reject_namespaced_interop()?;
1081 match self.get_plaintext(key).await? {
1082 Some(plaintext) => Ok(Some(redact_decode(crate::interop::deserialize(
1083 &plaintext,
1084 ))?)),
1085 None => Ok(None),
1086 }
1087 }
1088
1089 /// Retrieve, decrypt, and deserialize an interop-mode value with SWR
1090 /// classification. The secure twin of [`CacheKit::interop_get_swr`]:
1091 /// staleness is judged on the L1 **ciphertext** entry (zero-knowledge is
1092 /// preserved — freshness metadata never exposes plaintext), then the
1093 /// value is decrypted and decoded per [`Self::interop_get`].
1094 ///
1095 /// # Errors
1096 ///
1097 /// Same as [`Self::interop_get`] — the secure path fails closed on every
1098 /// backend and decryption error.
1099 pub async fn interop_get_swr<T: DeserializeOwned>(
1100 &self,
1101 key: &str,
1102 ) -> Result<SwrRead<T>, CachekitError> {
1103 self.client.reject_namespaced_interop()?;
1104 match self.client.get_bytes_swr(key).await? {
1105 SwrRead::Fresh(ct) => Ok(SwrRead::Fresh(redact_decode(crate::interop::deserialize(
1106 &self.decrypt_or_evict(&ct, key)?,
1107 ))?)),
1108 SwrRead::Stale(ct, token) => Ok(SwrRead::Stale(
1109 redact_decode(crate::interop::deserialize(
1110 &self.decrypt_or_evict(&ct, key)?,
1111 ))?,
1112 token,
1113 )),
1114 SwrRead::Miss => Ok(SwrRead::Miss),
1115 }
1116 }
1117
1118 /// Fetch ciphertext (L1, then L2 with L1 backfill) and decrypt it.
1119 ///
1120 /// Ciphertext retrieval delegates to [`CacheKit::get_bytes`], which returns
1121 /// the stored bytes untransformed — for a secure cache exactly the AES-GCM
1122 /// ciphertext, so decrypt receives the same bytes the backend holds.
1123 async fn get_plaintext(&self, key: &str) -> Result<Option<Vec<u8>>, CachekitError> {
1124 match self.client.get_bytes(key).await? {
1125 Some(ciphertext) => Ok(Some(self.decrypt_or_evict(&ciphertext, key)?)),
1126 None => Ok(None),
1127 }
1128 }
1129
1130 /// Decrypt ciphertext read for `key` against the key this client passes to
1131 /// its backend (`.namespace()` prefix included); on failure, drop the key's L1 copy before propagating the
1132 /// error.
1133 ///
1134 /// An L2 hit is backfilled into L1 before it is decrypted, so an entry
1135 /// that fails authentication would otherwise keep failing from L1 after
1136 /// the backend entry is fixed or deleted. The backend entry itself is
1137 /// left untouched as evidence.
1138 fn decrypt_or_evict(&self, ciphertext: &[u8], key: &str) -> Result<Vec<u8>, CachekitError> {
1139 let full_key = self.client.resolve_key(key)?;
1140 self.encryption
1141 .decrypt(ciphertext, &full_key)
1142 .inspect_err(|_| {
1143 #[cfg(feature = "l1")]
1144 self.client.l1_delete(&full_key);
1145 })
1146 }
1147
1148 /// Delete an encrypted key. Behaves identically to [`CacheKit::delete`].
1149 pub async fn delete(&self, key: &str) -> Result<bool, CachekitError> {
1150 self.client.delete(key).await
1151 }
1152
1153 /// Check if an encrypted key exists. Behaves identically to [`CacheKit::exists`].
1154 pub async fn exists(&self, key: &str) -> Result<bool, CachekitError> {
1155 self.client.exists(key).await
1156 }
1157}
1158
1159/// Decoding decrypted plaintext keeps the `Serialization` class, which
1160/// `#[cachekit]` reads as a miss, but replaces the decoder's message: serde
1161/// quotes the offending value (`invalid type: string "…", expected struct
1162/// …`), and that value is the plaintext encryption exists to protect
1163/// (CWE-532).
1164#[cfg(feature = "encryption")]
1165fn redact_decode<T>(decoded: Result<T, CachekitError>) -> Result<T, CachekitError> {
1166 decoded.map_err(|e| match e {
1167 CachekitError::Serialization(_) => CachekitError::Serialization(format!(
1168 "decrypted entry does not decode as {}",
1169 std::any::type_name::<T>()
1170 )),
1171 other => other,
1172 })
1173}
1174
1175// ── CacheKitBuilder ───────────────────────────────────────────────────────────
1176
1177/// Fluent builder for [`CacheKit`].
1178#[derive(Default)]
1179#[must_use]
1180pub struct CacheKitBuilder {
1181 backend: Option<SharedBackend>,
1182 default_ttl: Option<Duration>,
1183 namespace: Option<String>,
1184 max_payload_bytes: Option<usize>,
1185
1186 #[cfg(feature = "l1")]
1187 l1_capacity: Option<usize>,
1188
1189 #[cfg(feature = "l1")]
1190 no_l1: bool,
1191
1192 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1193 swr_enabled: Option<bool>,
1194
1195 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1196 swr_threshold_ratio: Option<f64>,
1197
1198 // pub(crate): the `secure` preset's key-path tests read the layer.
1199 #[cfg(feature = "encryption")]
1200 pub(crate) encryption: Option<SharedEncryption>,
1201
1202 #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
1203 reliability: Option<crate::reliability::ReliabilityConfig>,
1204
1205 /// Retry deadlines for the backend `CacheKit::io` built; cleared by
1206 /// [`Self::backend`] so a replaced backend never inherits them.
1207 #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
1208 pub(crate) retry_deadlines: Option<crate::reliability::RetryDeadlines>,
1209}
1210
1211#[cfg(not(feature = "encryption"))]
1212fn encryption_feature_missing(what: &str) -> CachekitError {
1213 CachekitError::Config(format!(
1214 "{what} needs the `encryption` cargo feature, which this build of \
1215 cachekit-rs was compiled without; enable it to encrypt"
1216 ))
1217}
1218
1219impl CacheKitBuilder {
1220 /// Set the storage backend.
1221 pub fn backend(mut self, backend: SharedBackend) -> Self {
1222 self.backend = Some(backend);
1223 #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
1224 {
1225 self.retry_deadlines = None;
1226 }
1227 self
1228 }
1229
1230 /// Override the default TTL (used when no per-call TTL is specified).
1231 pub fn default_ttl(mut self, ttl: Duration) -> Self {
1232 self.default_ttl = Some(ttl);
1233 self
1234 }
1235
1236 /// Set a namespace prefix. All keys will be stored as `{namespace}:{key}`.
1237 pub fn namespace(mut self, ns: impl Into<String>) -> Self {
1238 self.namespace = Some(ns.into());
1239 self
1240 }
1241
1242 /// Set the maximum accepted payload size in bytes.
1243 pub fn max_payload_bytes(mut self, limit: usize) -> Self {
1244 self.max_payload_bytes = Some(limit);
1245 self
1246 }
1247
1248 /// Set the L1 cache capacity (max entries).
1249 #[cfg(feature = "l1")]
1250 pub fn l1_capacity(mut self, capacity: usize) -> Self {
1251 self.l1_capacity = Some(capacity);
1252 self
1253 }
1254
1255 /// Disable the L1 cache entirely.
1256 #[cfg(feature = "l1")]
1257 pub fn no_l1(mut self) -> Self {
1258 self.no_l1 = true;
1259 self
1260 }
1261
1262 /// Enable or disable L1 stale-while-revalidate (default: **enabled**,
1263 /// matching the Python and TypeScript SDKs).
1264 ///
1265 /// With SWR on, an L1 hit older than `swr_threshold_ratio` of its TTL is
1266 /// still served immediately, and the `#[cachekit]` macro schedules at
1267 /// most one background refresh (deduplicated on the
1268 /// [`CacheKit::single_flight`] locks, in-process and — on lock-capable
1269 /// backends — across processes). A refresh that finds either lock held,
1270 /// or whose lock call fails, stands down instead of waiting, and the
1271 /// stale value keeps being served. A hard-expired entry is never served:
1272 /// it falls through to a normal blocking miss.
1273 ///
1274 /// Native targets only: this knob does not exist on wasm32, under the
1275 /// `unsync` feature, or without `l1` — calling it there is a compile
1276 /// error rather than a silent no-op.
1277 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1278 pub fn swr_enabled(mut self, enabled: bool) -> Self {
1279 self.swr_enabled = Some(enabled);
1280 self
1281 }
1282
1283 /// Set the SWR freshness threshold as a fraction of each L1 entry's TTL
1284 /// (default: **0.5**, matching the Python and TypeScript SDKs).
1285 ///
1286 /// An entry is *fresh* until it has lived `ratio × TTL` (±10% jitter,
1287 /// drawn once when the entry is inserted, to de-synchronise refreshes
1288 /// across processes), then *stale* — served immediately with a background
1289 /// refresh — until hard expiry. Mirrors cachekit-py's
1290 /// `swr_threshold_ratio` semantics (elapsed-lifetime fraction). Must be in
1291 /// `(0.0, 1.0]`; validated at [`Self::build`].
1292 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1293 pub fn swr_threshold_ratio(mut self, ratio: f64) -> Self {
1294 self.swr_threshold_ratio = Some(ratio);
1295 self
1296 }
1297
1298 // Stubs for when the l1 feature is disabled — still compile cleanly.
1299 /// No-op: the `l1` feature is disabled, so there is no L1 to size.
1300 #[cfg(not(feature = "l1"))]
1301 pub fn l1_capacity(self, _capacity: usize) -> Self {
1302 self
1303 }
1304
1305 /// No-op: the `l1` feature is disabled, so L1 is already off.
1306 #[cfg(not(feature = "l1"))]
1307 pub fn no_l1(self) -> Self {
1308 self
1309 }
1310
1311 /// Wrap the backend in the reliability stack (retry with exponential
1312 /// backoff + jitter, circuit breaker, backpressure) — see
1313 /// [`crate::reliability`].
1314 ///
1315 /// Enabled by default with production settings by the `production`,
1316 /// `secure`, and `io` intent presets; off for `minimal` and for
1317 /// manually-built clients. To opt a preset out, pass
1318 /// [`ReliabilityConfig::disabled()`](crate::reliability::ReliabilityConfig::disabled)
1319 /// — a disabled config applies no wrapping at all.
1320 #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
1321 pub fn reliability(mut self, config: crate::reliability::ReliabilityConfig) -> Self {
1322 self.reliability = Some(config);
1323 self
1324 }
1325
1326 /// Configure encryption from raw master key bytes and tenant ID.
1327 ///
1328 /// The master key must be exactly 32 raw bytes; anything else is a
1329 /// [`CachekitError::Config`]. Hex-encoded keys go to [`Self::encryption`]
1330 /// (or [`Self::encryption_with_previous`] to rotate) — their ASCII bytes
1331 /// are 64 bytes long and rejected here.
1332 /// Keys are derived per-tenant via HKDF-SHA256.
1333 ///
1334 /// The built client encrypts every value read and write, not only those
1335 /// through the [`secure_cache()`](CacheKit::secure_cache) handle; see
1336 /// [Encryption](CacheKit#encryption). Calling any encryption method
1337 /// again replaces the earlier configuration.
1338 ///
1339 /// Without the `encryption` cargo feature this method, and every other
1340 /// builder encryption method, returns [`CachekitError::Config`]: a build
1341 /// that cannot encrypt never accepts a key.
1342 #[cfg(feature = "encryption")]
1343 pub fn encryption_from_bytes(
1344 mut self,
1345 master_key: &[u8],
1346 tenant_id: &str,
1347 ) -> Result<Self, CachekitError> {
1348 let layer = crate::encryption::EncryptionLayer::new(master_key, tenant_id)?;
1349 self.encryption = Some(SharedEncryption::new(layer));
1350 Ok(self)
1351 }
1352
1353 /// Configure encryption with decrypt-only previous master keys for
1354 /// key rotation.
1355 ///
1356 /// Writes encrypt under `master_key`; reads attempt it first, then each
1357 /// key in `previous_keys` sequentially (attempt order = slice order).
1358 /// At most 3 previous keys; supplying more is a config error, never
1359 /// truncated. See [`crate::encryption::EncryptionLayer::with_previous_keys`].
1360 ///
1361 /// Every key, current and previous, must be exactly 32 raw bytes. For
1362 /// hex keys, use the hex counterpart, [`Self::encryption_with_previous`].
1363 ///
1364 /// Encrypts every value read and write, as for
1365 /// [`Self::encryption_from_bytes`].
1366 #[cfg(feature = "encryption")]
1367 pub fn encryption_from_bytes_with_previous(
1368 mut self,
1369 master_key: &[u8],
1370 previous_keys: &[&[u8]],
1371 tenant_id: &str,
1372 ) -> Result<Self, CachekitError> {
1373 let layer = crate::encryption::EncryptionLayer::with_previous_keys(
1374 master_key,
1375 previous_keys,
1376 tenant_id,
1377 )?;
1378 self.encryption = Some(SharedEncryption::new(layer));
1379 Ok(self)
1380 }
1381
1382 /// Configure encryption from a hex-encoded master key string.
1383 ///
1384 /// Hex-decodes with the same decoder as [`CacheKit::from_env`] and the
1385 /// `secure` preset. The key must decode to at least 32 bytes; use exactly
1386 /// 32 (64 hex chars), the only length every SDK accepts.
1387 ///
1388 /// Encrypts every value read and write, as for
1389 /// [`Self::encryption_from_bytes`].
1390 #[cfg(feature = "encryption")]
1391 pub fn encryption(self, hex_key: &str, tenant_id: &str) -> Result<Self, CachekitError> {
1392 let bytes = crate::config::decode_master_key_hex(hex_key, "master key")?;
1393 self.encryption_from_hex_decoded(&bytes, &[], tenant_id)
1394 }
1395
1396 /// Configure encryption from hex-encoded keys, with decrypt-only previous
1397 /// master keys for key rotation.
1398 ///
1399 /// The hex counterpart of [`Self::encryption_from_bytes_with_previous`].
1400 /// Every key, current and previous, is decoded as for [`Self::encryption`]
1401 /// and must decode to at least 32 bytes, so a key longer than 32 bytes
1402 /// that [`Self::encryption`] accepted can be listed as a previous key
1403 /// under the tenant it was written for (the env variables always derive
1404 /// for tenant `"default"`). Use exactly 32 bytes (64 hex chars) for new
1405 /// keys, the only length every SDK accepts.
1406 ///
1407 /// Writes encrypt under `hex_key`; reads attempt it first, then each key
1408 /// in `previous_hex_keys` sequentially (attempt order = slice order).
1409 /// At most 3 previous keys, and the current key must not be among them;
1410 /// either violation is a [`CachekitError::Config`], never truncated.
1411 ///
1412 /// ```no_run
1413 /// # fn demo(backend: cachekit::client::SharedBackend, k2_hex: &str, k1_hex: &str)
1414 /// # -> Result<(), cachekit::CachekitError> {
1415 /// let cache = cachekit::CacheKit::builder()
1416 /// .backend(backend)
1417 /// .encryption_with_previous(k2_hex, &[k1_hex], "tenant-a")?
1418 /// .build()?;
1419 /// # Ok(()) }
1420 /// ```
1421 ///
1422 /// Encrypts every value read and write, as for
1423 /// [`Self::encryption_from_bytes`].
1424 #[cfg(feature = "encryption")]
1425 pub fn encryption_with_previous(
1426 self,
1427 hex_key: &str,
1428 previous_hex_keys: &[&str],
1429 tenant_id: &str,
1430 ) -> Result<Self, CachekitError> {
1431 let current = crate::config::decode_master_key_hex(hex_key, "master key")?;
1432 let previous = previous_hex_keys
1433 .iter()
1434 .enumerate()
1435 .map(|(i, key)| {
1436 crate::config::decode_master_key_hex(key, &format!("previous master key {i}"))
1437 })
1438 .collect::<Result<Vec<_>, _>>()?;
1439 let previous: Vec<&[u8]> = previous.iter().map(|key| key.as_slice()).collect();
1440 self.encryption_from_hex_decoded(¤t, &previous, tenant_id)
1441 }
1442
1443 /// Shared tail of the hex paths: keys already validated by
1444 /// `decode_master_key_hex`, so the `>= 32` floor applies, not the
1445 /// raw-bytes exactly-32 rule.
1446 #[cfg(feature = "encryption")]
1447 pub(crate) fn encryption_from_hex_decoded(
1448 mut self,
1449 master_key: &[u8],
1450 previous_keys: &[&[u8]],
1451 tenant_id: &str,
1452 ) -> Result<Self, CachekitError> {
1453 let layer = crate::encryption::EncryptionLayer::from_hex_decoded_keys(
1454 master_key,
1455 previous_keys,
1456 tenant_id,
1457 )?;
1458 self.encryption = Some(SharedEncryption::new(layer));
1459 Ok(self)
1460 }
1461
1462 // Without the `encryption` feature the builder methods still exist, so
1463 // feature-agnostic code compiles, but each one is an error: accepting a
1464 // key and building a plaintext client is the silent downgrade protocol
1465 // `spec/intent-presets.md` § Encryption Activation rule 1 forbids.
1466
1467 /// Always [`CachekitError::Config`]: this build has no `encryption`
1468 /// feature.
1469 #[cfg(not(feature = "encryption"))]
1470 pub fn encryption_from_bytes(
1471 self,
1472 _master_key: &[u8],
1473 _tenant_id: &str,
1474 ) -> Result<Self, CachekitError> {
1475 Err(encryption_feature_missing(".encryption_from_bytes()"))
1476 }
1477
1478 /// Always [`CachekitError::Config`]: this build has no `encryption`
1479 /// feature.
1480 #[cfg(not(feature = "encryption"))]
1481 pub fn encryption_from_bytes_with_previous(
1482 self,
1483 _master_key: &[u8],
1484 _previous_keys: &[&[u8]],
1485 _tenant_id: &str,
1486 ) -> Result<Self, CachekitError> {
1487 Err(encryption_feature_missing(
1488 ".encryption_from_bytes_with_previous()",
1489 ))
1490 }
1491
1492 /// Always [`CachekitError::Config`]: this build has no `encryption`
1493 /// feature.
1494 #[cfg(not(feature = "encryption"))]
1495 pub fn encryption(self, _hex_key: &str, _tenant_id: &str) -> Result<Self, CachekitError> {
1496 Err(encryption_feature_missing(".encryption()"))
1497 }
1498
1499 /// Always [`CachekitError::Config`]: this build has no `encryption`
1500 /// feature.
1501 #[cfg(not(feature = "encryption"))]
1502 pub fn encryption_with_previous(
1503 self,
1504 _hex_key: &str,
1505 _previous_hex_keys: &[&str],
1506 _tenant_id: &str,
1507 ) -> Result<Self, CachekitError> {
1508 Err(encryption_feature_missing(".encryption_with_previous()"))
1509 }
1510
1511 /// Finalise and build the [`CacheKit`] client.
1512 ///
1513 /// Returns an error if no backend was provided.
1514 pub fn build(self) -> Result<CacheKit, CachekitError> {
1515 let backend = self.backend.ok_or_else(|| {
1516 CachekitError::Config("a backend must be provided via .backend()".to_owned())
1517 })?;
1518
1519 // Validate namespace if provided
1520 if let Some(ref ns) = self.namespace {
1521 if ns.is_empty() {
1522 return Err(CachekitError::Config("namespace cannot be empty".into()));
1523 }
1524 if ns.len() > 255 {
1525 return Err(CachekitError::Config("namespace exceeds 255 bytes".into()));
1526 }
1527 if !ns.bytes().all(|b| (0x20..=0x7E).contains(&b)) {
1528 return Err(CachekitError::Config(
1529 "namespace must be ASCII printable".into(),
1530 ));
1531 }
1532 }
1533
1534 #[cfg(feature = "l1")]
1535 let l1 = if self.no_l1 {
1536 None
1537 } else {
1538 let capacity = self.l1_capacity.unwrap_or(1000);
1539 Some(crate::l1::L1Cache::new(capacity))
1540 };
1541
1542 // SWR defaults mirror the sibling SDKs: enabled, threshold ratio 0.5,
1543 // ratio validated in (0.0, 1.0] exactly like py's L1CacheConfig.
1544 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1545 let swr_threshold_ratio = {
1546 let ratio = self.swr_threshold_ratio.unwrap_or(0.5);
1547 if !(ratio > 0.0 && ratio <= 1.0) {
1548 return Err(CachekitError::Config(format!(
1549 "swr_threshold_ratio must be in (0.0, 1.0]; got {ratio}"
1550 )));
1551 }
1552 ratio
1553 };
1554
1555 // Hand the backend a live view of this client's read counters so the
1556 // SaaS telemetry headers report real numbers with no user plumbing.
1557 // Attached to the raw backend, before the reliability decorator, so
1558 // no forwarding is needed; a provider set on the backend's own
1559 // builder wins (see `Backend::attach_metrics`).
1560 #[cfg(feature = "l1")]
1561 let l1_enabled = l1.is_some();
1562 #[cfg(not(feature = "l1"))]
1563 let l1_enabled = false;
1564 let counters = Arc::new(CacheCounters::new(l1_enabled));
1565 backend.attach_metrics(counters.provider());
1566
1567 // Apply the reliability stack last so it decorates the final backend.
1568 // A disabled config is the documented opt-out: skip the (no-op)
1569 // decorator entirely. The layer check lives on ReliabilityConfig
1570 // itself so a future layer can't be missed here (this gate
1571 // shipped that exact bug once already).
1572 #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
1573 let (backend, breaker) = match self.reliability {
1574 Some(config) if !config.is_disabled() => {
1575 crate::reliability::wrap_reliable(backend, config, self.retry_deadlines)
1576 }
1577 _ => (backend, None),
1578 };
1579
1580 Ok(CacheKit {
1581 backend,
1582 default_ttl: self.default_ttl.unwrap_or(Duration::from_secs(300)),
1583 namespace: self.namespace,
1584 max_payload_bytes: self.max_payload_bytes.unwrap_or(5 * 1024 * 1024),
1585 flight: SharedFlight::default(),
1586 counters,
1587
1588 #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
1589 breaker,
1590
1591 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1592 mutations: SharedMutations::default(),
1593
1594 #[cfg(feature = "l1")]
1595 l1,
1596
1597 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1598 swr_enabled: self.swr_enabled.unwrap_or(true),
1599
1600 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1601 swr_threshold_ratio,
1602
1603 #[cfg(feature = "encryption")]
1604 encryption: self.encryption,
1605 })
1606 }
1607}
1608
1609#[cfg(all(test, feature = "l1"))]
1610mod backfill_ttl_tests {
1611 use std::time::Duration;
1612
1613 use super::l1_backfill_ttl;
1614 use crate::backend::Freshness;
1615
1616 const DEFAULT_TTL: Duration = Duration::from_secs(300);
1617
1618 fn fresh_for(secs: u64) -> Freshness {
1619 Freshness {
1620 is_stale: false,
1621 fresh_for: Some(Duration::from_secs(secs)),
1622 }
1623 }
1624
1625 /// (e) No header: today's `min(default_ttl, 30 s)`, unchanged.
1626 #[test]
1627 fn absent_header_keeps_the_30s_cap() {
1628 let absent = Freshness::default();
1629 assert_eq!(
1630 l1_backfill_ttl(DEFAULT_TTL, absent),
1631 Some(Duration::from_secs(30))
1632 );
1633 assert_eq!(
1634 l1_backfill_ttl(Duration::from_secs(10), absent),
1635 Some(Duration::from_secs(10))
1636 );
1637 }
1638
1639 #[test]
1640 fn fresh_for_bounds_but_never_lifts_the_cap() {
1641 assert_eq!(
1642 l1_backfill_ttl(DEFAULT_TTL, fresh_for(5)),
1643 Some(Duration::from_secs(5))
1644 );
1645 assert_eq!(
1646 l1_backfill_ttl(DEFAULT_TTL, fresh_for(3600)),
1647 Some(Duration::from_secs(30))
1648 );
1649 }
1650
1651 #[test]
1652 fn stale_or_zero_forbids_the_backfill() {
1653 assert_eq!(l1_backfill_ttl(DEFAULT_TTL, fresh_for(0)), None);
1654 let stale = |fresh_for| Freshness {
1655 is_stale: true,
1656 fresh_for,
1657 };
1658 assert_eq!(l1_backfill_ttl(DEFAULT_TTL, stale(None)), None);
1659 assert_eq!(
1660 l1_backfill_ttl(DEFAULT_TTL, stale(Some(Duration::from_secs(60)))),
1661 None
1662 );
1663 }
1664}
1665
1666// SWR staleness exists only on native builds with L1 (see tests/swr_tests.rs).
1667#[cfg(all(
1668 test,
1669 feature = "encryption",
1670 feature = "l1",
1671 not(feature = "unsync"),
1672 not(target_arch = "wasm32")
1673))]
1674#[allow(clippy::unwrap_used)] // test-only: a failed setup step should panic loudly
1675mod secure_l1_tests {
1676 use std::time::Duration;
1677
1678 use super::{CacheKit, SharedBackend, SwrRead};
1679 use crate::backend::MemoryBackend;
1680 use crate::error::CachekitError;
1681
1682 /// The Stale arm reads straight from L1, and every public write on an
1683 /// encrypted client stores decryptable ciphertext there, so the
1684 /// undecryptable bytes are planted in L1 directly.
1685 #[tokio::test]
1686 async fn secure_interop_get_swr_evicts_stale_l1_after_decrypt_failure() {
1687 let backend = MemoryBackend::default();
1688 let shared: SharedBackend = std::sync::Arc::new(backend.clone());
1689 let client = CacheKit::builder()
1690 .backend(shared)
1691 .l1_capacity(100)
1692 .swr_threshold_ratio(0.001)
1693 .encryption_from_bytes(&[7u8; 32], "test-tenant")
1694 .unwrap()
1695 .build()
1696 .unwrap();
1697 client.l1_set(
1698 "poisoned-stale",
1699 bytes::Bytes::from_static(b"not ciphertext"),
1700 Duration::from_secs(60),
1701 );
1702 // Real time, not tokio's paused clock: L1 ages entries by std
1703 // Instant. threshold = 0.001 × 60 s = 60 ms (±10%); hard expiry 60 s.
1704 tokio::time::sleep(Duration::from_millis(200)).await;
1705
1706 let secure = client.secure_cache().unwrap();
1707 let err = secure
1708 .interop_get_swr::<String>("poisoned-stale")
1709 .await
1710 .unwrap_err();
1711 assert!(matches!(err, CachekitError::Encryption(_)), "got: {err:?}");
1712 // Served from L1, so the entry had not hard-expired into the L2 path.
1713 let stats = client.stats();
1714 assert_eq!((stats.l1_hits, stats.l2_hits), (1, 0), "{stats:?}");
1715
1716 // Nothing in the backend: a surviving L1 copy would keep failing; an
1717 // evicted one yields a miss.
1718 assert!(backend.store.lock().await.is_empty());
1719 assert!(matches!(
1720 secure.interop_get_swr::<String>("poisoned-stale").await,
1721 Ok(SwrRead::Miss)
1722 ));
1723 }
1724}