Skip to main content

cachekit/
client.rs

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