Skip to main content

cachekit

Attribute Macro cachekit 

Source
#[cachekit]
Expand description

Cache the result of an async function under an interop/v1 cache key.

Keys are {namespace}:{operation}:{args_hash} per the protocol’s interop-mode spec, so the same entry is addressable from the Python and TypeScript SDKs — Python’s @cache(interop="get_user", namespace="users") computes the identical key for the same arguments.

§Attributes

  • client = <ident> (required): Name of the &CacheKit parameter.
  • ttl = <integer> (required): TTL in seconds.
  • interop = "<string>" (required): explicit, language-neutral operation segment (^[a-z0-9][a-z0-9._-]{0,63}$) — same meaning as Python’s interop= / TypeScript’s interop:.
  • namespace = "<string>" (required): interop/v1 namespace segment, same grammar. ns and nsapi are reserved as namespaces (a compile error — the CachekitIO server parses those key prefixes); operations are unaffected. Neither segment may contain .. (a compile error — the server rejects .. anywhere in a key); a lone . is fine.
  • secure (optional flag): require encryption — every call fails with CachekitError::Config on a client without it — and fail closed on every backend error (see Reliability behaviour). The flag is not what encrypts: on a client with encryption configured, a function without it stores and reads ciphertext too, because the client’s value methods encrypt.

§Requirements

  • The function must return Result<T, E> where E: From<CachekitError> — CachekitError itself, or the application’s own error type. The body’s errors reach the caller unchanged and are never cached; cache-layer errors that propagate (see Reliability behaviour) arrive converted through From. The error type is taken from the return type as written, so a one-argument alias such as anyhow::Result<T> works.
  • Every non-client argument must be a plain identifier (no destructuring patterns, no self) and must convert into cachekit::interop::InteropValue via From (bool, i32/i64/ u32/u64/i128, f64, &str/String, Uuid). Unsupported argument types fail at compile time.
  • In-model values outside the interop ranges fail at CALL time with CachekitError::Serialization before the body runs: non-finite floats (NAN, ±INFINITY) and i128 outside [-2^63, 2^64-1]. This fail-loud contract matches the Python and TypeScript SDKs — silently running uncached would mask cross-SDK key divergence.
  • The client must be built without .namespace() — interop keys carry their own namespace segment, and reads fail closed on a namespaced client.
  • A stored entry that cannot be decoded as the return type (CachekitError::Serialization) is treated as a miss and overwritten (self-healing). On a client with encryption configured, secure or not, this covers only post-decrypt decode failures: an entry that fails AES-GCM authentication raises CachekitError::Encryption, which propagates (fail-closed). The failed read drops the key’s L1 copy and leaves the backend entry in place, so the next call reads the backend again: it fails while that entry remains, succeeds once any process replaces it with ciphertext this client can decrypt, and is a miss once it expires or is deleted.

§Requirements (continued)

  • The function must be async — the generated body awaits the cache read, the cold-miss single-flight, and SWR refresh scheduling. A sync function is a clear compile-time error, never a silent no-op.

§Reliability behaviour

  • Stale-while-revalidate (client SWR enabled — the default with the l1 feature on native targets): an L1 hit past the client’s freshness threshold (swr_threshold_ratio × entry TTL, ±10% jitter) but before hard expiry is returned immediately — the caller never blocks on a merely-stale entry — while one background task re-executes the function. Its version-checked commit rewrites both layers only if no newer set or delete replaced the stale entry; a successful commit renews the L1 hard expiry with the full write-path TTL. Refresh dedup takes the CacheKit::single_flight locks without waiting on them: N concurrent stale readers trigger at most one re-execution per process (and, on lock-capable backends, per fleet), and none while the lock call fails, in which case the stale value serves until hard expiry. Hard-expired entries always take the normal blocking miss path. The refresh task needs a tokio runtime (skipped otherwise — the stale value was already served) and captures arguments by owned copy (Clone/ToOwned — already required for key derivation); the future must be Send, so T and anything the body holds across an .await must be too. E need not be: the refresh drops the body’s error before it awaits again, so Box<dyn Error> works. Refresh failures are absorbed: the stale value keeps serving until hard expiry, where the blocking path surfaces errors.
  • Graceful degradation: on an outage-class backend failure — transient, timeout, an open circuit breaker, or a backpressure shed — the plain path fails open: the function executes uncached and its result is returned. Permanent and authentication errors propagate even on the plain path (a wrong API key must fail loudly, not silently disable caching forever). With secure, every backend and decryption error fails closed and propagates: an encrypted workload never silently degrades. Without the flag, a client with encryption configured still propagates decryption errors and still encrypts what it writes; only the outage-class backend errors above fail open.
  • Cold-miss single-flight: concurrent calls that miss on the same key are collapsed to one execution per process (and per fleet, when the backend supports distributed fill locks — CachekitIO and Redis do, with the reliability feature). Waiters re-read the filled entry instead of recomputing. See cachekit::flight.

§Example

ⓘ
#[cachekit(client = cache, ttl = 60, interop = "get_user", namespace = "users")]
async fn get_user(cache: &CacheKit, id: u64) -> Result<User, CachekitError> {
    Ok(User { name: format!("User {id}") })
}