Skip to main content

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}