Skip to main content

cachekit/
lib.rs

1//! CacheKit — caching for Rust.
2//!
3//! Supports cachekit.io SaaS, Redis, Memcached, local File, and Cloudflare
4//! Workers backends. Zero-knowledge encryption via AES-256-GCM with HKDF key
5//! derivation.
6//!
7//! # Getting started: intent presets
8//!
9//! The intent presets are the primary entry point — one call that names your
10//! use case and returns a pre-configured [`CacheKitBuilder`] you can still
11//! override before [`build()`](CacheKitBuilder::build):
12//!
13//! | Intent | Backend | L1 | Encryption | Auto-reconnect² | Reliability¹ | Default TTL |
14//! |------------|-----------|------|------------|-----------------|--------------|-------------|
15//! | `CacheKit::minimal`³ | Redis | On (no SWR) | No | No | Off | 300 s |
16//! | `CacheKit::production`³ | Redis | On | No | Yes | On | 600 s |
17//! | `CacheKit::secure`³ ⁵ | Redis | On | AES-256-GCM | Yes | On | 600 s |
18//! | [`io`](CacheKit::io)⁴ | cachekit.io | On | No | n/a (HTTP) | On | 3 600 s |
19//!
20//! ¹ Retry with backoff + jitter, a circuit breaker, and backpressure around
21//! backend ops — see [`reliability`]. Requires the default-on `reliability`
22//! cargo feature.
23//! ² `production`/`secure` re-establish a dropped Redis connection with
24//! exponential backoff (100 ms → 30 s, retrying indefinitely); `minimal` is
25//! fail-fast (a dropped connection stays dead). **Initial** connections fail
26//! fast for every Redis preset — `io` opens no connection at construction: an
27//! empty API key fails at construction, while an invalid key or unreachable
28//! endpoint surfaces at the first request.
29//! Auto-reconnect is connection-level repair, distinct from the per-operation
30//! reliability stack.
31//! ³ Requires the `redis` cargo feature; `secure` also needs the
32//! default-on `encryption` feature.
33//! ⁴ API key by argument, or from `CACHEKIT_API_KEY` via
34//! [`io_from_env`](CacheKit::io_from_env). Neither reads
35//! `CACHEKIT_MASTER_KEY`.
36//! ⁵ Master key as a hex string, `secure(url, master_key_hex)`, or from
37//! `CACHEKIT_MASTER_KEY` via `secure_from_env(url)` — the same hex decoding
38//! either way, so one key value derives the same key bytes in every CacheKit
39//! SDK. Use exactly 32 bytes (64 hex chars), the only length every SDK
40//! accepts. `secure_from_env` also reads decrypt-only rotation keys from
41//! `CACHEKIT_PREVIOUS_MASTER_KEYS`, validated as `from_env` validates them.
42//! A missing, non-hex or short key fails at construction, before any Redis
43//! I/O; `secure` never falls back to plaintext.
44//!
45//! **Which calls encrypt.** On a `secure` client — and on any client built
46//! with a builder encryption method, such as `io` plus
47//! [`.encryption()`](CacheKitBuilder::encryption) — every value read and
48//! write encrypts: plain [`get`](CacheKit::get), [`set`](CacheKit::set),
49//! [`set_with_ttl`](CacheKit::set_with_ttl),
50//! [`interop_get`](CacheKit::interop_get) and
51//! [`interop_get_swr`](CacheKit::interop_get_swr) behave exactly as the
52//! `SecureCache` handle from `cache.secure_cache()` does, and L1 holds
53//! ciphertext. The handle is optional: it fails on a client without
54//! encryption, for code that must never run unencrypted. A build without the
55//! `encryption` feature rejects every builder encryption call with
56//! [`CachekitError::Config`].
57//!
58//! No other client encrypts. `CACHEKIT_MASTER_KEY` is a key source, never a
59//! switch: after [`from_env`](CacheKit::from_env) with the variable set,
60//! chain [`.encryption_from_env()`](CacheKitBuilder::encryption_from_env) to
61//! encrypt with it or [`.no_encryption()`](CacheKitBuilder::no_encryption)
62//! for a plaintext client; stating neither is an error at `build()` (a
63//! transitional check, see [`from_env`](CacheKit::from_env)).
64//!
65//! ```no_run
66//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
67//! let cache = cachekit::CacheKit::io_from_env()?
68//!     .namespace("myapp")
69//!     .build()?;
70//!
71//! cache.set("greeting", &"Hello, world!").await?;
72//! let val: Option<String> = cache.get("greeting").await?;
73//! # Ok(())
74//! # }
75//! ```
76//!
77//! For full control, drop down to [`CacheKit::builder`] or
78//! [`CacheKit::from_env`].
79//!
80//! # Observability
81//!
82//! Every client counts its reads: [`CacheKit::stats`] answers "is my cache
83//! hitting?" (L1 hits / L2 hits / misses), [`CacheKit::l1_entry_count`]
84//! reports L1 occupancy, and with `reliability` on,
85//! [`CacheKit::circuit_state`] reports the breaker. The same counters feed the
86//! cachekit.io backends' `X-CacheKit-*` telemetry headers automatically. Enable
87//! the `tracing` cargo feature for a `debug` event per operation on the
88//! `cachekit` target (`op`, `outcome`, `key_hash` — never the key) and
89//! breaker transitions on `cachekit::reliability` — see [`metrics`].
90
91// Production code lints — these only fire in src/, not tests/
92#![warn(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
93#![warn(missing_docs)]
94
95// Mutually exclusive feature guards
96#[cfg(all(feature = "workers", feature = "redis"))]
97compile_error!(
98    "features `workers` and `redis` are mutually exclusive — Workers runtime cannot use fred"
99);
100
101#[cfg(all(feature = "workers", feature = "l1"))]
102compile_error!("features `workers` and `l1` are mutually exclusive — L1 reads `std::time::Instant`, which has no clock on wasm32-unknown-unknown, the Workers target");
103
104#[cfg(all(feature = "workers", feature = "reliability"))]
105compile_error!("features `workers` and `reliability` are mutually exclusive — retry/breaker timers need tokio `time`, unavailable in wasm32");
106
107#[cfg(all(feature = "workers", feature = "memcached"))]
108compile_error!("features `workers` and `memcached` are mutually exclusive — Workers runtime has no TCP sockets");
109
110#[cfg(all(feature = "workers", feature = "file"))]
111compile_error!(
112    "features `workers` and `file` are mutually exclusive — Workers runtime has no filesystem"
113);
114
115// Target guard: L1 reads `Instant::now()` on every insert and read.
116#[cfg(all(feature = "l1", target_arch = "wasm32", target_os = "unknown"))]
117compile_error!("feature `l1` is not supported on wasm32-unknown-unknown — std::time::Instant has no clock there, so the client's first L1 read or write would panic; set `default-features = false, features = [\"encryption\"]` plus your backend feature, leaving out `l1`");
118
119/// Pluggable cache backend trait and implementations (CachekitIO, Redis,
120/// Memcached, File, Workers).
121pub mod backend;
122/// High-level cache client with dual-layer (L1/L2) support.
123pub mod client;
124/// Configuration types and environment variable parsing.
125pub mod config;
126/// Error types for cache operations and backend communication.
127pub mod error;
128/// Cold-miss single-flight: dedup concurrent fills of the same key.
129pub mod flight;
130/// Interop mode (interop/v1): cross-SDK cache keys and plain-MessagePack values.
131pub mod interop;
132/// Live hit/miss counters, the SaaS telemetry headers built from them, and
133/// (feature `tracing`) structured events for cache operations.
134pub mod metrics;
135/// Serialization and deserialization of cached values via MessagePack.
136pub mod serializer;
137/// SDK session tracking (session ID and start timestamp).
138pub mod session;
139/// SSRF-safe URL validation for CachekitIO endpoints.
140pub mod url_validator;
141
142/// Intent-based cache presets (`CacheKit::minimal`, `::production`, `::secure`, `::io`).
143mod intents;
144
145/// Client-side AES-256-GCM encryption with HKDF key derivation.
146#[cfg(feature = "encryption")]
147pub mod encryption;
148
149/// In-process L1 cache with per-entry TTL, backed by [`quick_cache`].
150#[cfg(feature = "l1")]
151pub mod l1;
152
153/// Reliability tier: retry with backoff + jitter, circuit breaker.
154#[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
155pub mod reliability;
156
157// Re-exports
158pub use client::{CacheKit, CacheKitBuilder, SharedBackend, SwrRead, SwrToken};
159pub use config::CachekitConfig;
160pub use error::{BackendError, BackendErrorKind, CachekitError};
161pub use metrics::L1Stats;
162
163#[cfg(feature = "encryption")]
164pub use client::SecureCache;
165#[cfg(feature = "encryption")]
166pub use encryption::EncryptionLayer;
167
168#[cfg(feature = "macros")]
169pub use cachekit_macros::cachekit;
170
171pub use flight::SingleFlight;
172
173#[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
174pub use reliability::{
175    BackpressureConfig, CircuitBreakerConfig, CircuitState, ReliabilityConfig, RetryConfig,
176};
177
178// ── Shared jitter source ─────────────────────────────────────────────────────
179
180/// Uniform random in `[0, 1)`. Used by retry backoff (`reliability`) and the
181/// L1 SWR freshness threshold at entry insertion (`l1`). Jitter needs
182/// decorrelation across clients, not crypto quality, so this is a per-thread
183/// SplitMix64 seeded from std's `RandomState` instead of a getrandom syscall
184/// per call; 53 bits is plenty. Native only: wasm32 has no process ids
185/// (`std::process::id()` panics there), and `RandomState` carries no entropy
186/// on wasm32-unknown-unknown.
187#[cfg(all(
188    any(feature = "l1", feature = "reliability"),
189    not(target_arch = "wasm32")
190))]
191pub(crate) fn random_unit() -> f64 {
192    thread_local! {
193        static STATE: JitterState = const { std::cell::Cell::new(None) };
194    }
195    let z = STATE.with(|state| next_jitter(state, std::process::id()));
196    ((z >> 11) as f64) / ((1u64 << 53) as f64)
197}
198
199/// wasm32 variant (`l1` without `workers`, e.g. on WASI): one host-RNG draw per
200/// call through UUID v4. Nothing forks there, so the per-call draw is safe.
201#[cfg(all(feature = "l1", target_arch = "wasm32"))]
202pub(crate) fn random_unit() -> f64 {
203    let bits = uuid::Uuid::new_v4().as_u128() & ((1u128 << 53) - 1);
204    (bits as f64) / ((1u64 << 53) as f64)
205}
206
207/// The owning process id and SplitMix64 state behind [`random_unit`].
208#[cfg(all(
209    any(feature = "l1", feature = "reliability"),
210    not(target_arch = "wasm32")
211))]
212type JitterState = std::cell::Cell<Option<(u32, u64)>>;
213
214/// One SplitMix64 step. The state reseeds whenever `pid` differs from the one
215/// it was seeded under: a forked child inherits its parent's thread-local
216/// state, and without the reseed every prefork worker would replay the
217/// parent's sequence and synchronise refreshes and backoffs. The pid is hashed
218/// into the seed because siblings forked from one parent also inherit the same
219/// `RandomState` keys.
220#[cfg(all(
221    any(feature = "l1", feature = "reliability"),
222    not(target_arch = "wasm32")
223))]
224fn next_jitter(state: &JitterState, pid: u32) -> u64 {
225    use std::hash::{BuildHasher, Hasher};
226
227    let current = match state.get() {
228        Some((owner, current)) if owner == pid => current,
229        _ => {
230            let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
231            hasher.write_u32(pid);
232            hasher.finish()
233        }
234    };
235    let next = current.wrapping_add(0x9E37_79B9_7F4A_7C15);
236    state.set(Some((pid, next)));
237    let mut z = next;
238    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
239    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
240    z ^ (z >> 31)
241}
242
243// ── SWR background-refresh spawn (macro plumbing) ───────────────────────────
244
245/// Spawn a stale-while-revalidate background refresh onto the ambient tokio
246/// runtime. Macro plumbing for `#[cachekit]` — not public API.
247///
248/// Without a tokio runtime on the current thread the refresh is skipped: the
249/// caller has already been served the stale value, and a later stale read
250/// simply retries. Panicking here would turn a cache optimisation into an
251/// availability bug on non-tokio executors.
252#[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
253#[doc(hidden)]
254pub fn __swr_spawn<F>(fut: F)
255where
256    F: std::future::Future<Output = ()> + Send + 'static,
257{
258    if let Ok(handle) = tokio::runtime::Handle::try_current() {
259        drop(handle.spawn(fut));
260    }
261}
262
263/// No-op variant: under `unsync`, on wasm32, or without the `l1` feature the
264/// client never classifies a hit as stale (`SwrRead::Stale` is unreachable),
265/// so the refresh future handed here is dead code by construction. The stub
266/// exists so `#[cachekit]`-generated code compiles under every configuration.
267#[cfg(not(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32"))))]
268#[doc(hidden)]
269pub fn __swr_spawn<F>(_fut: F)
270where
271    F: std::future::Future<Output = ()> + 'static,
272{
273}
274
275/// Convenient glob import for the most common types.
276pub mod prelude {
277    pub use crate::{
278        BackendError, BackendErrorKind, CacheKit, CacheKitBuilder, CachekitConfig, CachekitError,
279        SwrRead, SwrToken,
280    };
281
282    #[cfg(feature = "encryption")]
283    pub use crate::{EncryptionLayer, SecureCache};
284
285    #[cfg(feature = "macros")]
286    pub use crate::cachekit;
287}
288
289#[cfg(all(
290    test,
291    any(feature = "l1", feature = "reliability"),
292    not(target_arch = "wasm32")
293))]
294mod random_unit_tests {
295    use super::{next_jitter, random_unit, JitterState};
296
297    /// A forked child inherits the parent's thread-local state (simulated by
298    /// copying the cell). Under its own pid it must not replay the parent's
299    /// next draw, and two siblings must not match each other either.
300    #[test]
301    fn a_new_pid_reseeds_instead_of_replaying_the_parent() {
302        let parent = JitterState::new(None);
303        next_jitter(&parent, 100);
304        let child_a = JitterState::new(parent.get());
305        let child_b = JitterState::new(parent.get());
306        let from_parent = next_jitter(&parent, 100);
307        let from_a = next_jitter(&child_a, 101);
308        let from_b = next_jitter(&child_b, 102);
309        assert_ne!(from_parent, from_a);
310        assert_ne!(from_parent, from_b);
311        assert_ne!(from_a, from_b);
312        // Same pid: the sequence continues rather than reseeding each draw.
313        let replay = JitterState::new(child_a.get());
314        assert_eq!(next_jitter(&replay, 101), next_jitter(&child_a, 101));
315    }
316
317    #[test]
318    fn stays_in_unit_interval_and_spreads() {
319        let draws: Vec<f64> = (0..10_000).map(|_| random_unit()).collect();
320        assert!(draws.iter().all(|x| (0.0..1.0).contains(x)));
321        let mean = draws.iter().sum::<f64>() / draws.len() as f64;
322        assert!((mean - 0.5).abs() < 0.02, "mean {mean}");
323        let low = draws.iter().filter(|x| **x < 0.1).count();
324        let high = draws.iter().filter(|x| **x >= 0.9).count();
325        assert!(low > 800 && high > 800, "tails {low} / {high}");
326    }
327
328    /// Each thread seeds from its own `RandomState` key, so two threads (and
329    /// two processes) do not replay one sequence.
330    #[test]
331    #[allow(clippy::expect_used)]
332    fn threads_draw_different_sequences() {
333        let draw = || (0..4).map(|_| random_unit().to_bits()).collect::<Vec<_>>();
334        let a = std::thread::spawn(draw).join().expect("thread a");
335        let b = std::thread::spawn(draw).join().expect("thread b");
336        assert_ne!(a, b);
337    }
338}