cachekit/client.rs
1use std::sync::Arc;
2use std::time::Duration;
3
4use serde::{de::DeserializeOwned, Serialize};
5
6use crate::backend::Backend;
7use crate::error::CachekitError;
8use crate::metrics::{CacheCounters, L1Stats, ReadOutcome};
9use crate::serializer;
10
11// ── SharedBackend type alias ──────────────────────────────────────────────────
12
13/// Reference-counted pointer to a heap-allocated backend.
14///
15/// On native targets (without `unsync`) we require `Send + Sync` via `Arc`.
16/// On `wasm32` or with the `unsync` feature, `Rc` is used instead — the runtime
17/// is single-threaded so `Send` bounds are unnecessary.
18#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
19pub type SharedBackend = std::sync::Arc<dyn Backend>;
20
21/// Reference-counted pointer to a heap-allocated backend (`?Send` variant).
22#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
23pub type SharedBackend = std::rc::Rc<dyn Backend>;
24
25// ── SharedFlight type alias ──────────────────────────────────────────────────
26
27/// Reference-counted pointer to the single-flight map, so client clones share
28/// fill-dedup state (two clones racing a cold miss must collapse to one fill).
29#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
30type SharedFlight = std::sync::Arc<crate::flight::FlightMap>;
31
32#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
33type SharedFlight = std::rc::Rc<crate::flight::FlightMap>;
34
35/// Separate same-key ordering from single-flight: a refresh holds the flight
36/// lock while computing, then takes this lock only for its version-checked
37/// commit. Direct reads/writes/deletes take the same mutation lock.
38#[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
39type SharedMutations = std::sync::Arc<crate::flight::MutationMap>;
40
41// ── SharedEncryption type alias ──────────────────────────────────────────────
42
43/// Reference-counted pointer to the encryption layer.
44///
45/// On native targets (without `unsync`) `Arc` is used (requires `Sync`).
46/// On `wasm32` or with `unsync`, `Rc` is used — avoids the `!Sync` problem
47/// caused by `Cell<u64>` inside cachekit-core's nonce counter.
48#[cfg(all(
49 feature = "encryption",
50 not(any(target_arch = "wasm32", feature = "unsync"))
51))]
52type SharedEncryption = std::sync::Arc<crate::encryption::EncryptionLayer>;
53
54#[cfg(all(
55 feature = "encryption",
56 any(target_arch = "wasm32", feature = "unsync")
57))]
58type SharedEncryption = std::rc::Rc<crate::encryption::EncryptionLayer>;
59
60// ── Key validation ────────────────────────────────────────────────────────────
61
62const MAX_KEY_BYTES: usize = 1024;
63
64/// Maximum TTL for L1 entries populated from L2 cache hits.
65/// Uses a short ceiling to limit staleness when the original TTL is unknown.
66///
67/// Reconciliation with stale-while-revalidate: a backfilled entry's SWR
68/// freshness window derives from this capped TTL (window = ratio × entry
69/// TTL), **not** from the write-path TTL — the cap is the staleness bound
70/// for L2-derived data, deliberately kept. SWR removes the cap's expiry
71/// cliff instead: past ~ratio × 30 s the entry is served stale while one
72/// background refresh re-executes the origin. If no newer mutation replaced
73/// the entry, that refresh writes both layers and renews L1 hard expiry with
74/// the caller's full TTL. Without SWR the entry simply hard-expires at the cap
75/// and the next read blocks on L2, as before.
76const L1_BACKFILL_TTL_SECS: u64 = 30;
77
78fn validate_key(key: &str) -> Result<(), CachekitError> {
79 if key.is_empty() {
80 return Err(CachekitError::InvalidKey(
81 "key must not be empty".to_owned(),
82 ));
83 }
84 if key.len() > MAX_KEY_BYTES {
85 return Err(CachekitError::InvalidKey(format!(
86 "key is {} bytes (limit: {MAX_KEY_BYTES})",
87 key.len()
88 )));
89 }
90 for b in key.bytes() {
91 if b < 0x20 || b == 0x7F {
92 return Err(CachekitError::InvalidKey(format!(
93 "key contains illegal control character 0x{b:02X}"
94 )));
95 }
96 }
97 Ok(())
98}
99
100// ── Stale-while-revalidate ───────────────────────────────────────────────────
101//
102// SWR needs an L1 to age entries in and a spawnable (`Send`) runtime for the
103// background refresh — native, non-`unsync` targets with the `l1` feature.
104// Everywhere else the SWR read path degrades to the plain read path and
105// `SwrRead::Stale` is never produced.
106
107/// Outcome of an SWR-aware typed read — see [`CacheKit::interop_get_swr`].
108#[derive(Debug, Clone, PartialEq)]
109pub enum SwrRead<T> {
110 /// Cache hit within the freshness window (or an L2 hit): use directly.
111 Fresh(T),
112 /// L1 hit past the freshness threshold but before hard expiry: use the
113 /// value now, and schedule a background refresh (the `#[cachekit]` macro
114 /// does this via [`CacheKit::single_flight`] + re-execution). The token
115 /// makes refresh completion conditional: a newer set or delete wins.
116 Stale(T, SwrToken),
117 /// No usable entry: fall through to a normal blocking miss + fill.
118 Miss,
119}
120
121/// Mutation version captured by an SWR stale read.
122///
123/// Pass this back only through the `#[cachekit]`-generated refresh path. It
124/// prevents an older background computation from overwriting a newer write
125/// or resurrecting a deleted entry on this client or any of its clones.
126#[derive(Clone)]
127pub struct SwrToken {
128 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
129 state: std::sync::Arc<crate::flight::MutationState>,
130 version: u64,
131}
132
133impl std::fmt::Debug for SwrToken {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 f.debug_struct("SwrToken")
136 .field("version", &self.version)
137 .finish_non_exhaustive()
138 }
139}
140
141impl PartialEq for SwrToken {
142 fn eq(&self, other: &Self) -> bool {
143 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
144 {
145 self.version == other.version && std::sync::Arc::ptr_eq(&self.state, &other.state)
146 }
147 #[cfg(not(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32"))))]
148 {
149 self.version == other.version
150 }
151 }
152}
153
154impl Eq for SwrToken {}
155
156// ── CacheKit ─────────────────────────────────────────────────────────────────
157
158/// Cache client with optional L1 in-process cache layer.
159///
160/// `Clone` is cheap and shares everything: backend, L1 cache, single-flight
161/// state, and encryption layer. Clones exist so `'static` background work
162/// (e.g. the SWR refresh spawned by `#[cachekit]`) can hold the client
163/// without borrowing it.
164#[derive(Clone)]
165pub struct CacheKit {
166 backend: SharedBackend,
167 default_ttl: Duration,
168 namespace: Option<String>,
169 max_payload_bytes: usize,
170 flight: SharedFlight,
171 /// Read counters behind [`Self::stats`]; the backend holds a
172 /// `MetricsProvider` over the same allocation.
173 counters: Arc<CacheCounters>,
174
175 /// The reliability stack's breaker, when configured — kept so
176 /// [`Self::circuit_state`] can read it through the type-erased backend.
177 #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
178 breaker: Option<Arc<crate::reliability::CircuitBreaker>>,
179
180 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
181 mutations: SharedMutations,
182
183 #[cfg(feature = "l1")]
184 pub(crate) l1: Option<crate::l1::L1Cache>,
185
186 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
187 pub(crate) swr_enabled: bool,
188
189 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
190 swr_threshold_ratio: f64,
191
192 #[cfg(feature = "encryption")]
193 encryption: Option<SharedEncryption>,
194}
195
196impl CacheKit {
197 /// Create a new builder.
198 pub fn builder() -> CacheKitBuilder {
199 CacheKitBuilder::default()
200 }
201
202 /// Build from environment variables via [`crate::config::CachekitConfig::from_env`].
203 ///
204 /// Creates a [`crate::backend::cachekitio::CachekitIO`] backend from the
205 /// config. Requires the `cachekitio` feature.
206 #[cfg(all(feature = "cachekitio", not(target_arch = "wasm32")))]
207 pub fn from_env() -> Result<CacheKitBuilder, CachekitError> {
208 use crate::backend::cachekitio::CachekitIO;
209 use crate::config::CachekitConfig;
210
211 let config = CachekitConfig::from_env()?;
212
213 let api_key_z = config
214 .api_key
215 .ok_or_else(|| CachekitError::Config("CACHEKIT_API_KEY is required".to_owned()))?;
216
217 let backend = CachekitIO::builder()
218 .api_key(api_key_z.as_str())
219 .api_url(config.api_url)
220 .build()
221 .map_err(|e| CachekitError::Config(e.to_string()))?;
222
223 #[cfg(not(feature = "unsync"))]
224 let shared: SharedBackend = std::sync::Arc::new(backend);
225 #[cfg(feature = "unsync")]
226 let shared: SharedBackend = std::rc::Rc::new(backend);
227
228 let mut builder = CacheKitBuilder::default()
229 .backend(shared)
230 .default_ttl(config.default_ttl)
231 .max_payload_bytes(config.max_payload_bytes)
232 .l1_capacity(config.l1_capacity);
233
234 if let Some(ns) = config.namespace.clone() {
235 builder = builder.namespace(ns);
236 }
237
238 // Wire up encryption if master key is configured
239 #[cfg(feature = "encryption")]
240 if let Some(ref master_key) = config.master_key {
241 let namespace = config.namespace.as_deref().unwrap_or("default");
242 let previous: Vec<&[u8]> = config
243 .previous_master_keys
244 .iter()
245 .map(|key| key.as_slice())
246 .collect();
247 builder =
248 builder.encryption_from_bytes_with_previous(master_key, &previous, namespace)?;
249 }
250
251 Ok(builder)
252 }
253
254 // ── Namespacing ───────────────────────────────────────────────────────────
255
256 fn namespaced_key(&self, key: &str) -> String {
257 match &self.namespace {
258 Some(ns) => format!("{ns}:{key}"),
259 None => key.to_owned(),
260 }
261 }
262
263 /// Validate key and return the namespaced version.
264 fn resolve_key(&self, key: &str) -> Result<String, CachekitError> {
265 validate_key(key)?;
266 Ok(self.namespaced_key(key))
267 }
268
269 // ── L1 helpers ───────────────────────────────────────────────────────────
270
271 /// Try L1 cache first. Returns Some(bytes) on hit.
272 #[cfg(feature = "l1")]
273 fn l1_get(&self, full_key: &str) -> Option<Vec<u8>> {
274 self.l1.as_ref().and_then(|l1| l1.get(full_key))
275 }
276
277 /// Populate L1 from an L2 hit with capped TTL to limit staleness.
278 #[cfg(feature = "l1")]
279 fn l1_backfill(&self, full_key: &str, bytes: &[u8]) {
280 if let Some(ref l1) = self.l1 {
281 let l1_ttl = std::cmp::min(self.default_ttl, Duration::from_secs(L1_BACKFILL_TTL_SECS));
282 l1.set(full_key, bytes, l1_ttl);
283 }
284 }
285
286 /// Write-through to L1.
287 #[cfg(feature = "l1")]
288 fn l1_set(&self, full_key: &str, bytes: &[u8], ttl: Duration) {
289 if let Some(ref l1) = self.l1 {
290 l1.set(full_key, bytes, ttl);
291 }
292 }
293
294 /// Invalidate L1 entry.
295 #[cfg(feature = "l1")]
296 fn l1_delete(&self, full_key: &str) {
297 if let Some(ref l1) = self.l1 {
298 l1.delete(full_key);
299 }
300 }
301
302 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
303 async fn lock_l1_mutation(&self, full_key: &str) -> Option<crate::flight::MutationGuard> {
304 if self.l1.is_some() {
305 Some(self.mutations.lock(full_key).await)
306 } else {
307 None
308 }
309 }
310
311 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
312 async fn complete_swr_bytes(
313 &self,
314 key: &str,
315 bytes: Vec<u8>,
316 ttl: Duration,
317 token: SwrToken,
318 ) -> Result<bool, CachekitError> {
319 Self::validate_ttl(ttl)?;
320 self.check_payload_size(bytes.len())?;
321 let full_key = self.resolve_key(key)?;
322 let Some(mutation) = self.lock_l1_mutation(&full_key).await else {
323 return Ok(false);
324 };
325
326 // Check before touching L2. The mutation state lives independently of
327 // the moka entry, so hard expiry or capacity eviction does not look
328 // like an explicit set/delete and waste a valid origin result.
329 if !mutation.is_current(&token.state, token.version) {
330 return Ok(false);
331 }
332 let l1_bytes = bytes.clone();
333 self.backend.set(&full_key, bytes, Some(ttl)).await?;
334 self.l1_set(&full_key, &l1_bytes, ttl);
335 mutation.advance();
336 crate::metrics::trace_write(&full_key, ttl);
337 Ok(true)
338 }
339
340 #[cfg(not(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32"))))]
341 async fn complete_swr_bytes(
342 &self,
343 _key: &str,
344 _bytes: Vec<u8>,
345 _ttl: Duration,
346 _token: SwrToken,
347 ) -> Result<bool, CachekitError> {
348 // `SwrRead::Stale` is unreachable on this build, so there is no
349 // versioned entry a caller could legitimately complete.
350 Ok(false)
351 }
352
353 /// Validate TTL is at least 1 second.
354 fn validate_ttl(ttl: Duration) -> Result<(), CachekitError> {
355 if ttl < Duration::from_secs(1) {
356 return Err(CachekitError::Config(format!(
357 "TTL must be at least 1 second; got {ttl:?}"
358 )));
359 }
360 Ok(())
361 }
362
363 // ── Public operations ─────────────────────────────────────────────────────
364
365 /// Retrieve and deserialize a value stored under `key`.
366 ///
367 /// Returns `None` if the key does not exist.
368 /// Checks L1 cache before hitting the backend.
369 pub async fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, CachekitError> {
370 match self.get_bytes(key).await? {
371 Some(bytes) => Ok(Some(serializer::deserialize(&bytes)?)),
372 None => Ok(None),
373 }
374 }
375
376 /// Retrieve and deserialize an interop-mode value stored under `key`.
377 ///
378 /// Identical to [`Self::get`] except the payload is decoded with
379 /// [`crate::interop::deserialize`], which consumes exactly one MessagePack
380 /// document and rejects trailing bytes (interop/v1 spec MUST). A
381 /// Python-SDK-internal CK frame is rejected with a specific diagnostic
382 /// instead of silently decoding as the integer 67.
383 ///
384 /// Use with keys from [`crate::interop::interop_key`] on a client
385 /// **without** a namespace prefix. There is no interop-specific write
386 /// method: [`Self::set`] already writes plain MessagePack (no ByteStorage
387 /// envelope), which is the interop value format.
388 ///
389 /// # Errors
390 ///
391 /// Returns [`CachekitError::Config`] if the client was built with
392 /// [`CacheKitBuilder::namespace`]: the prefix
393 /// would rewrite the storage key to `{prefix}:{interop_key}`, which no
394 /// other SDK computes — every cross-SDK entry would silently miss. Interop
395 /// keys carry their own namespace segment; failing loudly here beats a
396 /// 100% miss rate that looks like a cold cache.
397 pub async fn interop_get<T: DeserializeOwned>(
398 &self,
399 key: &str,
400 ) -> Result<Option<T>, CachekitError> {
401 self.reject_namespaced_interop()?;
402 match self.get_bytes(key).await? {
403 Some(bytes) => Ok(Some(crate::interop::deserialize(&bytes)?)),
404 None => Ok(None),
405 }
406 }
407
408 /// Interop keys must reach the backend verbatim; a client namespace prefix
409 /// would silently produce storage keys no other SDK computes.
410 fn reject_namespaced_interop(&self) -> Result<(), CachekitError> {
411 match self.namespace {
412 None => Ok(()),
413 Some(_) => Err(CachekitError::Config(
414 "interop reads require a client without a namespace prefix: .namespace() \
415 would store interop entries under {prefix}:{interop_key}, \
416 which other SDKs never compute (interop keys already carry a namespace \
417 segment) — use a dedicated non-namespaced client for interop entries"
418 .to_owned(),
419 )),
420 }
421 }
422
423 /// Retrieve and deserialize an interop-mode value with SWR classification.
424 ///
425 /// Identical to [`Self::interop_get`] except an L1 hit is classified
426 /// against the client's stale-while-revalidate freshness window:
427 ///
428 /// - [`SwrRead::Fresh`] — L1 hit within `swr_threshold_ratio` of the
429 /// entry's TTL (±10% jitter), or any L2 hit. Use directly.
430 /// - [`SwrRead::Stale`] — L1 hit past the threshold but **before hard
431 /// expiry**: the value is returned without touching the backend or
432 /// origin, and the caller should schedule exactly one background
433 /// refresh (dedup via [`Self::single_flight`] — this is what the
434 /// `#[cachekit]` macro generates). The accompanying [`SwrToken`] makes
435 /// completion conditional, so a newer set/delete always wins.
436 /// - [`SwrRead::Miss`] — nothing usable anywhere: normal blocking miss.
437 ///
438 /// A hard-expired L1 entry is a [`SwrRead::Miss`], never `Stale` — moka
439 /// drops entries at their TTL, so SWR cannot serve past hard expiry.
440 ///
441 /// With SWR disabled ([`CacheKitBuilder::swr_enabled`]`(false)`), without
442 /// the `l1` feature, on wasm32, or under `unsync`, this behaves exactly
443 /// like [`Self::interop_get`]: hits are `Fresh`, `Stale` is never
444 /// produced.
445 ///
446 /// # Errors
447 ///
448 /// Same as [`Self::interop_get`] (including the namespaced-client
449 /// rejection).
450 pub async fn interop_get_swr<T: DeserializeOwned>(
451 &self,
452 key: &str,
453 ) -> Result<SwrRead<T>, CachekitError> {
454 self.reject_namespaced_interop()?;
455 match self.get_bytes_swr(key).await? {
456 SwrRead::Fresh(b) => Ok(SwrRead::Fresh(crate::interop::deserialize(&b)?)),
457 SwrRead::Stale(b, token) => Ok(SwrRead::Stale(crate::interop::deserialize(&b)?, token)),
458 SwrRead::Miss => Ok(SwrRead::Miss),
459 }
460 }
461
462 /// Fetch raw payload bytes with SWR classification: an L1 hit is split
463 /// into fresh vs stale against the configured freshness window; on L1
464 /// miss this defers to [`Self::get_bytes`] (L2 + backfill), whose hit is
465 /// always fresh.
466 async fn get_bytes_swr(&self, key: &str) -> Result<SwrRead<Vec<u8>>, CachekitError> {
467 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
468 if self.swr_enabled {
469 if let Some(ref l1) = self.l1 {
470 let full_key = self.resolve_key(key)?;
471 match l1.get_with_swr(&full_key, self.swr_threshold_ratio) {
472 crate::l1::L1SwrRead::Fresh(bytes) => {
473 self.counters.record(ReadOutcome::L1Hit, &full_key);
474 self.check_payload_size(bytes.len())?;
475 return Ok(SwrRead::Fresh(bytes));
476 }
477 crate::l1::L1SwrRead::Stale(_) => {
478 // Token capture and the stale snapshot must be atomic
479 // relative to explicit mutations. Re-check after
480 // taking the per-key guard; a write may have landed
481 // between the optimistic classification and here.
482 let mutation = self.mutations.lock(&full_key).await;
483 match l1.get_with_swr(&full_key, self.swr_threshold_ratio) {
484 crate::l1::L1SwrRead::Fresh(bytes) => {
485 self.counters.record(ReadOutcome::L1Hit, &full_key);
486 self.check_payload_size(bytes.len())?;
487 return Ok(SwrRead::Fresh(bytes));
488 }
489 crate::l1::L1SwrRead::Stale(bytes) => {
490 self.counters.record(ReadOutcome::L1Stale, &full_key);
491 self.check_payload_size(bytes.len())?;
492 let (state, version) = mutation.snapshot();
493 return Ok(SwrRead::Stale(bytes, SwrToken { state, version }));
494 }
495 crate::l1::L1SwrRead::Miss => {}
496 }
497 }
498 // Absent or hard-expired: fall through to the normal
499 // read path (the redundant L1 re-check there is a cheap
500 // in-process miss).
501 crate::l1::L1SwrRead::Miss => {}
502 }
503 }
504 }
505
506 Ok(match self.get_bytes(key).await? {
507 Some(bytes) => SwrRead::Fresh(bytes),
508 None => SwrRead::Miss,
509 })
510 }
511
512 /// Fetch raw payload bytes for `key` (L1, then L2 with L1 backfill).
513 async fn get_bytes(&self, key: &str) -> Result<Option<Vec<u8>>, CachekitError> {
514 let full_key = self.resolve_key(key)?;
515
516 // L1 hit
517 #[cfg(feature = "l1")]
518 if let Some(bytes) = self.l1_get(&full_key) {
519 self.counters.record(ReadOutcome::L1Hit, &full_key);
520 self.check_payload_size(bytes.len())?;
521 return Ok(Some(bytes));
522 }
523
524 // Serialize an L2 read/backfill with same-key writes. Re-check L1
525 // after taking the lock because another operation may have filled it
526 // between the optimistic read above and lock acquisition.
527 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
528 let _mutation = self.lock_l1_mutation(&full_key).await;
529
530 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
531 if let Some(bytes) = self.l1_get(&full_key) {
532 self.counters.record(ReadOutcome::L1Hit, &full_key);
533 self.check_payload_size(bytes.len())?;
534 return Ok(Some(bytes));
535 }
536
537 // L2 backend
538 let bytes = match self.backend.get(&full_key).await? {
539 Some(b) => {
540 self.counters.record(ReadOutcome::L2Hit, &full_key);
541 b
542 }
543 None => {
544 self.counters.record(ReadOutcome::Miss, &full_key);
545 return Ok(None);
546 }
547 };
548
549 self.check_payload_size(bytes.len())?;
550
551 // Populate L1 on L2 hit (capped TTL to limit staleness)
552 #[cfg(feature = "l1")]
553 self.l1_backfill(&full_key, &bytes);
554
555 Ok(Some(bytes))
556 }
557
558 /// Serialize and store `value` under `key` using the client's default TTL.
559 pub async fn set<T: Serialize>(&self, key: &str, value: &T) -> Result<(), CachekitError> {
560 self.set_with_ttl(key, value, self.default_ttl).await
561 }
562
563 /// Serialize and store `value` under `key` with an explicit `ttl`.
564 ///
565 /// Returns [`CachekitError::Config`] if `ttl` is less than 1 second.
566 pub async fn set_with_ttl<T: Serialize>(
567 &self,
568 key: &str,
569 value: &T,
570 ttl: Duration,
571 ) -> Result<(), CachekitError> {
572 Self::validate_ttl(ttl)?;
573
574 let bytes = serializer::serialize(value)?;
575 self.check_payload_size(bytes.len())?;
576
577 let full_key = self.resolve_key(key)?;
578
579 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
580 let mutation = self.lock_l1_mutation(&full_key).await;
581
582 // Invalidate older refresh tokens before the first backend await, so
583 // cancellation cannot leave an applied/attempted write vulnerable to
584 // a stale background result.
585 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
586 if let Some(ref mutation) = mutation {
587 mutation.advance();
588 }
589
590 // Only clone bytes when L1 needs a copy after the backend consumes them.
591 #[cfg(feature = "l1")]
592 {
593 let l1_bytes = bytes.clone();
594 self.backend.set(&full_key, bytes, Some(ttl)).await?;
595 self.l1_set(&full_key, &l1_bytes, ttl);
596 }
597 #[cfg(not(feature = "l1"))]
598 {
599 self.backend.set(&full_key, bytes, Some(ttl)).await?;
600 }
601
602 crate::metrics::trace_write(&full_key, ttl);
603 Ok(())
604 }
605
606 /// Commit an unencrypted SWR refresh only if its stale-read token is
607 /// still current. Macro plumbing; ordinary writes use [`Self::set_with_ttl`].
608 #[doc(hidden)]
609 pub async fn __complete_swr_refresh<T: Serialize>(
610 &self,
611 key: &str,
612 value: &T,
613 ttl: Duration,
614 token: SwrToken,
615 ) -> Result<bool, CachekitError> {
616 let bytes = serializer::serialize(value)?;
617 self.complete_swr_bytes(key, bytes, ttl, token).await
618 }
619
620 /// Delete `key` and return `true` if it existed.
621 ///
622 /// Invalidates the L1 entry regardless of the backend result.
623 pub async fn delete(&self, key: &str) -> Result<bool, CachekitError> {
624 let full_key = self.resolve_key(key)?;
625
626 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
627 let mutation = self.lock_l1_mutation(&full_key).await;
628
629 // Invalidate before L1 changes or backend I/O so task cancellation
630 // cannot let an older refresh resurrect this key.
631 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
632 if let Some(ref mutation) = mutation {
633 mutation.advance();
634 }
635
636 // Invalidate L1 first so callers never read a stale value even if the
637 // backend delete fails partway through.
638 #[cfg(feature = "l1")]
639 self.l1_delete(&full_key);
640
641 let existed = self.backend.delete(&full_key).await?;
642 crate::metrics::trace_delete(&full_key, existed);
643 Ok(existed)
644 }
645
646 /// Return `true` if `key` exists without fetching the value.
647 pub async fn exists(&self, key: &str) -> Result<bool, CachekitError> {
648 let full_key = self.resolve_key(key)?;
649
650 // Check L1 first — avoids a network round-trip for warm entries.
651 #[cfg(feature = "l1")]
652 if self.l1_get(&full_key).is_some() {
653 return Ok(true);
654 }
655
656 Ok(self.backend.exists(&full_key).await?)
657 }
658
659 // ── Observability ─────────────────────────────────────────────────────────
660
661 /// Live hit/miss counters for this client and all of its clones.
662 ///
663 /// Every value read — `get`, `interop_get`, their SWR variants, and the
664 /// [`SecureCache`] equivalents — is counted by where it was served from.
665 /// `exists` and reads that fail with a backend error are not counted. The
666 /// same counters feed the cachekit.io backends' `X-CacheKit-*` telemetry
667 /// headers, so for a backend used by this client alone, what you see here
668 /// is what the SaaS sees (a backend shared by several clients reports the
669 /// first client built over it — see [`Backend::attach_metrics`]).
670 ///
671 /// ```no_run
672 /// # fn example(cache: &cachekit::CacheKit) {
673 /// let stats = cache.stats();
674 /// let reads = stats.l1_hits + stats.l2_hits + stats.misses;
675 /// eprintln!("L1 hit rate {:.1}% over {reads} reads", stats.l1_hit_rate() * 100.0);
676 /// # }
677 /// ```
678 pub fn stats(&self) -> L1Stats {
679 self.counters.snapshot()
680 }
681
682 /// Number of entries currently held in L1, or `None` when L1 is disabled
683 /// (or the `l1` feature is off).
684 ///
685 /// Exact at the time of the call: moka's pending expirations and
686 /// evictions are applied first, which makes this a housekeeping call, not
687 /// a free counter read — poll it, don't put it on a hot path.
688 pub fn l1_entry_count(&self) -> Option<u64> {
689 #[cfg(feature = "l1")]
690 let count = self.l1.as_ref().map(crate::l1::L1Cache::entry_count);
691 #[cfg(not(feature = "l1"))]
692 let count = None;
693 count
694 }
695
696 /// Current circuit-breaker state, or `None` when this client has no
697 /// breaker (reliability disabled, or configured with
698 /// `circuit_breaker: None`).
699 ///
700 /// Reads the live state: an open circuit whose `open_timeout` has elapsed
701 /// reports [`CircuitState::HalfOpen`](crate::CircuitState::HalfOpen). With
702 /// the `tracing` feature every transition is also emitted on the
703 /// `cachekit::reliability` target — `warn` when the circuit opens, `info`
704 /// for half-open and closed.
705 #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
706 pub fn circuit_state(&self) -> Option<crate::reliability::CircuitState> {
707 self.breaker.as_ref().map(|breaker| breaker.state())
708 }
709
710 // ── Single-flight ─────────────────────────────────────────────────────────
711
712 /// Begin a cold-miss single-flight for `key` (see [`crate::flight`]).
713 ///
714 /// Call after a cache miss, before computing the value. Concurrent
715 /// in-process fills of the same key are collapsed to one; with the
716 /// `reliability` feature and a lock-capable backend (CachekitIO, Redis),
717 /// fills are also suppressed across processes via a distributed fill
718 /// lock. The `#[cachekit]` macro does this automatically.
719 ///
720 /// The key is namespaced like every cache operation but not validated —
721 /// this call is infallible; an invalid key simply fails later at the
722 /// actual cache operation.
723 pub async fn single_flight(&self, key: &str) -> crate::flight::SingleFlight {
724 let full_key = self.namespaced_key(key);
725 crate::flight::SingleFlight::acquire(&self.flight, &self.backend, &full_key).await
726 }
727
728 // ── Secure cache ─────────────────────────────────────────────────────────
729
730 /// Return a [`SecureCache`] handle that encrypts all values before storage.
731 ///
732 /// L1 stores **ciphertext** (not plaintext) to preserve the zero-knowledge
733 /// property across all cache layers.
734 ///
735 /// # Errors
736 /// Returns `CachekitError::Config` if no encryption layer is configured.
737 /// Configure encryption via [`CacheKitBuilder::encryption`] /
738 /// [`CacheKitBuilder::encryption_from_bytes`], or start from the
739 /// `CacheKit::secure(url, master_key_hex)` /
740 /// `CacheKit::secure_from_env(url)` preset (`redis` feature).
741 #[cfg(feature = "encryption")]
742 pub fn secure_cache(&self) -> Result<SecureCache<'_>, CachekitError> {
743 let enc = self.encryption.as_ref().ok_or_else(|| {
744 CachekitError::Config(
745 "encryption not configured: use the CacheKit::secure(url, master_key_hex) \
746 preset, CacheKit::secure_from_env(url) to read the hex key from \
747 CACHEKIT_MASTER_KEY, or call .encryption() on the builder"
748 .to_owned(),
749 )
750 })?;
751 Ok(SecureCache {
752 client: self,
753 encryption: enc,
754 })
755 }
756
757 // ── Private helpers ───────────────────────────────────────────────────────
758
759 fn check_payload_size(&self, size: usize) -> Result<(), CachekitError> {
760 if size > self.max_payload_bytes {
761 return Err(CachekitError::PayloadTooLarge {
762 size,
763 limit: self.max_payload_bytes,
764 });
765 }
766 Ok(())
767 }
768}
769
770// ── SecureCache ──────────────────────────────────────────────────────────────
771
772/// Encrypted cache handle returned by [`CacheKit::secure_cache()`].
773///
774/// All values are serialized, then encrypted with AES-256-GCM before storage.
775/// L1 stores ciphertext to maintain zero-knowledge guarantees.
776#[cfg(feature = "encryption")]
777pub struct SecureCache<'a> {
778 client: &'a CacheKit,
779 encryption: &'a crate::encryption::EncryptionLayer,
780}
781
782#[cfg(feature = "encryption")]
783impl std::fmt::Debug for SecureCache<'_> {
784 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
785 f.debug_struct("SecureCache")
786 .field("tenant_id", &self.encryption.tenant_id())
787 .finish()
788 }
789}
790
791#[cfg(feature = "encryption")]
792impl SecureCache<'_> {
793 /// Rotation drain signal; see
794 /// [`EncryptionLayer::previous_key_hits`](crate::EncryptionLayer::previous_key_hits).
795 pub fn previous_key_hits(&self) -> Vec<u64> {
796 self.encryption.previous_key_hits()
797 }
798
799 /// Whether AES is hardware-accelerated on this host (informational); see
800 /// [`EncryptionLayer::hardware_acceleration_enabled`](crate::EncryptionLayer::hardware_acceleration_enabled).
801 pub fn hardware_acceleration_enabled(&self) -> bool {
802 self.encryption.hardware_acceleration_enabled()
803 }
804
805 /// Encrypt and store `value` under `key` using the client's default TTL.
806 pub async fn set<T: Serialize>(&self, key: &str, value: &T) -> Result<(), CachekitError> {
807 self.set_with_ttl(key, value, self.client.default_ttl).await
808 }
809
810 /// Encrypt and store `value` under `key` with an explicit `ttl`.
811 pub async fn set_with_ttl<T: Serialize>(
812 &self,
813 key: &str,
814 value: &T,
815 ttl: Duration,
816 ) -> Result<(), CachekitError> {
817 CacheKit::validate_ttl(ttl)?;
818
819 // Serialize then encrypt
820 let plaintext = serializer::serialize(value)?;
821 let ciphertext = self.encryption.encrypt(&plaintext, key)?;
822 // Size-check what is actually persisted (nonce + ciphertext + tag).
823 // The get paths check the stored ciphertext length, so checking the
824 // plaintext here would let a value within 28 bytes of the limit write
825 // successfully and then fail EVERY subsequent read with PayloadTooLarge.
826 self.client.check_payload_size(ciphertext.len())?;
827
828 let full_key = self.client.resolve_key(key)?;
829
830 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
831 let mutation = self.client.lock_l1_mutation(&full_key).await;
832
833 // Match the plain write path: invalidate older refresh tokens before
834 // the first backend await, including on cancellation or failure.
835 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
836 if let Some(ref mutation) = mutation {
837 mutation.advance();
838 }
839
840 // Only clone when L1 needs a copy after the backend consumes the data.
841 #[cfg(feature = "l1")]
842 {
843 let l1_bytes = ciphertext.clone();
844 self.client
845 .backend
846 .set(&full_key, ciphertext, Some(ttl))
847 .await?;
848 self.client.l1_set(&full_key, &l1_bytes, ttl);
849 }
850 #[cfg(not(feature = "l1"))]
851 {
852 self.client
853 .backend
854 .set(&full_key, ciphertext, Some(ttl))
855 .await?;
856 }
857
858 crate::metrics::trace_write(&full_key, ttl);
859 Ok(())
860 }
861
862 /// Commit an encrypted SWR refresh only if its stale-read token is still
863 /// current. Macro plumbing; ordinary writes use [`Self::set_with_ttl`].
864 #[doc(hidden)]
865 pub async fn __complete_swr_refresh<T: Serialize>(
866 &self,
867 key: &str,
868 value: &T,
869 ttl: Duration,
870 token: SwrToken,
871 ) -> Result<bool, CachekitError> {
872 let plaintext = serializer::serialize(value)?;
873 let ciphertext = self.encryption.encrypt(&plaintext, key)?;
874 self.client
875 .complete_swr_bytes(key, ciphertext, ttl, token)
876 .await
877 }
878
879 /// Retrieve, decrypt, and deserialize a value stored under `key`.
880 ///
881 /// Checks L1 (which holds ciphertext) before the backend.
882 ///
883 /// # Errors
884 ///
885 /// Returns [`CachekitError::Encryption`] if the stored entry fails
886 /// decryption. The key's L1 copy is dropped first and the backend entry
887 /// is left in place, so the next read reaches the backend: a hit once the
888 /// entry is replaced with ciphertext this client can decrypt, a miss once
889 /// it expires or is deleted.
890 pub async fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, CachekitError> {
891 match self.get_plaintext(key).await? {
892 Some(plaintext) => Ok(Some(serializer::deserialize(&plaintext)?)),
893 None => Ok(None),
894 }
895 }
896
897 /// Retrieve, decrypt, and deserialize an interop-mode value stored under `key`.
898 ///
899 /// Identical to [`Self::get`] except the decrypted plaintext is decoded
900 /// with [`crate::interop::deserialize`] — exactly one MessagePack document,
901 /// trailing bytes rejected (interop/v1 spec MUST). In interop mode the
902 /// AES-GCM plaintext is the plain MessagePack value bytes, so the AAD
903 /// (v0x03, `format="msgpack"`, `compressed="False"`) verifies cross-SDK
904 /// unchanged.
905 ///
906 /// # Errors
907 ///
908 /// Returns [`CachekitError::Config`] on a namespace-prefixed client — see
909 /// [`CacheKit::interop_get`] — and [`CachekitError::Encryption`] if the
910 /// stored entry fails decryption, with the key's L1 copy dropped as for
911 /// [`Self::get`].
912 pub async fn interop_get<T: DeserializeOwned>(
913 &self,
914 key: &str,
915 ) -> Result<Option<T>, CachekitError> {
916 self.client.reject_namespaced_interop()?;
917 match self.get_plaintext(key).await? {
918 Some(plaintext) => Ok(Some(crate::interop::deserialize(&plaintext)?)),
919 None => Ok(None),
920 }
921 }
922
923 /// Retrieve, decrypt, and deserialize an interop-mode value with SWR
924 /// classification. The secure twin of [`CacheKit::interop_get_swr`]:
925 /// staleness is judged on the L1 **ciphertext** entry (zero-knowledge is
926 /// preserved — freshness metadata never exposes plaintext), then the
927 /// value is decrypted and decoded per [`Self::interop_get`].
928 ///
929 /// # Errors
930 ///
931 /// Same as [`Self::interop_get`] — the secure path fails closed on every
932 /// backend and decryption error.
933 pub async fn interop_get_swr<T: DeserializeOwned>(
934 &self,
935 key: &str,
936 ) -> Result<SwrRead<T>, CachekitError> {
937 self.client.reject_namespaced_interop()?;
938 match self.client.get_bytes_swr(key).await? {
939 SwrRead::Fresh(ct) => Ok(SwrRead::Fresh(crate::interop::deserialize(
940 &self.decrypt_or_evict(&ct, key)?,
941 )?)),
942 SwrRead::Stale(ct, token) => Ok(SwrRead::Stale(
943 crate::interop::deserialize(&self.decrypt_or_evict(&ct, key)?)?,
944 token,
945 )),
946 SwrRead::Miss => Ok(SwrRead::Miss),
947 }
948 }
949
950 /// Fetch ciphertext (L1, then L2 with L1 backfill) and decrypt it.
951 ///
952 /// Ciphertext retrieval delegates to [`CacheKit::get_bytes`], which returns
953 /// the stored bytes untransformed — for a secure cache exactly the AES-GCM
954 /// ciphertext, so decrypt receives the same bytes the backend holds.
955 async fn get_plaintext(&self, key: &str) -> Result<Option<Vec<u8>>, CachekitError> {
956 match self.client.get_bytes(key).await? {
957 Some(ciphertext) => Ok(Some(self.decrypt_or_evict(&ciphertext, key)?)),
958 None => Ok(None),
959 }
960 }
961
962 /// Decrypt ciphertext read for `key`; on failure, drop the key's L1 copy
963 /// before propagating the error.
964 ///
965 /// An L2 hit is backfilled into L1 before it is decrypted, so an entry
966 /// that fails authentication would otherwise keep failing from L1 after
967 /// the backend entry is fixed or deleted. The backend entry itself is
968 /// left untouched as evidence.
969 fn decrypt_or_evict(&self, ciphertext: &[u8], key: &str) -> Result<Vec<u8>, CachekitError> {
970 self.encryption.decrypt(ciphertext, key).inspect_err(|_| {
971 // The read that produced `ciphertext` already resolved this key,
972 // so `resolve_key` cannot fail here.
973 #[cfg(feature = "l1")]
974 if let Ok(full_key) = self.client.resolve_key(key) {
975 self.client.l1_delete(&full_key);
976 }
977 })
978 }
979
980 /// Delete an encrypted key. Behaves identically to [`CacheKit::delete`].
981 pub async fn delete(&self, key: &str) -> Result<bool, CachekitError> {
982 self.client.delete(key).await
983 }
984
985 /// Check if an encrypted key exists. Behaves identically to [`CacheKit::exists`].
986 pub async fn exists(&self, key: &str) -> Result<bool, CachekitError> {
987 self.client.exists(key).await
988 }
989}
990
991// ── CacheKitBuilder ───────────────────────────────────────────────────────────
992
993/// Fluent builder for [`CacheKit`].
994#[derive(Default)]
995#[must_use]
996pub struct CacheKitBuilder {
997 backend: Option<SharedBackend>,
998 default_ttl: Option<Duration>,
999 namespace: Option<String>,
1000 max_payload_bytes: Option<usize>,
1001
1002 #[cfg(feature = "l1")]
1003 l1_capacity: Option<usize>,
1004
1005 #[cfg(feature = "l1")]
1006 no_l1: bool,
1007
1008 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1009 swr_enabled: Option<bool>,
1010
1011 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1012 swr_threshold_ratio: Option<f64>,
1013
1014 // pub(crate): the `secure` preset's key-path tests read the layer.
1015 #[cfg(feature = "encryption")]
1016 pub(crate) encryption: Option<SharedEncryption>,
1017
1018 #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
1019 reliability: Option<crate::reliability::ReliabilityConfig>,
1020}
1021
1022impl CacheKitBuilder {
1023 /// Set the storage backend.
1024 pub fn backend(mut self, backend: SharedBackend) -> Self {
1025 self.backend = Some(backend);
1026 self
1027 }
1028
1029 /// Override the default TTL (used when no per-call TTL is specified).
1030 pub fn default_ttl(mut self, ttl: Duration) -> Self {
1031 self.default_ttl = Some(ttl);
1032 self
1033 }
1034
1035 /// Set a namespace prefix. All keys will be stored as `{namespace}:{key}`.
1036 pub fn namespace(mut self, ns: impl Into<String>) -> Self {
1037 self.namespace = Some(ns.into());
1038 self
1039 }
1040
1041 /// Set the maximum accepted payload size in bytes.
1042 pub fn max_payload_bytes(mut self, limit: usize) -> Self {
1043 self.max_payload_bytes = Some(limit);
1044 self
1045 }
1046
1047 /// Set the L1 cache capacity (max entries).
1048 #[cfg(feature = "l1")]
1049 pub fn l1_capacity(mut self, capacity: usize) -> Self {
1050 self.l1_capacity = Some(capacity);
1051 self
1052 }
1053
1054 /// Disable the L1 cache entirely.
1055 #[cfg(feature = "l1")]
1056 pub fn no_l1(mut self) -> Self {
1057 self.no_l1 = true;
1058 self
1059 }
1060
1061 /// Enable or disable L1 stale-while-revalidate (default: **enabled**,
1062 /// matching the Python and TypeScript SDKs).
1063 ///
1064 /// With SWR on, an L1 hit older than `swr_threshold_ratio` of its TTL is
1065 /// still served immediately, and the `#[cachekit]` macro schedules
1066 /// exactly one background refresh (deduplicated through
1067 /// [`CacheKit::single_flight`], in-process and — on lock-capable
1068 /// backends — across processes). A hard-expired entry is never served:
1069 /// it falls through to a normal blocking miss.
1070 ///
1071 /// Native targets only: this knob does not exist on wasm32, under the
1072 /// `unsync` feature, or without `l1` — calling it there is a compile
1073 /// error rather than a silent no-op.
1074 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1075 pub fn swr_enabled(mut self, enabled: bool) -> Self {
1076 self.swr_enabled = Some(enabled);
1077 self
1078 }
1079
1080 /// Set the SWR freshness threshold as a fraction of each L1 entry's TTL
1081 /// (default: **0.5**, matching the Python and TypeScript SDKs).
1082 ///
1083 /// An entry is *fresh* until it has lived `ratio × TTL` (±10% jitter,
1084 /// drawn once when the entry is inserted, to de-synchronise refreshes
1085 /// across processes), then *stale* — served immediately with a background
1086 /// refresh — until hard expiry. Mirrors cachekit-py's
1087 /// `swr_threshold_ratio` semantics (elapsed-lifetime fraction). Must be in
1088 /// `(0.0, 1.0]`; validated at [`Self::build`].
1089 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1090 pub fn swr_threshold_ratio(mut self, ratio: f64) -> Self {
1091 self.swr_threshold_ratio = Some(ratio);
1092 self
1093 }
1094
1095 // Stubs for when the l1 feature is disabled — still compile cleanly.
1096 #[cfg(not(feature = "l1"))]
1097 pub fn l1_capacity(self, _capacity: usize) -> Self {
1098 self
1099 }
1100
1101 #[cfg(not(feature = "l1"))]
1102 pub fn no_l1(self) -> Self {
1103 self
1104 }
1105
1106 /// Wrap the backend in the reliability stack (retry with exponential
1107 /// backoff + jitter, circuit breaker, backpressure) — see
1108 /// [`crate::reliability`].
1109 ///
1110 /// Enabled by default with production settings by the `production`,
1111 /// `secure`, and `io` intent presets; off for `minimal` and for
1112 /// manually-built clients. To opt a preset out, pass
1113 /// [`ReliabilityConfig::disabled()`](crate::reliability::ReliabilityConfig::disabled)
1114 /// — a disabled config applies no wrapping at all.
1115 #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
1116 pub fn reliability(mut self, config: crate::reliability::ReliabilityConfig) -> Self {
1117 self.reliability = Some(config);
1118 self
1119 }
1120
1121 /// Configure encryption from raw master key bytes and tenant ID.
1122 ///
1123 /// The master key must be at least 32 bytes.
1124 /// Keys are derived per-tenant via HKDF-SHA256.
1125 #[cfg(feature = "encryption")]
1126 pub fn encryption_from_bytes(
1127 mut self,
1128 master_key: &[u8],
1129 tenant_id: &str,
1130 ) -> Result<Self, CachekitError> {
1131 let layer = crate::encryption::EncryptionLayer::new(master_key, tenant_id)?;
1132 self.encryption = Some(SharedEncryption::new(layer));
1133 Ok(self)
1134 }
1135
1136 /// Configure encryption with decrypt-only previous master keys for
1137 /// key rotation.
1138 ///
1139 /// Writes encrypt under `master_key`; reads attempt it first, then each
1140 /// key in `previous_keys` sequentially (attempt order = slice order).
1141 /// At most 3 previous keys; supplying more is a config error, never
1142 /// truncated. See [`crate::encryption::EncryptionLayer::with_previous_keys`].
1143 ///
1144 /// Every key, current and previous, must be at least 32 bytes.
1145 #[cfg(feature = "encryption")]
1146 pub fn encryption_from_bytes_with_previous(
1147 mut self,
1148 master_key: &[u8],
1149 previous_keys: &[&[u8]],
1150 tenant_id: &str,
1151 ) -> Result<Self, CachekitError> {
1152 let layer = crate::encryption::EncryptionLayer::with_previous_keys(
1153 master_key,
1154 previous_keys,
1155 tenant_id,
1156 )?;
1157 self.encryption = Some(SharedEncryption::new(layer));
1158 Ok(self)
1159 }
1160
1161 /// Configure encryption from a hex-encoded master key string.
1162 ///
1163 /// Convenience wrapper that hex-decodes (the same decoder as
1164 /// [`CacheKit::from_env`] and the `secure` preset) then delegates to
1165 /// [`Self::encryption_from_bytes`].
1166 #[cfg(feature = "encryption")]
1167 pub fn encryption(self, hex_key: &str, tenant_id: &str) -> Result<Self, CachekitError> {
1168 let bytes = crate::config::decode_master_key_hex(hex_key, "master key")?;
1169 self.encryption_from_bytes(&bytes, tenant_id)
1170 }
1171
1172 // Stub for when encryption feature is disabled.
1173 #[cfg(not(feature = "encryption"))]
1174 pub fn encryption_from_bytes(
1175 self,
1176 _master_key: &[u8],
1177 _tenant_id: &str,
1178 ) -> Result<Self, CachekitError> {
1179 Ok(self)
1180 }
1181
1182 #[cfg(not(feature = "encryption"))]
1183 pub fn encryption_from_bytes_with_previous(
1184 self,
1185 _master_key: &[u8],
1186 _previous_keys: &[&[u8]],
1187 _tenant_id: &str,
1188 ) -> Result<Self, CachekitError> {
1189 Ok(self)
1190 }
1191
1192 #[cfg(not(feature = "encryption"))]
1193 pub fn encryption(self, _hex_key: &str, _tenant_id: &str) -> Result<Self, CachekitError> {
1194 Ok(self)
1195 }
1196
1197 /// Finalise and build the [`CacheKit`] client.
1198 ///
1199 /// Returns an error if no backend was provided.
1200 pub fn build(self) -> Result<CacheKit, CachekitError> {
1201 let backend = self.backend.ok_or_else(|| {
1202 CachekitError::Config("a backend must be provided via .backend()".to_owned())
1203 })?;
1204
1205 // Validate namespace if provided
1206 if let Some(ref ns) = self.namespace {
1207 if ns.is_empty() {
1208 return Err(CachekitError::Config("namespace cannot be empty".into()));
1209 }
1210 if ns.len() > 255 {
1211 return Err(CachekitError::Config("namespace exceeds 255 bytes".into()));
1212 }
1213 if !ns.bytes().all(|b| (0x20..=0x7E).contains(&b)) {
1214 return Err(CachekitError::Config(
1215 "namespace must be ASCII printable".into(),
1216 ));
1217 }
1218 }
1219
1220 #[cfg(feature = "l1")]
1221 let l1 = if self.no_l1 {
1222 None
1223 } else {
1224 let capacity = self.l1_capacity.unwrap_or(1000);
1225 Some(crate::l1::L1Cache::new(capacity))
1226 };
1227
1228 // SWR defaults mirror the sibling SDKs: enabled, threshold ratio 0.5,
1229 // ratio validated in (0.0, 1.0] exactly like py's L1CacheConfig.
1230 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1231 let swr_threshold_ratio = {
1232 let ratio = self.swr_threshold_ratio.unwrap_or(0.5);
1233 if !(ratio > 0.0 && ratio <= 1.0) {
1234 return Err(CachekitError::Config(format!(
1235 "swr_threshold_ratio must be in (0.0, 1.0]; got {ratio}"
1236 )));
1237 }
1238 ratio
1239 };
1240
1241 // Hand the backend a live view of this client's read counters so the
1242 // SaaS telemetry headers report real numbers with no user plumbing.
1243 // Attached to the raw backend, before the reliability decorator, so
1244 // no forwarding is needed; a provider set on the backend's own
1245 // builder wins (see `Backend::attach_metrics`).
1246 #[cfg(feature = "l1")]
1247 let l1_enabled = l1.is_some();
1248 #[cfg(not(feature = "l1"))]
1249 let l1_enabled = false;
1250 let counters = Arc::new(CacheCounters::new(l1_enabled));
1251 backend.attach_metrics(counters.provider());
1252
1253 // Apply the reliability stack last so it decorates the final backend.
1254 // A disabled config is the documented opt-out: skip the (no-op)
1255 // decorator entirely. The layer check lives on ReliabilityConfig
1256 // itself so a future layer can't be missed here (this gate
1257 // shipped that exact bug once already).
1258 #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
1259 let (backend, breaker) = match self.reliability {
1260 Some(config) if !config.is_disabled() => {
1261 crate::reliability::wrap_reliable(backend, config)
1262 }
1263 _ => (backend, None),
1264 };
1265
1266 Ok(CacheKit {
1267 backend,
1268 default_ttl: self.default_ttl.unwrap_or(Duration::from_secs(300)),
1269 namespace: self.namespace,
1270 max_payload_bytes: self.max_payload_bytes.unwrap_or(5 * 1024 * 1024),
1271 flight: SharedFlight::default(),
1272 counters,
1273
1274 #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
1275 breaker,
1276
1277 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1278 mutations: SharedMutations::default(),
1279
1280 #[cfg(feature = "l1")]
1281 l1,
1282
1283 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1284 swr_enabled: self.swr_enabled.unwrap_or(true),
1285
1286 #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1287 swr_threshold_ratio,
1288
1289 #[cfg(feature = "encryption")]
1290 encryption: self.encryption,
1291 })
1292 }
1293}