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