diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 44c520d5..2ab2fa8e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -123,6 +123,15 @@ repos: files: ^((packages|crates)/[^/]+/)?README[^/]*$|^docs/ types: [text] stages: [pre-commit] + - id: no-retired-names + name: No retired product names or wrong package names in docs + # The hosted service is "CacheKit Cloud" in prose; `CachekitIO` is only a code identifier. + # Naming rules: https://docs.cachekit.io/contributing/#names + language: pygrep + entry: '(?i:\bCachekitIO[ -](?:Cloud|SaaS|backend|service|server|API)s?\b)|\bSaaS\b|(?i:(? [!WARNING] -> Earlier releases connected to `redis://localhost:6379` when nothing was configured. To keep -> that Redis, set `REDIS_URL=redis://localhost:6379` (or `CACHEKIT_REDIS_URL`). `@cache.secure` +> With nothing configured, `@cache` never connects to `redis://localhost:6379`. To use that +> Redis, set `REDIS_URL=redis://localhost:6379` (or `CACHEKIT_REDIS_URL`); upgraders, see the +> [CHANGELOG](CHANGELOG.md). `@cache.secure` > and `encryption=True` still need a backend and still fall back to localhost Redis; see > [Backend Resolution Priority](docs/backends/README.md#backend-resolution-priority). `interop=` > refuses that Redis, and any Redis resolved without `backend=`, because it prefixes keys with the @@ -112,25 +115,25 @@ def expensive_api_call(user_id: int): ### More Backends
-CachekitIO — Managed SaaS (Beta) +CacheKit Cloud — Managed (Closed Beta) ```python notest import os from cachekit import cache -# Set your CachekitIO API key +# Set your CacheKit Cloud API key # export CACHEKIT_API_KEY="your-api-key" # pragma: allowlist secret -@cache.io() # Uses CachekitIO SaaS backend — no Redis to manage +@cache.io() # Uses the CacheKit Cloud backend — no Redis to manage def expensive_api_call(user_id: int): return fetch_user_data(user_id) ``` -*cachekit.io is in closed beta — [request access](https://cachekit.io) to get started.* +*CacheKit Cloud is in closed beta: sign-up is by waitlist at [dash.cachekit.io](https://dash.cachekit.io).* -During the beta the backend reports hit counts and L1 hit latency to cachekit.io by default; `CACHEKIT_TELEMETRY=false` turns that off ([Telemetry](docs/backends/cachekitio.md#telemetry)). +During the beta the backend reports hit counts and L1 hit latency to CacheKit Cloud by default; `CACHEKIT_TELEMETRY=false` turns that off ([Telemetry](docs/backends/cachekitio.md#telemetry)). -How fresh a cachekit.io read is, and how long a deleted project's data stays readable: [Consistency and Deletion](https://docs.cachekit.io/concepts/consistency/). +How fresh a CacheKit Cloud read is, and how long a deleted project's data stays readable: [Consistency and Deletion](https://docs.cachekit.io/concepts/consistency/).
@@ -156,9 +159,9 @@ def expensive_api_call(user_id: int): --- -> **CachekitIO Cloud (Beta)** +> **CacheKit Cloud (Closed Beta)** > Managed caching with zero infrastructure. L1+L2 caching, circuit breaker, and automatic failover — no Redis to manage. -> *cachekit.io is in closed beta — [request access](https://cachekit.io) to get started.* +> *CacheKit Cloud is in closed beta: sign-up is by waitlist at [dash.cachekit.io](https://dash.cachekit.io).* --- @@ -212,7 +215,7 @@ Setting `CACHEKIT_MASTER_KEY` instead of passing `master_key=` means every prese > > **L1 SWR** (within-TTL background refresh) runs only in L1-only mode (`backend=None`) — with a backend configured it has no effect. `@cache.io` additionally ships past-TTL SWR via `stale_ttl` ([docs](docs/configuration.md#stale-while-revalidate-stale_ttl)). > -> **`@cache.io()`** mirrors `@cache.production` (full reliability + observability) but routes to the managed CachekitIO SaaS backend instead of Redis. **`@cache.local()`** is a Python-only, SDK-local preset: a separate in-process path backed by `ObjectCache` (raw object references, entry-count LRU, no serialization) — the reliability and encryption features listed above do not apply to it. +> **`@cache.io()`** mirrors `@cache.production` (full reliability + observability) but routes to CacheKit Cloud instead of Redis. **`@cache.local()`** is a Python-only, SDK-local preset: a separate in-process path backed by `ObjectCache` (raw object references, entry-count LRU, no serialization) — the reliability and encryption features listed above do not apply to it.
Additional Presets: @cache.dev and @cache.test @@ -248,7 +251,7 @@ def test_cached_function(): │ └─────────────┘ └─────────────┘ └─────────────────────┘ │ ├─────────────────────────────────────────────────────────────┤ │ L1 Cache (In-Memory) │ L2 Cache (Pluggable Backend) │ -│ in-process │ Redis / CachekitIO / File / │ +│ in-process │ Redis / CacheKit Cloud / File / │ │ │ Memcached: one backend round trip │ ├─────────────────────────────────────────────────────────────┤ │ Rust Core (PyO3) │ @@ -289,9 +292,9 @@ Hosted runners come with other CPU models too: in a run of an earlier commit, an - Circuit breaker with graceful degradation - Connection pooling with thread affinity -- CachekitIO keeps idle connections pooled until the server closes them, with TCP keepalive probes, so a request after a pause skips a new TLS handshake -- Distributed locking prevents cache stampedes -- Pluggable backend abstraction (Redis, CachekitIO, File, Memcached, custom) +- The CacheKit Cloud backend keeps idle connections pooled until the server closes them, with TCP keepalive probes, so a request after a pause skips a new TLS handshake +- Distributed locking guards against cache stampedes across pods, best effort (async functions, on a backend that supports it) +- Pluggable backend abstraction (Redis, CacheKit Cloud, File, Memcached, custom) - Untrusted-decode bounds: nesting depth and header-declared allocation are capped on every cache read (a forged entry is a bounded cache miss), verified against the protocol's shared [`decode-bounds.json`](https://github.com/cachekit-io/protocol/blob/42ea601f3c1656df0a06904afa2406604f43f57d/test-vectors/decode-bounds.json) vectors > [!NOTE] @@ -323,7 +326,7 @@ def get_large_dataset(date: str): return pd.read_csv(f"data/{date}.csv") ``` -Encrypted DataFrames go through `@cache.secure`, which takes any serializer whose class declares `cross_sdk_compatible = True` ([details](docs/serializers/encryption.md#composability)). A file backend keeps this example self-contained; production uses Redis or cachekit.io: +Encrypted DataFrames go through `@cache.secure`, which takes any serializer whose class declares `cross_sdk_compatible = True` ([details](docs/serializers/encryption.md#composability)). A file backend keeps this example self-contained; production uses Redis or CacheKit Cloud: ```python import tempfile @@ -445,9 +448,9 @@ exposition setup. **Free-threaded CPython (3.15t, 3.14t):** the core suites run green on free-threaded 3.14 and 3.15 with the GIL verified disabled (CI job `test-freethreaded`), and the Rust extension declares free-threaded safety -(`gil_used = false`). **From the release after 0.24.1, 3.15t has a wheel**: +(`gil_used = false`). **3.15t has a wheel**: the 3.15+ wheel is `abi3t`, so it installs on 3.15t without a Rust toolchain -(pip 26.1+ or uv 0.11.3+). **3.14t is tested +([with a recent pip or uv](docs/free-threading.md#cpython-315-one-abi3t-wheel)). **3.14t is tested but has no wheel** (it builds from source). Free-threaded builds are not officially supported yet — blocked upstream on orjson (no free-threaded wheels) and hiredis (re-enables the GIL on import; @@ -506,7 +509,7 @@ info = expensive_func.cache_info() | [L1 Invalidation & SWR][l1-invalidation-url] | Invalidation scope (incl. cross-process whole-function on tenant-scoped Redis from the environment or `RedisBackendProvider`), opt-in cross-process L1 eviction, stale-while-revalidate | | [Reference Caching][reference-caching-url] | `@cache.local()` for non-serializable objects | | [Rust Serialization][rust-serialization-url] | ByteStorage layer: LZ4, xxHash3, AES-256-GCM | -| [SSRF Protection][ssrf-url] | URL allowlisting for the CachekitIO backend | +| [SSRF Protection][ssrf-url] | URL allowlisting for the CacheKit Cloud backend | | [Write-Behind][write-behind-url] | Opt-in `write_behind=True`: a miss returns before its L2 write lands | --- @@ -527,10 +530,10 @@ info = expensive_func.cache_info() CACHEKIT_REDIS_URL="redis://localhost:6379" # Primary (preferred) REDIS_URL="redis://localhost:6379" # Fallback -# CachekitIO SaaS Backend (closed beta — request access at cachekit.io) +# CacheKit Cloud backend (closed beta: join the waitlist at https://dash.cachekit.io) # For @cache.io() next to Redis, pass api_key= from your secret store instead. # CACHEKIT_API_KEY="your-api-key" # pragma: allowlist secret -CACHEKIT_API_URL="https://api.cachekit.io" # Default SaaS endpoint +CACHEKIT_API_URL="https://api.cachekit.io" # Default CacheKit Cloud endpoint # Memcached Backend (optional: pip install cachekit[memcached]) # CACHEKIT_MEMCACHED_SERVERS='["mc1:11211", "mc2:11211"]' # Default: 127.0.0.1:11211 diff --git a/docs/README.md b/docs/README.md index 095878aa..1e16dca6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,7 +29,7 @@ Choose your storage backend: | [Redis](backends/redis.md) | Self-hosted, production-grade, connection pooling | | [File](backends/file.md) | Local filesystem, zero dependencies | | [Memcached](backends/memcached.md) | High-throughput, consistent hashing | -| [CachekitIO](backends/cachekitio.md) | Managed SaaS, zero infrastructure | +| [CacheKit Cloud](backends/cachekitio.md) | Managed, zero infrastructure | | [L1-Only (None)](backends/none.md) | In-memory only, no external services | | [Custom](backends/custom.md) | Implement your own backend | @@ -60,7 +60,7 @@ Choose how data is stored: | [L1 Invalidation & SWR](features/l1-invalidation.md) | Invalidation scope, opt-in cross-process L1 eviction, stale-while-revalidate | | [Reference Caching](features/reference-caching.md) | `@cache.local()` for non-serializable objects | | [Rust Serialization](features/rust-serialization.md) | ByteStorage layer: LZ4, xxHash3, AES-256-GCM | -| [SSRF Protection](features/ssrf-protection.md) | URL allowlisting for the CachekitIO backend | +| [SSRF Protection](features/ssrf-protection.md) | URL allowlisting for the CacheKit Cloud backend | | [Write-Behind](features/write-behind.md) | Opt-in `write_behind=True`: a miss returns before its L2 write lands | ## Architecture & Reference diff --git a/docs/api-reference.md b/docs/api-reference.md index 9db035f2..66b3cc35 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -22,7 +22,7 @@ The `@cache` decorator takes its configuration from a preset or from explicit ar ```python notest from cachekit import cache -# Zero-config: automatic optimization (90% of use cases) +# L1 only: backend=None forces L1 only, even when a backend is configured @cache(backend=None) def expensive_function(): return do_expensive_computation() @@ -47,7 +47,7 @@ def custom_function(): - **`@cache.secure`** - Security profile: EncryptionWrapper, comprehensive audit logging, zero-knowledge caching - **`@cache.dev`** - SDK-local, Python-only development profile: Verbose logging, easy debugging, integrity checking on, Prometheus cache-operation series off ([series that ignore `MonitoringConfig`](features/prometheus-metrics.md#series-that-ignore-monitoringconfig) still record) - **`@cache.test`** - SDK-local, Python-only testing profile: Deterministic behavior, all protections disabled, no monitoring for reproducible tests -- **`@cache.io`** - cachekit.io SaaS profile: HTTP-based edge caching via api.cachekit.io, zero infrastructure required *(cachekit.io is in closed beta — [request access](https://cachekit.io))* +- **`@cache.io`** - CacheKit Cloud profile: HTTP-based edge caching via api.cachekit.io, zero infrastructure required *(CacheKit Cloud is in closed beta: sign-up is by waitlist at [dash.cachekit.io](https://dash.cachekit.io))* - **`@cache`** - Baseline: no preset; every setting at its default or as passed. It never inspects the function to pick a profile, and never encrypts unless told to ### `@cache(...)` - Manual Configuration @@ -90,7 +90,7 @@ def your_function(args): #### Performance Parameters -- **`refresh_ttl_on_get`** (`bool`, default: `False`) - Refresh TTL on async L2 hits when below threshold, in the background (no extra round trip on the hit). On CachekitIO the threshold is checked against the hit's remaining freshness; see [CachekitIO TTL inspection](backends/cachekitio.md#ttl-inspection-async-only) +- **`refresh_ttl_on_get`** (`bool`, default: `False`) - Refresh TTL on async L2 hits when below threshold, in the background (no extra round trip on the hit). On CacheKit Cloud the threshold is checked against the hit's remaining freshness; see [CacheKit Cloud TTL inspection](backends/cachekitio.md#ttl-inspection-async-only) - **`ttl_refresh_threshold`** (`float`, default: `0.5`) - Minimum remaining TTL fraction (0.0–1.0) to trigger refresh - **`write_behind`** (`bool`, default: `False`; set by no preset; any non-bool raises `ConfigurationError`) - Return a miss once its value is serialized and in L1; the L2 write follows on a bounded background writer. Until it lands, other processes miss the key, a process killed first loses it, and a write queued before another process's invalidation can land after it, so do not use it for values that must not outlive an invalidation. The async distributed-lock path and a sync miss's streaming write stay synchronous. See [Write-Behind](features/write-behind.md) - **`distributed_lock`** (`bool`, default: `True`; set off by no preset; any non-bool raises `ConfigurationError`) - Take the backend's distributed lock around an async miss. `False` skips it: no lock round trips, and write-behind, when on, applies to the miss. Concurrent misses on the event loop running the key's call still share it; every other process computes the key, and so does every caller on another thread's event loop while that call runs. Sync functions never take the lock. See [Distributed Locking](features/distributed-locking.md#turning-it-off-distributed_lockfalse) @@ -180,11 +180,11 @@ def critical_business_logic(): # The decorator automatically connects to Redis using the environment variable ``` -### `@cache.io` — cachekit.io SaaS Backend +### `@cache.io` — CacheKit Cloud Backend -> *cachekit.io is in closed beta — [request access](https://cachekit.io)* +> *CacheKit Cloud is in closed beta: sign-up is by waitlist at [dash.cachekit.io](https://dash.cachekit.io)* -**Profile for zero-infrastructure caching** via the cachekit.io edge network. Automatically configures `CachekitIOBackend` (HTTP) as the L2 backend with production-grade reliability settings (circuit breaker, L1 in-memory cache). +**Profile for zero-infrastructure caching** via the CacheKit Cloud edge network. Automatically configures `CachekitIOBackend` (HTTP) as the L2 backend with production-grade reliability settings (circuit breaker, L1 in-memory cache). #### Environment Variables @@ -711,7 +711,7 @@ For error examples and handling patterns, see [Troubleshooting Guide](troublesho ## Backend Abstraction -cachekit uses a protocol-based backend abstraction (PEP 544) that allows pluggable storage backends for L2 cache. Built-in backends include Redis, CachekitIO, File, and Memcached. You can also implement custom backends for any key-value store. +cachekit uses a protocol-based backend abstraction (PEP 544) that allows pluggable storage backends for L2 cache. Built-in backends include Redis, CacheKit Cloud, File, and Memcached. You can also implement custom backends for any key-value store. For comprehensive backend guide with examples and implementation patterns, see **[Backend Guide](backends/README.md)**. @@ -815,7 +815,7 @@ lives in an external store, not a bounded in-process dict. Statistics are tracked per function identity (`module.qualname`) and are thread-safe. Re-applying a decorator to the same function — even with different options — reuses the existing counters from a process-global registry rather than resetting them, so `session_id` -stays stable and the counters reported to the CachekitIO backend stay monotonic (counters +stays stable and the counters reported to the CacheKit Cloud backend stay monotonic (counters that reset under an unchanged session ID would trip the server's anti-replay validation). `cache_clear()` resets the counters and rotates `session_id`. A forked child process starts with zeroed counters and a session ID derived from its own process UUID; the parent's @@ -894,7 +894,7 @@ cachekit writes structured records through Python's `logging`, so your handlers With `enable_structured_logging=True` (the default), these cache operations are logged at INFO on the `cachekit.decorators.orchestrator` logger: L1 and L2 hits of a sync function that has a backend, failed operations and circuit-breaker rejections. Each record's `structured` attribute holds the operation, the namespace and the redacted cache key, plus the duration, hit, serializer and error type where they apply. The message is plain text; your formatter decides whether those fields are rendered. The health checker behind `get_health_checker()`, `health_check_handler()` and `async_health_check_handler()` logs each full check it runs (`HealthLevel.FULL`, the default) the same way, at INFO on the `cachekit.health` logger, with the status, duration and component count, whatever `enable_structured_logging` says. -SDK diagnostics, such as the CachekitIO backend's debug lines, are one JSON object per record on the `cachekit.logging` logger, with `timestamp`, `level`, `message`, `logger`, `hostname`, `pid` and `thread_id`. Each is written when it is logged, on the calling thread. DEBUG, INFO and WARNING records are sampled at `CACHEKIT_LOG_SAMPLING_RATE` (default `0.1`), and ERROR and CRITICAL records never are. A field value JSON cannot encode is written as its type name, such as `""`, never as its text. Nothing is sampled or serialised below that logger's level. A child forked by Python (`os.fork()`, `multiprocessing`, Gunicorn, Celery) writes its own records, with its own `pid`. A child forked from C without Python's at-fork hooks (uWSGI without `--py-call-uwsgi-fork-hooks`) writes none, and no health-check record either: `logging`'s after-fork lock reset did not run there, so a lock a parent thread held at the fork would hang the call. +SDK diagnostics, such as the CacheKit Cloud backend's debug lines, are one JSON object per record on the `cachekit.logging` logger, with `timestamp`, `level`, `message`, `logger`, `hostname`, `pid` and `thread_id`. Each is written when it is logged, on the calling thread. DEBUG, INFO and WARNING records are sampled at `CACHEKIT_LOG_SAMPLING_RATE` (default `0.1`), and ERROR and CRITICAL records never are. A field value JSON cannot encode is written as its type name, such as `""`, never as its text. Nothing is sampled or serialised below that logger's level. A child forked by Python (`os.fork()`, `multiprocessing`, Gunicorn, Celery) writes its own records, with its own `pid`. A child forked from C without Python's at-fork hooks (uWSGI without `--py-call-uwsgi-fork-hooks`) writes none, and no health-check record either: `logging`'s after-fork lock reset did not run there, so a lock a parent thread held at the fork would hang the call. ## Best Practices diff --git a/docs/backends/README.md b/docs/backends/README.md index fbcd4625..afa2258f 100644 --- a/docs/backends/README.md +++ b/docs/backends/README.md @@ -2,7 +2,7 @@ # Backend Guide -Pluggable L2 cache storage for cachekit. Four backends are included out of the box — Redis, File (local), Memcached, and CachekitIO (managed SaaS). With no backend configured, a plaintext decorator runs in [L1-only mode](none.md), as `backend=None` does; encrypted and interop decorators still need a backend ([Nothing Configured](#4-nothing-configured-l1-only)). You can also implement custom backends for any key-value store. +Pluggable L2 cache storage for cachekit. Four backends are included out of the box — Redis, File (local), Memcached, and CacheKit Cloud (managed). With no backend configured, a plaintext decorator runs in [L1-only mode](none.md), as `backend=None` does; encrypted and interop decorators still need a backend ([Nothing Configured](#4-nothing-configured-l1-only)). You can also implement custom backends for any key-value store. ## Overview @@ -54,7 +54,7 @@ class BaseBackend(Protocol): key: Cache key to delete Returns: - True if key was deleted, False if key didn't exist. CachekitIO returns + True if key was deleted, False if key didn't exist. CacheKit Cloud returns True on every successful delete, whether or not the key existed: the server does not report existence on DELETE. @@ -98,12 +98,12 @@ class BaseBackend(Protocol): | **[File](file.md)** | set = fsync + a scan of every entry, so slow on a large cache; get waits on a concurrent set ([cost model](file.md#performance-characteristics)) | Yes (disk) | Format shared; one writer process | Yes | File locks | | **[Redis](redis.md)** | One network round trip per operation ([not measured here](../performance.md#not-measured-here)) | Yes (RDB/AOF) | Yes | Yes | Yes | | **[Memcached](memcached.md)** | One network round trip per operation ([not measured here](../performance.md#not-measured-here)) | No | Yes | Yes (max 30d) | No | -| **[CachekitIO](cachekitio.md)** | 42–45ms p50 hit, measured from MEL ([varies by vantage](cachekitio.md#characteristics)) | Yes | Yes | Yes | Yes | +| **[CacheKit Cloud](cachekitio.md)** | 42–45ms p50 hit, measured from MEL ([varies by vantage](cachekitio.md#characteristics)) | Yes | Yes | Yes | Yes | | **[HTTP (custom)](custom.md)** | One HTTP request per operation ([not measured here](../performance.md#not-measured-here)) | Varies | Yes | Varies | Varies | | **[DynamoDB (custom)](custom.md)** | One DynamoDB API request per operation ([not measured here](../performance.md#not-measured-here)) | Yes | Yes | Yes | No | **TTL inspection/refresh** (`refresh_ttl_on_get` sliding expiration, via the -`TTLInspectableBackend` protocol): supported by **File**, **Redis**, and **CachekitIO**. +`TTLInspectableBackend` protocol): supported by **File**, **Redis**, and **CacheKit Cloud**. **Memcached** supports `refresh_ttl` (via `touch`) directly but not `get_ttl`, so `refresh_ttl_on_get` does not apply to it (see [Memcached](memcached.md#ttl-inspection--refresh)). @@ -134,7 +134,7 @@ calling process knows (see [Whole-Function Invalidation](../features/l1-invalida - Existing Memcached infrastructure you want to reuse - Read-heavy workloads where a network round trip on each L1 miss is acceptable ([not measured here](../performance.md#not-measured-here)) -**Use [CachekitIOBackend](cachekitio.md) when** *(closed beta — [request access](https://cachekit.io))*: +**Use [CacheKit Cloud](cachekitio.md) (`CachekitIOBackend`) when** *(closed beta: sign-up is by waitlist at [dash.cachekit.io](https://dash.cachekit.io))*: - You want managed, zero-ops distributed caching - Multi-region caching without operating Redis - Building zero-knowledge architecture with `@cache.secure` @@ -204,7 +204,7 @@ assert fast_lookup(2) == 4 and critical_function(2) == 6 set_default_backend(None) # clear the default ``` -Call `set_default_backend(None)` to clear the default. Works with any backend (Redis, File, CachekitIO, custom). +Call `set_default_backend(None)` to clear the default. Works with any backend (Redis, File, CacheKit Cloud, custom). **Import order does not matter, but configuration must happen before a decorated function's first call.** A decorator applied @@ -213,7 +213,7 @@ already set, otherwise at first call — so the usual layout (business modules imported at the top of the file, `set_default_backend()` in `main()`) works. Later `set_default_backend()` calls do not re-point already-pinned functions. Exception: an explicit `stale_ttl` validates SWR capability at decoration, so set a -CachekitIO default *before* importing modules that use it. `@cache.io` is not affected: +`CachekitIOBackend` default *before* importing modules that use it. `@cache.io` is not affected: it builds its own `CachekitIOBackend` and never consults `set_default_backend()`. ### 3. Environment Variable Auto-Detection @@ -225,7 +225,7 @@ set exactly one. | Environment variable | Backend | |-----------------------------|--------------------| -| `CACHEKIT_API_KEY` | `CachekitIOBackend` (SaaS) | +| `CACHEKIT_API_KEY` | `CachekitIOBackend` (CacheKit Cloud) | | `CACHEKIT_REDIS_URL` | Redis (tenant-scoped, keys prefixed `t:{tenant}:`) | | `CACHEKIT_MEMCACHED_SERVERS`| `MemcachedBackend` | | `CACHEKIT_FILE_CACHE_DIR` | `FileBackend` | @@ -411,7 +411,7 @@ first call (and runs uncached if nothing listens there): give it `backend=RedisB | **L1 (In-Memory)** | 0.31μs per `backend=None` hit with a scalar argument the memo answers ([measured](../performance.md#github-hosted-runner)) | Repeated calls in same process | Process-local only | | **File** | set = fsync, flat in cache size, except eviction, rejection and the 30 s rescan, which scan the directory; get waits on a concurrent set | Single-process local caching | Development, scripts, CLI tools | | **Redis** | One network round trip per operation ([not measured here](../performance.md#not-measured-here)) | Shared cache across pods | Recommended production backend | -| **CachekitIO** | 42–45ms p50 hit, measured from MEL | Managed SaaS, zero-ops | HTTPS (HTTP/1.1, async calls on a worker thread); depends on vantage, store region and edge hits ([measured](cachekitio.md#characteristics)); closed beta | +| **CacheKit Cloud** | 42–45ms p50 hit, measured from MEL | Managed, zero-ops | HTTPS (HTTP/1.1, async calls on a worker thread); depends on vantage, store region and edge hits ([measured](cachekitio.md#characteristics)); closed beta | | **HTTP API** | One HTTP request per operation ([not measured here](../performance.md#not-measured-here)) | Custom cloud services | Network dependent | | **DynamoDB** | One DynamoDB API request per operation ([not measured here](../performance.md#not-measured-here)) | Serverless, low-traffic | High availability | | **Memcached** | One network round trip per operation ([not measured here](../performance.md#not-measured-here)) | Alternative to Redis | No persistence | @@ -420,10 +420,10 @@ first call (and runs uncached if nothing listens there): give it `backend=RedisB ## Backend Pages -- [Redis Backend](redis.md) — Default, production-grade, shared cache +- [Redis Backend](redis.md) — Production-grade, shared cache - [File Backend](file.md) — Local disk, single-process, no infrastructure - [Memcached Backend](memcached.md) — High-throughput, volatile, multi-process -- [CachekitIO Backend](cachekitio.md) — Managed SaaS, zero-ops, zero-knowledge +- [CacheKit Cloud Backend](cachekitio.md) — Managed, zero-ops, zero-knowledge - [Custom Backends](custom.md) — HTTP, DynamoDB, and your own implementations ## See Also diff --git a/docs/backends/cachekitio.md b/docs/backends/cachekitio.md index de741774..100a7893 100644 --- a/docs/backends/cachekitio.md +++ b/docs/backends/cachekitio.md @@ -1,10 +1,10 @@ -**[Home](../README.md)** › **[Backends](README.md)** › **CachekitIO Backend** +**[Home](../README.md)** › **[Backends](README.md)** › **CacheKit Cloud Backend** -# CachekitIO Backend +# CacheKit Cloud Backend -> *cachekit.io is in closed beta — [request access](https://cachekit.io)* +> *CacheKit Cloud is in closed beta: sign-up is by waitlist at [dash.cachekit.io](https://dash.cachekit.io)* -`CachekitIOBackend` connects to the cachekit.io managed cache API over HTTPS (HTTP/1.1, through urllib3). It implements the full `BaseBackend` protocol plus distributed locking (`LockableBackend`) and TTL inspection (`TTLInspectableBackend`). +`CachekitIOBackend` connects to the CacheKit Cloud API (`api.cachekit.io`) over HTTPS (HTTP/1.1, through urllib3). It implements the full `BaseBackend` protocol plus distributed locking (`LockableBackend`) and TTL inspection (`TTLInspectableBackend`). ## Setup @@ -70,7 +70,7 @@ To cache through another backend, use `@cache.production(backend=...)`. A function with a custom `key=` is filed in the service's `default` namespace, an open write space, whatever its `namespace=`. For a `key=` namespace of `ns` or `nsapi` that is a change from earlier releases, and any API key that can reach `default` can read, overwrite, delete or lock those entries: -see [CachekitIO files every custom key in its `default` namespace](../features/l1-invalidation.md#upgrading-custom-key-keys-carry-the-functions-identity). +see [CacheKit Cloud files every custom key in its `default` namespace](../features/l1-invalidation.md#upgrading-custom-key-keys-carry-the-functions-identity). A bearer token carries only `A-Z a-z 0-9 - . _ ~ + /`, then any number of trailing `=`. So cachekit rejects a key with whitespace (usually a trailing newline from a secrets file), a byte-order mark (from @@ -187,7 +187,7 @@ CACHEKIT_TELEMETRY=false # Optional — turns telemetry off (see Te ## Telemetry -While cachekit.io is in closed beta, a `CachekitIOBackend` sends usage telemetry to cachekit.io by default. It +While CacheKit Cloud is in closed beta, a `CachekitIOBackend` sends usage telemetry to CacheKit Cloud by default. It becomes opt-in when the service launches. It goes only to the API URL the backend is configured with, as headers on the cache requests the backend already makes; nothing is sent on its own, and the Redis, Memcached and File backends and L1-only caches send none. @@ -203,7 +203,7 @@ backends and L1-only caches send none. the call runs. A timed call that is an L1 hit adds its duration, from entering the wrapper to returning the value, to a report: sixteen duration buckets doubling from 128 ns, and their sum in nanoseconds. A duration over 60 s is dropped and counts toward neither. The next request to -cachekit.io carries the report and empties it, so each hit is reported once. A call that waited on another call's +CacheKit Cloud carries the report and empties it, so each hit is reported once. A call that waited on another call's miss for the same key, a miss, an L2 hit and a direct `backend.get()` are never timed, and the health check never carries a report. A forked child starts with an empty report and its own random draws. Under uWSGI without `--py-call-uwsgi-fork-hooks`, a worker draws the same sequence as its siblings until it builds a backend or makes its @@ -226,7 +226,7 @@ API requires, and a `User-Agent` naming cachekit-py and its version. ## When to Use -**Use CachekitIOBackend when**: +**Use `CachekitIOBackend` when**: - Managed, zero-infrastructure caching - Multi-region distributed caching without operating Redis - Teams that want caching without DevOps overhead @@ -289,17 +289,17 @@ API requires, and a `User-Agent` naming cachekit-py and its version. [Forked Processes](../features/l1-invalidation.md#forked-processes) explains; [Free-threading](../free-threading.md) gives the detail - Every request identifies the SDK with a `User-Agent: cachekit-py/ urllib3/` header, - taken from the installed packages, so cachekit.io can attribute traffic to an SDK release + taken from the installed packages, so CacheKit Cloud can attribute traffic to an SDK release - Distributed locking via server-side Durable Objects - TTL inspection and in-place refresh supported --- -## Encrypted SaaS Pattern (Zero-Knowledge) +## Encrypted CacheKit Cloud Pattern (Zero-Knowledge) -> *cachekit.io is in closed beta — [request access](https://cachekit.io)* +> *CacheKit Cloud is in closed beta: sign-up is by waitlist at [dash.cachekit.io](https://dash.cachekit.io)* -Compose `@cache.secure` with `CachekitIOBackend` for end-to-end zero-knowledge encryption over managed SaaS storage. The backend stores opaque ciphertext values — it never sees plaintext values or your master key. The cache key stays cleartext ([details](../features/zero-knowledge-encryption.md#cleartext-cache-key-accepted-exposure)). +Compose `@cache.secure` with `CachekitIOBackend` for end-to-end zero-knowledge encryption over CacheKit Cloud storage. The backend stores opaque ciphertext values — it never sees plaintext values or your master key. The cache key stays cleartext ([details](../features/zero-knowledge-encryption.md#cleartext-cache-key-accepted-exposure)). ```python notest from cachekit import cache @@ -316,7 +316,7 @@ def get_user_profile(user_id: str) -> dict: serialize(result) -> encrypt(HKDF-derived key) -> PUT /v1/cache/{key} GET /v1/cache/{key} -> decrypt() -> deserialize() -> return result - The cachekit.io API sees only encrypted values; the cache key in the URL path is cleartext. + The CacheKit Cloud API sees only encrypted values; the cache key in the URL path is cleartext. """ return fetch_user_from_db(user_id) ``` @@ -324,10 +324,10 @@ def get_user_profile(user_id: str) -> dict: **Why this matters**: - `@cache.secure` applies AES-256-GCM client-side encryption before any data leaves the process - Per-tenant key derivation via HKDF — not a tenancy boundary; see [Multi-Tenant Isolation](../features/zero-knowledge-encryption.md#multi-tenant-isolation) -- The SaaS backend stores whatever bytes arrive and never holds a key to decrypt the values -- With `@cache.secure`: the SaaS holds only ciphertext values, which supports a HIPAA/PCI DSS scope-*reduction* argument — not a guarantee, and the cache key stays cleartext; see [Compliance Implications](../features/zero-knowledge-encryption.md#compliance-implications) -- Without encryption: SaaS stores plaintext, may be in compliance scope -- Pass `backend=` explicitly, as above: `@cache.secure` does not pin the SaaS on its own ([why](../features/zero-knowledge-encryption.md#cachesecure-does-not-pin-a-backend)) +- CacheKit Cloud stores whatever bytes arrive and never holds a key to decrypt the values +- With `@cache.secure`: CacheKit Cloud holds only ciphertext values, which supports a HIPAA/PCI DSS scope-*reduction* argument — not a guarantee, and the cache key stays cleartext; see [Compliance Implications](../features/zero-knowledge-encryption.md#compliance-implications) +- Without encryption: CacheKit Cloud stores plaintext, may be in compliance scope +- Pass `backend=` explicitly, as above: `@cache.secure` does not pin CacheKit Cloud on its own ([why](../features/zero-knowledge-encryption.md#cachesecure-does-not-pin-a-backend)) **Requirements**: diff --git a/docs/backends/custom.md b/docs/backends/custom.md index d9ab747f..d795a20b 100644 --- a/docs/backends/custom.md +++ b/docs/backends/custom.md @@ -90,7 +90,7 @@ a URL) are not prefixes and stay out of `key_prefix`. ## HTTPBackend Example -A generic HTTP API backend — useful as a starting point for integrating cloud-based cache services (Cloudflare KV, Vercel KV, etc.). For managed cachekit.io storage, use [`CachekitIOBackend`](cachekitio.md) instead. +A generic HTTP API backend — useful as a starting point for integrating cloud-based cache services (Cloudflare KV, Vercel KV, etc.). For managed CacheKit Cloud storage, use [`CachekitIOBackend`](cachekitio.md) instead. ```python notest from cachekit import cache @@ -244,7 +244,7 @@ def test_custom_backend(): ## See Also - [Backend Guide](README.md) — Backend comparison and resolution priority -- [CachekitIO Backend](cachekitio.md) — Managed SaaS backend (no custom implementation needed) +- [CacheKit Cloud Backend](cachekitio.md) — Managed backend (no custom implementation needed) - [Redis Backend](redis.md) — Recommended production backend - [API Reference](../api-reference.md) — Decorator parameters diff --git a/docs/backends/file.md b/docs/backends/file.md index 3bdb9e07..154c028b 100644 --- a/docs/backends/file.md +++ b/docs/backends/file.md @@ -102,7 +102,7 @@ AES-256-GCM needs the whole ciphertext to produce/verify its auth tag, so the se stays on buffered `set()`/`get()`. Streamed values also intentionally skip the L1 in-memory cache — holding a multi-GB envelope in-process would defeat the point. -Other backends (Redis, CachekitIO, Memcached) fall back transparently to buffered +Other backends (Redis, CacheKit Cloud, Memcached) fall back transparently to buffered `set(bytes)` with no behaviour change. ## TTL Inspection & Sliding Expiration diff --git a/docs/backends/memcached.md b/docs/backends/memcached.md index 33bfd28f..a0c4818c 100644 --- a/docs/backends/memcached.md +++ b/docs/backends/memcached.md @@ -128,7 +128,7 @@ checks out its own connection, so threads sharing one backend never share a sock ## Limitations 1. **No persistence**: All data is in-memory. Server restart = data loss. -2. **No locking**: No distributed lock support (use Redis or CachekitIO for stampede prevention). +2. **No locking**: No distributed lock support (use Redis or CacheKit Cloud for stampede prevention). 3. **30-day TTL maximum**: TTLs exceeding 30 days are automatically clamped. 4. **1MB value limit**: Default Memcached slab size limits values to ~1MB. 5. **No TTL inspection (`get_ttl`)**: see below — `refresh_ttl` is supported, inspection is not. @@ -146,7 +146,7 @@ Memcached ships only **half** of the `TTLInspectableBackend` protocol, by design Consequence: `@cache(..., refresh_ttl_on_get=True)` performs *threshold-based* sliding expiration, which needs to read the remaining TTL first — so it **does not apply to Memcached** and is ignored (the decorator warns once, then continues serving the hit). For -automatic sliding expiration use the [Redis](redis.md), [CachekitIO](cachekitio.md), or +automatic sliding expiration use the [Redis](redis.md), [CacheKit Cloud](cachekitio.md), or [File](file.md) backend. You can still call `backend.refresh_ttl(key, ttl)` yourself. ## See Also diff --git a/docs/backends/redis.md b/docs/backends/redis.md index 00eb623f..012d534b 100644 --- a/docs/backends/redis.md +++ b/docs/backends/redis.md @@ -61,7 +61,7 @@ Query options in `redis_url` override the pool fields above, because redis-py's ### hiredis and the reply parser -redis-py uses the hiredis C parser when hiredis is installed, which `redis[hiredis]` always does. cachekit loads redis-py only for a program that uses its Redis backend, so with the variable unset, the default below never touches a program that uses L1 only, CachekitIO, File or Memcached. `true` is the exception: it blocks hiredis at `import cachekit` for every program, whichever backend it uses. +redis-py uses the hiredis C parser when hiredis is installed, which `redis[hiredis]` always does. cachekit loads redis-py only for a program that uses its Redis backend, so with the variable unset, the default below never touches a program that uses L1 only, CacheKit Cloud, File or Memcached. `true` is the exception: it blocks hiredis at `import cachekit` for every program, whichever backend it uses. `CACHEKIT_DISABLE_HIREDIS=true` keeps hiredis out and gives every connection redis-py's pure-Python parser. cachekit applies it at `import cachekit`, so import cachekit before redis; if hiredis is already loaded it logs a warning and the setting cannot take effect. `CACHEKIT_DISABLE_HIREDIS=false` keeps hiredis on every build. @@ -109,7 +109,7 @@ When the decorator resolves Redis from `CACHEKIT_REDIS_URL` / `REDIS_URL` (no `b - [Backend Guide](README.md) — Backend comparison and resolution priority - [Memcached Backend](memcached.md) — Alternative in-memory shared backend -- [CachekitIO Backend](cachekitio.md) — Managed SaaS alternative to self-hosted Redis +- [CacheKit Cloud Backend](cachekitio.md) — Managed alternative to self-hosted Redis - [Configuration Guide](../configuration.md) — Full environment variable reference --- diff --git a/docs/comparison.md b/docs/comparison.md index cf400ef4..5eb6dee3 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -44,7 +44,7 @@ Are you caching in Python? | **Upgrade Path** | None | None | Rewrite | Rewrite | Rewrite | ✅ Seamless | > [!NOTE] -> **Type preservation**: In L1-only mode (`backend=None`), Python types are preserved exactly — tuples, sets, frozensets all survive cache hits (raw object storage, no serialization). When using a distributed backend (Redis, Memcached, CachekitIO), the default `StandardSerializer` converts tuples to lists and cannot encode sets or frozensets (the result is returned uncached; see [Serialization unsupported type](error-codes.md#serialization-unsupported-type)), for cross-language SDK compatibility. +> **Type preservation**: In L1-only mode (`backend=None`), Python types are preserved exactly — tuples, sets, frozensets all survive cache hits (raw object storage, no serialization). When using a distributed backend (Redis, Memcached, CacheKit Cloud), the default `StandardSerializer` converts tuples to lists and cannot encode sets or frozensets (the result is returned uncached; see [Serialization unsupported type](error-codes.md#serialization-unsupported-type)), for cross-language SDK compatibility. > > **If you need type preservation with a distributed backend**, use `serializer='auto'`: > ```python @@ -104,7 +104,7 @@ def expensive_computation(x: int) -> dict: > **Why cachekit wins:** > - **L1+L2 caching**: L1 hits from process memory with no network round trip, an L1 miss that hits L2 costs one Redis round trip (see [Performance](performance.md)) > - **Graceful degradation**: Redis down? Errors are logged, the function runs, nothing raises -> - **Distributed locking**: Prevents cache stampedes across pods +> - **Distributed locking**: Best-effort guard against cache stampedes across pods, for async functions on a lock-capable backend (sync functions dedup within one process only) > - **Encryption**: with `@cache.secure`, client-side AES-256-GCM means Redis sees only ciphertext values (plain `@cache` does not encrypt) > - **Metrics**: Prometheus operation counts and latency and payload-size histograms, plus `cache_info()` hit/miss counters per function @@ -117,7 +117,8 @@ def get_user_data(user_id: int) -> dict: # Different pod: L2 hit from Redis (one network round trip) # Redis down: errors are logged and the function runs, doesn't crash # Circuit breaker open: L1 hits still served; a miss runs the function and returns its result - # Multiple pods calling simultaneously: Distributed lock prevents stampede + # Multiple pods calling simultaneously: each pod computes once (sync functions take no + # distributed lock; use an async function on a lock-capable backend for cross-pod protection) return fetch_user_from_db(user_id) ``` @@ -167,7 +168,7 @@ sequenceDiagram **Why cachekit wins**: - **Same code**: `@cache` decorator works with or without Redis -- **Pluggable backends**: Redis, CachekitIO, File, Memcached built-in, plus custom protocol +- **Pluggable backends**: Redis, CacheKit Cloud, File, Memcached built-in, plus custom protocol - **Graceful degradation**: Redis down? L1 cache serves, app continues - **No vendor lock-in**: Backend protocol is documented, implement your own @@ -213,13 +214,17 @@ def fetch_data(key): **Distributed Locking**: Prevents cache stampedes ```python -@cache(ttl=300) # Distributed locking enabled by default -def expensive_compute(key): +@cache(ttl=300) # Distributed locking on by default for async functions +async def expensive_compute(key): + # Needs a lock-capable backend: env-configured Redis or CacheKit Cloud # 1000 pods call simultaneously on cache miss - # Only ONE pod calls expensive_compute - # Others wait for L2 cache to be populated - # No cascade of simultaneous DB queries - return compute_expensive_value(key) + # The pod that wins the lock calls expensive_compute; the others wait up to + # blocking_timeout (5 s) for L2 to be populated, then re-check it and, if it + # is still empty, compute anyway. Best effort: a fill slower than 5 s, or one + # that lands between another pod's read and its lock request, means a second + # pod computes too (see features/distributed-locking.md) + # A sync function takes no distributed lock: misses dedup within one process only + return await compute_expensive_value(key) ``` **Limitations of alternatives**: @@ -268,14 +273,14 @@ The series carry no hit/miss label; `cache_info()` has those counts. cachekit st **Scenario**: You want distributed caching without standing up or operating Redis infrastructure. **Why cachekit wins**: -- **CachekitIOBackend**: Drop-in L2 backend backed by `api.cachekit.io` — no Redis cluster to provision, patch, or scale +- **CacheKit Cloud** (`CachekitIOBackend`): Drop-in L2 backend backed by `api.cachekit.io` — no Redis cluster to provision, patch, or scale - **Zero-knowledge compatible**: Pair with `@cache.secure` and the managed backend stores only ciphertext values, never plaintext values; the cache key stays cleartext ([details](features/zero-knowledge-encryption.md#cleartext-cache-key-accepted-exposure)) - **Same decorator API**: Swap backend by setting `CACHEKIT_API_KEY` — zero code changes ```python notest from cachekit import cache -# Uses cachekit.io as the L2 backend (CACHEKIT_API_KEY in environment) +# Uses CacheKit Cloud as the L2 backend (CACHEKIT_API_KEY in environment) @cache.io(ttl=3600) def get_user_data(user_id: int) -> dict: return fetch_from_db(user_id) @@ -509,7 +514,7 @@ A: Yes. Works with any framework (FastAPI, Django, Flask, etc). Metrics integrat A: The app keeps working. Redis errors are logged, and your function runs and returns its result. If Redis was down when the backend was first built, nothing is cached and repeated failures open the circuit breaker, so calls stop waiting on Redis. If Redis goes away later, L1 still caches results in-process. While the breaker is open, L1 hits are still served and a miss runs the function. **Q: Can I use my own backend?** -A: Yes. Four built-in backends (Redis, CachekitIO, File, Memcached) or implement the BaseBackend protocol (~100 LOC) for custom storage. +A: Yes. Four built-in backends (Redis, CacheKit Cloud, File, Memcached) or implement the BaseBackend protocol (~100 LOC) for custom storage. --- diff --git a/docs/configuration.md b/docs/configuration.md index e1c7f809..a66d41ab 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -16,11 +16,11 @@ cachekit supports four backends. Pick the one that fits your infrastructure: | Backend | Best For | Required Config | |---------|----------|----------------| | Redis | Self-hosted, full control | `CACHEKIT_REDIS_URL` | -| CachekitIO | Managed, zero-ops | `CACHEKIT_API_KEY` | +| CacheKit Cloud | Managed, zero-ops | `CACHEKIT_API_KEY` | | File | Local dev, testing | None | | Memcached | High-throughput, existing infra | `CACHEKIT_MEMCACHED_SERVERS` | -`@cache` picks its backend from env vars at a function's first call; with nothing configured it caches L1-only, in process, and connects to nothing ([details](backends/README.md#4-nothing-configured-l1-only)). CachekitIO is a managed alternative (currently in closed beta). File backend is intended for local development and testing only. Memcached is an optional backend (`pip install cachekit[memcached]`). +`@cache` picks its backend from env vars at a function's first call; with nothing configured it caches L1-only, in process, and connects to nothing ([details](backends/README.md#4-nothing-configured-l1-only)). CacheKit Cloud is a managed alternative (currently in closed beta). File backend is intended for local development and testing only. Memcached is an optional backend (`pip install cachekit[memcached]`). ### Redis Backend @@ -37,13 +37,13 @@ def fetch_data(): **Configuration:** See [Redis Environment Variables](#redis-connection-for-redisbackendconfig) below. -### CachekitIO Backend +### CacheKit Cloud Backend -> *cachekit.io is in closed beta — [request access](https://cachekit.io)* +> *CacheKit Cloud is in closed beta: sign-up is by waitlist at [dash.cachekit.io](https://dash.cachekit.io)* -Zero-ops managed caching via the cachekit.io SaaS API. No Redis to provision or maintain. +Zero-ops managed caching via the CacheKit Cloud API. No Redis to provision or maintain. -**Configuration:** See [CachekitIO Configuration](#cachekitio-configuration) below. +**Configuration:** See [CacheKit Cloud Configuration](#cachekit-cloud-configuration) below. ### File Backend @@ -118,13 +118,13 @@ REQUESTS_CA_BUNDLE= # Unset to avoid SSL issues --- -## CachekitIO Configuration +## CacheKit Cloud Configuration -> *cachekit.io is in closed beta — [request access](https://cachekit.io)* +> *CacheKit Cloud is in closed beta: sign-up is by waitlist at [dash.cachekit.io](https://dash.cachekit.io)* -Configure the CachekitIO managed backend through environment variables. All fields use the `CACHEKIT_` prefix. +Configure the CacheKit Cloud backend through environment variables. All fields use the `CACHEKIT_` prefix. -### CachekitIO Environment Variables +### CacheKit Cloud Environment Variables ```bash # Required: API key for authentication (ck_live_... format) @@ -149,7 +149,7 @@ CACHEKIT_TELEMETRY=false CACHEKIT_ALLOW_CUSTOM_HOST=false ``` -### CachekitIO Field Reference +### `CachekitIOBackendConfig` Field Reference | Variable | Type | Default | Required | Description | |----------|------|---------|----------|-------------| @@ -203,13 +203,13 @@ def report(): Rules and behavior: - Requires a positive `ttl`; `ttl + stale_ttl` is capped at 2,592,000 s (30 days). Violations raise `ConfigurationError` at decoration time. -- **CachekitIO only, known at decoration time** — `@cache.io` or an explicit `backend=CachekitIOBackend()`. Other backends have no read-side freshness signal and raise `ConfigurationError` if `stale_ttl` is set; so does a CachekitIO backend resolved lazily from `CACHEKIT_API_KEY` under another preset (the remaining-freshness bound below still applies to its reads). +- **CacheKit Cloud only, known at decoration time** — `@cache.io` or an explicit `backend=CachekitIOBackend()`. Other backends have no read-side freshness signal and raise `ConfigurationError` if `stale_ttl` is set; so does a CacheKit Cloud backend resolved lazily from `CACHEKIT_API_KEY` under another preset (the remaining-freshness bound below still applies to its reads). - Concurrent stale hits trigger at most one revalidation: per-process dedup plus (async functions) a non-blocking distributed lease on the backend's lock. Contested = serve stale, don't wait. - At most 32 revalidations per decorated function run at once. A stale hit that finds the limit reached serves stale without revalidating, and a later hit retries. An async revalidation stranded on a stopped event loop (sync code that calls `loop.run_until_complete` and leaves the loop stopped, or closes it without cancelling its tasks) gives up its turn 30 seconds after it was scheduled and is cancelled, so a stale hit on another loop can revalidate that key again. If its loop resumes, it stores nothing, even when your function swallows the cancellation and returns. A write it had already sent before its loop stopped is not recalled, and can land after a newer revalidation's. - A failed background recompute never reaches the caller: the entry keeps serving stale until its hard eviction bound, after which the next call takes the ordinary synchronous miss path. It logs a WARNING `SWR revalidation failed`, as does a revalidation skipped because the call's arguments cannot be deep-copied (`SWR revalidation skipped`) or one that could not be scheduled. It names the function by the same digest as the L1-only refresh WARNING. Each fires at most once a minute per function and carries the count since the last one; the occurrences in between log at DEBUG. - The background recompute runs with a **snapshot of the caller's `contextvars`** (contextvar-based tenant extraction works), but outside the request otherwise — don't rely on other request-scoped resources (open sessions, connections) inside functions that enable SWR. - Stale values are never written to the L1 in-memory cache, and stale reads still count as cache **hits** for metered-misses billing. -- On the CachekitIO backend, every read (SWR-configured or not) also carries the server's remaining freshness (`X-CacheKit-Fresh-For`, [protocol spec](https://github.com/cachekit-io/protocol/blob/main/spec/saas-api.md#remaining-freshness)): an L2 hit backfilled into L1 lives at most `min(ttl, remaining)` locally (with `ttl=None`, L1's own 300-second default lifetime, capped by `remaining`), so a value read near the end of its server-side freshness window is never served fresh from L1 past the server's bound. Pre-signal servers omit the header and behavior is unchanged. +- On the CacheKit Cloud backend, every read (SWR-configured or not) also carries the server's remaining freshness (`X-CacheKit-Fresh-For`, [protocol spec](https://github.com/cachekit-io/protocol/blob/main/spec/saas-api.md#remaining-freshness)): an L2 hit backfilled into L1 lives at most `min(ttl, remaining)` locally (with `ttl=None`, L1's own 300-second default lifetime, capped by `remaining`), so a value read near the end of its server-side freshness window is never served fresh from L1 past the server's bound. Pre-signal servers omit the header and behavior is unchanged. ### File Backend Environment Variables @@ -379,7 +379,7 @@ def test_function(): def secure_function(): pass -# cachekit.io SaaS - production-grade via managed API (closed beta) +# CacheKit Cloud - production-grade via managed API (closed beta) # Requires: CACHEKIT_API_KEY env var # @cache.io(ttl=300) # def io_function(): @@ -395,7 +395,7 @@ def secure_function(): | `dev()` | 300 s | L1-only¹ | 100 MB | Function runs, no error | Verbose logs, no cache-operation Prometheus series ([which still record](features/prometheus-metrics.md#series-that-ignore-monitoringconfig)) | | `production()` | 600 s | L1-only¹ | 100 MB | Function runs, no error | Full observability | | `secure()` | 600 s | ❌ | 100 MB | Function runs, no error | AES-256-GCM encryption required; refuses `backend=None`, so no L1-only SWR | -| `io()` | 3600 s | ✓ | 100 MB | Function runs, no error | CachekitIO managed SaaS backend (closed beta — [request access](https://cachekit.io)); past-TTL [SWR](#stale-while-revalidate-stale_ttl) default-on (`stale_ttl = ttl`) | +| `io()` | 3600 s | ✓ | 100 MB | Function runs, no error | CacheKit Cloud backend (closed beta: sign-up is by waitlist at [dash.cachekit.io](https://dash.cachekit.io)); past-TTL [SWR](#stale-while-revalidate-stale_ttl) default-on (`stale_ttl = ttl`) | **Default TTL** is fixed by the cross-SDK [intent-preset spec](https://github.com/cachekit-io/protocol/blob/main/spec/intent-presets.md#default-ttl) so a `production` entry expires at the same moment in Python, Rust and TypeScript; `dev()` / `test()` are SDK-local, Python-only presets and take `minimal`'s 300 s. Pass `ttl=` to override, or `ttl=None` to opt in to never-expire explicitly. The spec forbids a process-wide TTL override, so there is no `CACHEKIT_DEFAULT_TTL` (the name is reserved and ignored). Two consequences of a finite default: an entry this process writes also lives in its L1 for the same TTL (previously L1's own 300 s when no `ttl` was set), and the presets' `swr_enabled` / `stale_ttl` features — which need a positive `ttl` — are now active without an explicit `ttl=`. @@ -428,7 +428,7 @@ export CACHEKIT_REDIS_URL=redis://prod.example.com:6379/0 export REDIS_URL=redis://localhost:6379/0 # Ignored - won't be used ``` -### CachekitIO Config is Separate +### CacheKit Cloud Config is Separate `@cache.io()` reads from `CachekitIOBackendConfig` — a completely separate config class from `RedisBackendConfig`. Redis URL precedence does not apply. @@ -438,7 +438,7 @@ export REDIS_URL=redis://localhost:6379/0 # Ignored - won't be used | `@cache.io()` | `CachekitIOBackendConfig` | `CACHEKIT_API_KEY` | Setting `REDIS_URL` has no effect on `@cache.io()`. `CACHEKIT_API_KEY` is different: it is also -one of the backend selectors, so a decorator without `backend=` resolves to cachekit.io from it, +one of the backend selectors, so a decorator without `backend=` resolves to CacheKit Cloud from it, and with `CACHEKIT_REDIS_URL` also set it hits a selector conflict and runs uncached. To keep Redis-backed decorators beside `@cache.io()`, pass `backend=` to them, or pass `@cache.io()` its key as `api_key=`, read from your secret store (never a literal in source), instead of setting @@ -656,11 +656,11 @@ export CACHEKIT_ARROW_COMPRESSION=zstd ### Connection Pooling -One variable sizes the pool of whichever backend is in use: Redis defaults to 50 connections, CachekitIO to 32. A Redis operation that finds every connection in use waits up to the socket timeout for one, then fails as a cache miss. That timeout is `CACHEKIT_SOCKET_TIMEOUT`, unless the Redis URL sets `?socket_timeout=`, which wins. A CachekitIO sync request holds a connection for its whole round trip (HTTP/1.1), so the pool caps how many run at once: size it to at least the number of threads that share one backend ([details](backends/cachekitio.md#characteristics)). +One variable sizes the pool of whichever backend is in use: Redis defaults to 50 connections, CacheKit Cloud to 32. A Redis operation that finds every connection in use waits up to the socket timeout for one, then fails as a cache miss. That timeout is `CACHEKIT_SOCKET_TIMEOUT`, unless the Redis URL sets `?socket_timeout=`, which wins. A CacheKit Cloud sync request holds a connection for its whole round trip (HTTP/1.1), so the pool caps how many run at once: size it to at least the number of threads that share one backend ([details](backends/cachekitio.md#characteristics)). ```bash # Tune connection pool size based on concurrency -export CACHEKIT_CONNECTION_POOL_SIZE=100 # Defaults: Redis 50, CachekitIO 32 +export CACHEKIT_CONNECTION_POOL_SIZE=100 # Defaults: Redis 50, CacheKit Cloud 32 # Higher for: # - Many concurrent requests diff --git a/docs/data-flow-architecture.md b/docs/data-flow-architecture.md index 2d9467cd..c94127a3 100644 --- a/docs/data-flow-architecture.md +++ b/docs/data-flow-architecture.md @@ -30,7 +30,7 @@ cachekit uses a hybrid Python-Rust architecture to provide production caching wi > [!TIP] > **Architecture Principles:** > - **L1 + L2 Caching**: In-memory bytes cache (L1) + pluggable backend storage (L2) for optimal performance -> - **Backend Abstraction**: Protocol-based L2 backend layer (Redis, CachekitIO, File, Memcached, custom) +> - **Backend Abstraction**: Protocol-based L2 backend layer (Redis, CacheKit Cloud, File, Memcached, custom) > - **Bytes-Only Storage**: with a backend configured, L1 stores serialized bytes (encrypted or plaintext), not Python objects; L1-only mode (`backend=None`) stores the object itself. After its first hit, a plaintext entry of the default or auto serializer with integrity checking on is held as its verified MessagePack where that is no longer than the envelope, so later hits skip the decompress and checksum, and an orjson entry as its checksummed JSON, so later hits skip the checksum; every hit still runs the bounded decode (see [Storage Architecture](#storage-architecture)); encrypted caches keep the ciphertext > - **Security First**: Decompression bomb protection, checksum validation, size limits, encrypted-at-rest > - **Performance Optimized**: Thread affinity, connection pooling, pre-cached serializers @@ -56,7 +56,7 @@ cachekit uses a hybrid Python-Rust architecture to provide production caching wi │ @cache.minimal # Speed-critical (low latency) │ │ @cache.production # Reliability-critical (circuit breaker) │ │ @cache.secure # Security-critical (encrypted L1+L2) │ -│ @cache(l1=True, l2=None) # L1-only mode (local dev, no Redis) │ +│ @cache(backend=None) # L1-only mode (local dev, no Redis) │ │ │ │ Intent Resolution → Config Dictionary (includes l1_enabled, l2_backend) │ └──────────────────────────────────┬──────────────────────────────────────────┘ @@ -218,8 +218,8 @@ cachekit uses a hybrid Python-Rust architecture to provide production caching wi ├─────────────────────────────────────────────────────────────────────────────┤ │ # After acquiring lock, check cache again │ │ # Another thread may have populated it while we waited │ -│ # On an async miss, CachekitIO and the tenant-scoped Redis backend skip it │ -│ # when the first lock attempt won after a clean miss │ +│ # On an async miss, CacheKit Cloud and the tenant-scoped Redis backend │ +│ # skip it when the first lock attempt won after a clean miss │ │ │ │ cached_result = get_cached_value(cache_key) │ │ if cached_result: │ @@ -384,7 +384,7 @@ def custom_function(): | **secure** | ✓ | ✓ | Resolved² | ✓ | Encrypted sensitive data (L1 stores ciphertext) | | **dev** | ✓ | ✓ | Resolved¹ | ✗ | Local development | | **test** | ✗ | ✓ | Resolved¹ | ✗ | Test environments | -| **io** | ✓ | ✓ | CachekitIOBackend | ✓ | Managed cloud backend (cachekit.io) | +| **io** | ✓ | ✓ | `CachekitIOBackend` | ✓ | Managed backend (CacheKit Cloud) | ¹ Per [Backend Resolution Priority](backends/README.md#backend-resolution-priority); with nothing configured, L1-only. ² As ¹, but never L1-only: with nothing configured, Redis at `redis://localhost:6379`. @@ -405,7 +405,7 @@ Trailing suffix: `1`/`0` for integrity checking, then a one-character serializer (`s` standard, `a` auto, `o` orjson, `w` arrow, `l` reference/local; `x` plus four hex digits for any serializer passed as an instance). See [Changing Serializers](serializers/README.md#changing-serializers-separate-keyspaces). -> **SDK convention, not a server contract.** The CachekitIO backend validates +> **SDK convention, not a server contract.** The CacheKit Cloud backend validates > keys security-only (length cap, charset whitelist, `..` rejection, > `ns:{namespace}:` prefix shape) and otherwise stores them as opaque strings — > the `func:`/`args:` structure exists for deterministic generation and @@ -528,7 +528,7 @@ class BaseBackend(Protocol): - **Bytes-only interface**: Language-agnostic, no Python-specific types - **Stateless operations**: No connection management in protocol - **Simple and focused**: Four operations only (get/set/delete/exists) -- **Works for any backend**: Redis, CachekitIO, File, Memcached, custom implementations +- **Works for any backend**: Redis, CacheKit Cloud, File, Memcached, custom implementations ### RedisBackend diff --git a/docs/error-codes.md b/docs/error-codes.md index 886f1969..8ce3533a 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -4,7 +4,7 @@ The errors cachekit raises or logs, and how to fix them. cachekit has no numeric error codes: catch the class shown under **Exception**. -Configuration errors raise when the decorator is applied. Backend failures (connection, timeout, CachekitIO HTTP errors), serialization, deserialization, circuit-breaker and lock failures do not raise to the caller: a `@cache`-decorated call logs the failure and runs the function. Where that holds, the entry reads **Exception**: none. Two exceptions to that rule: +Configuration errors raise when the decorator is applied. Backend failures (connection, timeout, CacheKit Cloud HTTP errors), serialization, deserialization, circuit-breaker and lock failures do not raise to the caller: a `@cache`-decorated call logs the failure and runs the function. Where that holds, the entry reads **Exception**: none. Two exceptions to that rule: - Decryption failures raise only when fail-closed is on. - With `interop=...`, a return value the interop data model can't represent raises `InteropError`. @@ -571,7 +571,7 @@ One specific cause worth naming: `... envelope format 'X' disagrees with header **Exception**: none: while the breaker is open, a function with an L2 backend still serves L1 hits, skips L2, and runs uncached on an L1 miss, sync or async. An L1 hit is never a probe and records no outcome, so it neither holds the breaker open nor closes it. In L1-only mode (`backend=None`) the breaker is never consulted. -**Cause**: Five failures within a 60-second rolling window (five is the default [`failure_threshold`](features/circuit-breaker.md)) — successes do not reset the count, and a failure older than 60 seconds stops counting. Backend read and write failures (connection errors, timeouts, CachekitIO HTTP errors) do not currently count: they are logged, a failed read is a miss, and a failed write skips only L2. With L1 enabled (the default) a buffered write still stores the result in L1, so later calls in the same process can hit it. A sync function using the plaintext Arrow serializer on the File backend streams its writes instead, and a streamed write never goes to L1, so after a failed one the next call recomputes. What counts is a failure to create the backend client and, for async functions only, a result too large to cache (over `max_value_size`) or a multi-tenant encrypted write whose tenant id cannot be extracted. An exception raised by the decorated function never counts, and neither does an `InteropError` for a return value the interop data model can't represent: both reach the caller unchanged, sync or async. A result that fails to serialize or encrypt for the cache write never counts, sync or async: the write is skipped with an ERROR and a WARNING log line, and the function's result is returned uncached. A keyring configuration fault (`KeyringConfigurationError`) never counts, whether the cache write raised it or the decorated function did (from a nested cached call, for example): it reaches the caller, sync or async. A cached entry that fails to deserialize, decrypt or pass its integrity check, or that an encrypting reader refuses, does not count under either policy (see *Deserialization failed* and *Decryption failed*). Under fail-open, the default, it is a miss: cachekit attempts to evict it (best effort: a failed delete is only logged, and the entry stays) and the function recomputes. With `fail_closed=True`, an authentication failure raises `DecryptionAuthenticationError` and keeps the entry as evidence; any other such failure is a miss, as under fail-open. Each decorated function has its own breaker, and that many counted failures open it even when the backend is healthy. +**Cause**: Five failures within a 60-second rolling window (five is the default [`failure_threshold`](features/circuit-breaker.md)) — successes do not reset the count, and a failure older than 60 seconds stops counting. Backend read and write failures (connection errors, timeouts, CacheKit Cloud HTTP errors) do not currently count: they are logged, a failed read is a miss, and a failed write skips only L2. With L1 enabled (the default) a buffered write still stores the result in L1, so later calls in the same process can hit it. A sync function using the plaintext Arrow serializer on the File backend streams its writes instead, and a streamed write never goes to L1, so after a failed one the next call recomputes. What counts is a failure to create the backend client and, for async functions only, a result too large to cache (over `max_value_size`) or a multi-tenant encrypted write whose tenant id cannot be extracted. An exception raised by the decorated function never counts, and neither does an `InteropError` for a return value the interop data model can't represent: both reach the caller unchanged, sync or async. A result that fails to serialize or encrypt for the cache write never counts, sync or async: the write is skipped with an ERROR and a WARNING log line, and the function's result is returned uncached. A keyring configuration fault (`KeyringConfigurationError`) never counts, whether the cache write raised it or the decorated function did (from a nested cached call, for example): it reaches the caller, sync or async. A cached entry that fails to deserialize, decrypt or pass its integrity check, or that an encrypting reader refuses, does not count under either policy (see *Deserialization failed* and *Decryption failed*). Under fail-open, the default, it is a miss: cachekit attempts to evict it (best effort: a failed delete is only logged, and the entry stays) and the function recomputes. With `fail_closed=True`, an authentication failure raises `DecryptionAuthenticationError` and keeps the entry as evidence; any other such failure is a miss, as under fail-open. Each decorated function has its own breaker, and that many counted failures open it even when the backend is healthy. **What it means**: - A failure listed under **Cause** has occurred `failure_threshold` times (five by default) within 60 seconds @@ -701,7 +701,7 @@ export CACHEKIT_DEPLOYMENT_UUID=$(python -c 'import uuid; print(uuid.uuid4())') **Message** (logged): `Failed to acquire lock for ... after 5.0s, checking cache` -**Exception**: none. On a cache miss, an async `@cache` function on CachekitIO, or Redis configured via `CACHEKIT_REDIS_URL`, waits about 5 seconds (plus request time) for the per-key lock. If another process still holds it, cachekit checks the cache once more, then runs the function itself. +**Exception**: none. On a cache miss, an async `@cache` function on CacheKit Cloud, or Redis configured via `CACHEKIT_REDIS_URL`, waits about 5 seconds (plus request time) for the per-key lock. If another process still holds it, cachekit checks the cache once more, then runs the function itself. **Cause**: Distributed lock could not be acquired (another process holds the lock) @@ -720,9 +720,9 @@ redis-cli DEL --- -## CachekitIO HTTP Errors +## CacheKit Cloud HTTP Errors -These errors occur when using `@cache.io()` with the CachekitIO SaaS backend. None raises, for sync or async functions: each HTTP failure becomes a `BackendError` with a `BackendErrorType`, is logged as described under *Connection Errors*, and the function runs; its result is still stored in L1. One case is retried first: a write or delete (a `PUT` or `DELETE`, the lock release included) that the server answers `503` with a `Retry-After` of 2 seconds or less is sent once more, inline, after exactly that delay; see [Server error (5xx)](#server-error-5xx). Nothing else is retried. A failed read is treated as a miss, so the decorator still tries to write the function's result to the cache afterwards, and a failed write is logged the same way. These failures do not count toward the circuit breaker. +These errors occur when using `@cache.io()` with the CacheKit Cloud backend. None raises, for sync or async functions: each HTTP failure becomes a `BackendError` with a `BackendErrorType`, is logged as described under *Connection Errors*, and the function runs; its result is still stored in L1. One case is retried first: a write or delete (a `PUT` or `DELETE`, the lock release included) that the server answers `503` with a `Retry-After` of 2 seconds or less is sent once more, inline, after exactly that delay; see [Server error (5xx)](#server-error-5xx). Nothing else is retried. A failed read is treated as a miss, so the decorator still tries to write the function's result to the cache afterwards, and a failed write is logged the same way. These failures do not count toward the circuit breaker. On a miss, an async function first requests the per-key lock (see [Distributed Locking](features/distributed-locking.md)). Any HTTP failure of that request ends the lock wait at once: the function runs without the lock, and cachekit logs `Lock operation failed … executing without lock`. Only a lock another caller holds is waited on. @@ -779,7 +779,7 @@ def get_data(): return fetch() ``` -2. **Upgrade tier**: Increase plan limits at [cachekit.io](https://cachekit.io) +2. **Upgrade tier**: Increase plan limits in the dashboard at [dash.cachekit.io](https://dash.cachekit.io) 3. **Review cache key design**: Overly granular keys cause unnecessary misses @@ -791,7 +791,7 @@ def get_data(): **Exception**: none — logged as `BackendError` (`BackendErrorType.TRANSIENT`) -**Cause**: Transient server-side error at the CachekitIO API +**Cause**: Transient server-side error at the CacheKit Cloud API **Behavior**: TRANSIENT — logged; the function runs and its result is still stored in L1. @@ -814,7 +814,7 @@ def my_function(): ### Client error (4xx) -**Message**: `Client error: HTTP 400` (or 404, etc.). A 413 reads `Value too large for cachekit.io backend (HTTP 413): value exceeds the server's maximum cache value size`. +**Message**: `Client error: HTTP 400` (or 404, etc.). A 413's message names `HTTP 413` and ends `value exceeds the server's maximum cache value size`. **Exception**: none — logged as `BackendError` (`BackendErrorType.PERMANENT`) @@ -830,7 +830,7 @@ def my_function(): **Exception**: none — logged as `BackendError` (`BackendErrorType.TIMEOUT`) -**Cause**: HTTP request to the CachekitIO API exceeded the configured timeout +**Cause**: HTTP request to the CacheKit Cloud API exceeded the configured timeout **Behavior**: TIMEOUT — logged; the function runs and its result is still stored in L1. @@ -869,7 +869,7 @@ echo $CACHEKIT_API_URL --- -### CachekitIO error classification summary +### CacheKit Cloud error classification summary | HTTP Status / Exception | `BackendErrorType` | |---|---| @@ -878,7 +878,7 @@ echo $CACHEKIT_API_URL | 5xx | `TRANSIENT` | | 413, other 4xx | `PERMANENT` | | urllib3 `ConnectTimeoutError`, `ReadTimeoutError` | `TIMEOUT` | -| urllib3 `NewConnectionError`, `ProtocolError`, `SSLError`, `ProxyError` | `TRANSIENT` (a failed certificate verification says so: see `SSL_CERT_FILE` in [CachekitIO](backends/cachekitio.md#characteristics)) | +| urllib3 `NewConnectionError`, `ProtocolError`, `SSLError`, `ProxyError` | `TRANSIENT` (a failed certificate verification says so: see `SSL_CERT_FILE` in [CacheKit Cloud Backend](backends/cachekitio.md#characteristics)) | | Other | `UNKNOWN` | No type counts toward the circuit breaker. Only a `503` to a write or delete with a `Retry-After` of 2 seconds or less is retried, once, for sync and async functions alike (see [Server error (5xx)](#server-error-5xx)). A failed lock request is not retried: it ends the lock wait, as the note at the top of this section says. diff --git a/docs/features/circuit-breaker.md b/docs/features/circuit-breaker.md index 53d730c9..05940a24 100644 --- a/docs/features/circuit-breaker.md +++ b/docs/features/circuit-breaker.md @@ -337,9 +337,9 @@ Redis error → Logged: a failed read is a miss, a failed write skips L2 only, a **Circuit Breaker + Distributed Locking**: ```python notest @cache(ttl=300) # Both features enabled (they need an L2 backend) -def fetch(key): - # L2 miss → Distributed lock acquired - # Only one pod calls fetch() +async def fetch(key): # The distributed lock is async-only: a sync function never takes it + # L2 miss → Distributed lock acquired (on a lock-capable backend) + # Usually only one pod calls fetch(); best effort, see distributed-locking.md # If L2 fails → logged; backend errors do not currently count toward the breaker # Each pod runs fetch() on its own L1 miss and keeps the result in its L1 return db.fetch(key) # illustrative - not defined diff --git a/docs/features/distributed-locking.md b/docs/features/distributed-locking.md index ce418e01..b965593e 100644 --- a/docs/features/distributed-locking.md +++ b/docs/features/distributed-locking.md @@ -8,7 +8,7 @@ ## TL;DR -Distributed locking prevents "cache stampede" - when multiple pods simultaneously call an expensive function on cache miss. With locking, only one pod calls the function; others wait for the cache result. Inside one pod, callers that miss a key while its call runs share that call's trip instead of each reaching the lock ([in-process single-flight](#in-process-single-flight-before-the-lock)). For async callers, sharing holds only on the event loop running the key's call: a caller on another thread's loop runs its own trip and takes the lock itself. +Distributed locking prevents "cache stampede" - when multiple pods simultaneously call an expensive function on cache miss. With locking, usually only one pod calls the function; others wait for the cache result, for up to 5 s. This is best effort, not a guarantee: a fill slower than 5 s (see [Lock Timing](#lock-timing-30-second-expiry-5-second-wait)), or one that lands between another pod's read and its lock request (see [Round Trips on an Async Miss](#round-trips-on-an-async-miss)), lets a second pod compute too. Inside one pod, callers that miss a key while its call runs share that call's trip instead of each reaching the lock ([in-process single-flight](#in-process-single-flight-before-the-lock)). For async callers, sharing holds only on the event loop running the key's call: a caller on another thread's loop runs its own trip and takes the lock itself. ```python @cache(ttl=300) # Distributed locking enabled by default (via LockableBackend) @@ -16,8 +16,9 @@ async def expensive_query(key): return db.expensive_query(key) # 1000 pods call simultaneously on L2 miss -# Only 1 pod calls expensive_query() -# 999 pods wait for L2 cache to be populated +# Usually only 1 pod calls expensive_query(); the rest wait up to 5 s (blocking_timeout) +# for L2 to be populated. Best effort: a fill slower than 5 s, or one that lands +# between another pod's read and its lock request, means a second pod computes too ``` --- @@ -34,14 +35,14 @@ async def get_report(date): return db.generate_report(date) # Expensive operation # Multiple pods calling simultaneously on cache miss -# Only one executes generate_report() +# Usually only one executes generate_report() (best effort: a fill slower than 5 s, or one that lands between another pod's read and its lock request, means a second pod computes too) report = await get_report("2025-01-15") ``` > [!NOTE] -> Locking requires **both** of: +> Locking requires **all three** of: > -> 1. A backend implementing the `LockableBackend` protocol. `CachekitIOBackend` (the SaaS backend behind `api.cachekit.io`) does, and so does the tenant-scoped Redis backend you get from env auto-detection or `RedisBackendProvider(...).get_shared_backend()`. A `RedisBackend` you construct yourself and pass as `backend=` does **not** — it has no `acquire_lock`. `FileBackend` and pure-L1 (zero-config) caching do not either. All of them silently skip lock acquisition; the function still works, just without cross-process stampede protection. Concurrent misses inside one process still share one call: sync callers across the process's threads, async callers only on the event loop running the key's call ([in-process single-flight](#in-process-single-flight-before-the-lock)). +> 1. A backend implementing the `LockableBackend` protocol. `CachekitIOBackend` (the CacheKit Cloud backend behind `api.cachekit.io`) does, and so does the tenant-scoped Redis backend you get from env auto-detection or `RedisBackendProvider(...).get_shared_backend()`. A `RedisBackend` you construct yourself and pass as `backend=` does **not** — it has no `acquire_lock`. `FileBackend` and pure-L1 (zero-config) caching do not either. All of them silently skip lock acquisition; the function still works, just without cross-process stampede protection. Concurrent misses inside one process still share one call: sync callers across the process's threads, async callers only on the event loop running the key's call ([in-process single-flight](#in-process-single-flight-before-the-lock)). > 2. An **async** decorated function. Sync wrappers never take the lock path on any backend — see [Async-only](#async-only-sync-functions-are-never-lock-protected). > 3. The function has not opted out with `distributed_lock=False` — see [Turning It Off](#turning-it-off-distributed_lockfalse). @@ -76,7 +77,7 @@ Sync callers share a call across the threads of one process; async callers share The `LockableBackend` protocol is async-only (`acquire_lock` is an async context manager), so **only async decorated functions get distributed locking**. The sync wrapper executes the function directly on cache miss — on every backend, including -Redis and CachekitIO — with no lock. Inside one process, concurrent sync misses on a +Redis and CacheKit Cloud — with no lock. Inside one process, concurrent sync misses on a key still share one trip ([in-process single-flight](#in-process-single-flight-before-the-lock)): twelve threads on a cold key mean one L2 read, one recompute and one write. Across processes nothing dedups them: twelve processes that each miss the key at once mean @@ -174,7 +175,7 @@ Three behavioural edges to design around: the lock expires while the winner is still computing and another pod may start a concurrent recompute. This applies whether the holder is slow or crashed — the expiry is the crash-recovery safety net (Redis key TTL / - CachekitIO server-side expiry). Keep expensive functions well under 30 s, + CacheKit Cloud server-side expiry). Keep expensive functions well under 30 s, or split the work. 3. **Lock backend errors degrade to no lock.** If lock acquisition raises a backend error (lock backend outage, authentication failure), the wrapper @@ -183,7 +184,7 @@ Three behavioural edges to design around: running the key's call share its trip; a caller on another thread's loop runs its own ([in-process single-flight](#in-process-single-flight-before-the-lock)). A failed lock request is not retried; only a lock another caller holds is waited on. A failed release never runs the - function again: the Redis and CachekitIO backends swallow a backend error + function again: the Redis and CacheKit Cloud backends swallow a backend error on release, any other release failure is logged and the call keeps its result, and the lock may stay until its 30 s timeout. The lock is best-effort stampede mitigation, never load-bearing mutual exclusion: do @@ -266,8 +267,9 @@ async def get_leaderboard(): return db.expensive_leaderboard_query() # 1000 users request leaderboard simultaneously -# Only 1 computes leaderboard -# 999 wait for result +# Usually only 1 computes leaderboard; the rest wait up to 5 s (blocking_timeout) +# for the result. Best effort: a fill slower than 5 s, or one that lands between +# another pod's read and its lock request, means a second pod computes too leaderboard = await get_leaderboard() ``` @@ -287,7 +289,7 @@ async def generate_stats(date): return stats_engine.compute(date) ``` -### With CachekitIO Backend (SaaS) +### With the CacheKit Cloud Backend ```python notest from cachekit import cache from cachekit.backends.cachekitio import CachekitIOBackend @@ -299,7 +301,7 @@ async def generate_stats(date): return stats_engine.compute(date) ``` -The SaaS lock is available to every authenticated API key — no extra +The CacheKit Cloud lock is available to every authenticated API key — no extra configuration or plan tier required. ### Turning It Off (`distributed_lock=False`) @@ -322,10 +324,10 @@ What changes on an async miss: | | `distributed_lock=True` (default) | `distributed_lock=False` | |---|---|---| -| Lock requests (CachekitIO) | Uncontended: one `POST …/lock` the caller waits on, one `DELETE …/lock` in the background | None | +| Lock requests (CacheKit Cloud) | Uncontended: one `POST …/lock` the caller waits on, one `DELETE …/lock` in the background | None | | Lock round trips (tenant-scoped Redis) | Uncontended: one `SET NX` the caller waits on, one release `EVALSHA` in the background | None | | Concurrent misses on one event loop | One call ([single-flight](#in-process-single-flight-before-the-lock)) | Single-flight is unchanged: within a process, only callers on the event loop running the key's call share it. While it runs, every caller on another thread's loop runs its own call (ten such callers, ten calls) | -| Concurrent misses across processes | One call per key, within the [lock timing](#lock-timing-30-second-expiry-5-second-wait) limits. On CachekitIO and Redis, a fill that lands between a process's clean miss and its uncontended lock grant is recomputed there ([no re-read](#round-trips-on-an-async-miss)) | At least one call per process that misses, each computing and writing | +| Concurrent misses across processes | One call per key, within the [lock timing](#lock-timing-30-second-expiry-5-second-wait) limits. On CacheKit Cloud and Redis, a fill that lands between a process's clean miss and its uncontended lock grant is recomputed there ([no re-read](#round-trips-on-an-async-miss)) | At least one call per process that misses, each computing and writing | | [Write-behind](write-behind.md) | Not applied on the lock path: the L2 write stays synchronous, including after a timed-out lock wait. Write-behind can defer it only when the miss falls through to the unlocked path: a backend error while acquiring the lock, or a backend that cannot lock | Applied when on: the L2 write leaves the caller's path | The trade-off is cross-process stampede protection. Turn it off when the function is cheap or idempotent and a @@ -372,7 +374,7 @@ The decorator wrapper calls it with `timeout=30.0` (lock self-expiry) and Lock auto-expires via Redis TTL if holder crashes ``` -**Lock flow (CachekitIO / SaaS)**: +**Lock flow (CacheKit Cloud)**: ``` 1. POST /v1/cache/{key}/lock with {"timeout_ms": 30000} 2. Response carries a lock_id → lock acquired, yield True @@ -385,9 +387,9 @@ The decorator wrapper calls it with `timeout=30.0` (lock self-expiry) and ``` ### Performance Impact -- **Lock already held**: Waiters poll for up to `blocking_timeout` (5 s) — fixed 0.1 s interval on Redis, exponential backoff + jitter on CachekitIO +- **Lock already held**: Waiters poll for up to `blocking_timeout` (5 s) — fixed 0.1 s interval on Redis, exponential backoff + jitter on CacheKit Cloud - **Lock acquisition**: one backend round trip per attempt (`SET NX` on Redis) -- **Lock release**: one backend round trip (`DELETE …/lock` on CachekitIO; on Redis, once the release script is cached on the server) +- **Lock release**: one backend round trip (`DELETE …/lock` on CacheKit Cloud; on Redis, once the release script is cached on the server) - **Waiting cost**: Function execution cost saved * (pods_waiting - 1) **Example**: 1000 pods, 3s function call, 999 waiting diff --git a/docs/features/interop-mode.md b/docs/features/interop-mode.md index a69b2383..203b62e9 100644 --- a/docs/features/interop-mode.md +++ b/docs/features/interop-mode.md @@ -50,7 +50,7 @@ Default behavior is completely unchanged: functions that don't pass `interop=` k | `@cache.production(interop=..., ...)` | ✅ Spec-identical | **Recommended.** Reliability profile (circuit breaker, monitoring) affects runtime only, never bytes | | `@cache.minimal(interop=..., ...)` | ✅ Spec-identical | Its `integrity_checking=False` is a no-op here — see [Encryption](#encryption) | | `@cache.secure(interop=..., ...)` | ✅ Spec-identical ciphertext | Encrypted interop bytes; cross-SDK readable with the same master key (and the same explicit tenant, if one is configured) | -| `@cache.io(interop=..., ...)` | ✅ Spec-identical | The CachekitIO key validator accepts interop keys ([spec → SaaS Considerations](https://github.com/cachekit-io/protocol/blob/main/spec/interop-mode.md#saas-considerations)) | +| `@cache.io(interop=..., ...)` | ✅ Spec-identical | The CacheKit Cloud key validator accepts interop keys ([spec → CacheKit Cloud considerations](https://github.com/cachekit-io/protocol/blob/main/spec/interop-mode.md#saas-considerations)) | | `@cache.local(...)` / `@cache(backend=None)` | ❌ Rejected loudly | No shared medium: `.local` raises `TypeError` (it accepts no `interop=`), `backend=None` raises `ConfigurationError` at decoration time | **Recommended form** — spec-identical bytes plus the production reliability profile: @@ -91,7 +91,7 @@ Treat operation names like queue names or topic names: a **cross-team contract** The contract for one operation is the operation name **plus** the effective argument list (arity, order, types): -- `namespace` and `operation` must match `^[a-z0-9][a-z0-9._-]{0,63}$` (lowercase only — enforced loudly at decoration time, never silently normalized). `ns` and `nsapi` are reserved as namespaces (operations, and namespaces such as `nsx`, are unaffected), because the CachekitIO server parses a key starting `ns:` or `nsapi:` as namespace-prefixed ([cache-key-format.md → Server-Side Requirements](https://github.com/cachekit-io/protocol/blob/main/spec/cache-key-format.md#server-side-requirements)). Neither segment may contain `..` (`a..b` raises; a lone `.`, as in `app.v1`, is fine), because the server rejects `..` anywhere in a key. Both rules apply on every backend, so a namespace valid on one backend is valid on all. +- `namespace` and `operation` must match `^[a-z0-9][a-z0-9._-]{0,63}$` (lowercase only — enforced loudly at decoration time, never silently normalized). `ns` and `nsapi` are reserved as namespaces (operations, and namespaces such as `nsx`, are unaffected), because the CacheKit Cloud server parses a key starting `ns:` or `nsapi:` as namespace-prefixed ([cache-key-format.md → Server-Side Requirements](https://github.com/cachekit-io/protocol/blob/main/spec/cache-key-format.md#server-side-requirements)). Neither segment may contain `..` (`a..b` raises; a lone `.`, as in `app.v1`, is fine), because the server rejects `..` anywhere in a key. Both rules apply on every backend, so a namespace valid on one backend is valid on all. - A `str` subclass is checked and keyed as its plain `str` value, so a `StrEnum` or `(str, Enum)` member `USERS = "users"` is the namespace `users`. Earlier releases put a `(str, Enum)` member's formatted name into the key on Python 3.11 and later (`NS.USERS:get_user:…`, a key no other SDK derives). Upgrading moves those functions to the correct key. The old entries are not deleted: they retire only by TTL (never, if none was set). To erase them on Redis, `SCAN` for the old prefix (here `NS.USERS:get_user:*`) and `UNLINK` the matches. - Named arguments bind to their declared positions and **introspectable defaults are applied**: `get_user(42)`, `get_user(user_id=42)` and `get_user(42, include_profile=False)` all produce the same key. - Arguments must fit the closed interop data model (int in `[-2^63, 2^64-1]`, float, str, bytes, bool, None, list/tuple, dict with str keys, set, tz-aware datetime, UUID; Python conveniences: Enum → value, Path → POSIX string, Decimal → string). Anything else raises `InteropError` **at call time** — interop mode never silently degrades to uncached execution. A subclass of `str`, `int`, `float`, `bytes` or `bytearray` is keyed as its plain base-type value, whatever methods it overrides; a dict whose keys become equal that way (possible only through an overridden `__eq__`/`__hash__`) raises `InteropError`. An object that only *reports* one of those types through `__class__` without subclassing it, such as a lazy proxy, is hashed through its own forwarded methods. @@ -163,4 +163,4 @@ assert decode_interop_value(data) == {"age": 30, "name": "alice"} Every build byte-verifies the implementation against the shared protocol vectors (`tests/unit/protocol/`): 44 key vectors, 6 value vectors, 34 must-error vectors, 6 non-canonical documents the reader must decode and 1 it must reject, the interop AAD vector, and a full HKDF-SHA256 → AES-256-GCM decrypt of the published cross-SDK ciphertext through the production Rust stack. -> **CachekitIO note**: the api.cachekit.io cache-key validator accepts interop keys. The segment rules above (reserved `ns`/`nsapi` namespaces, no `..`) are what keep every interop key inside what it accepts — see the spec's [SaaS Considerations](https://github.com/cachekit-io/protocol/blob/main/spec/interop-mode.md#saas-considerations). +> **CacheKit Cloud note**: the api.cachekit.io cache-key validator accepts interop keys. The segment rules above (reserved `ns`/`nsapi` namespaces, no `..`) are what keep every interop key inside what it accepts — see the spec's [CacheKit Cloud considerations](https://github.com/cachekit-io/protocol/blob/main/spec/interop-mode.md#saas-considerations). diff --git a/docs/features/l1-invalidation.md b/docs/features/l1-invalidation.md index 0b4ac9fa..e1202d65 100644 --- a/docs/features/l1-invalidation.md +++ b/docs/features/l1-invalidation.md @@ -5,7 +5,7 @@ > L1 invalidation and SWR freshness management are **process-local**. When an L2 backend is configured, invalidating a key also deletes it from shared L2 — but other processes keep serving their own L1 copy until it expires (L1 TTL), unless they run the opt-in [invalidation listener](#cross-process-l1-eviction). On the tenant-scoped Redis backend (env auto-detection, or a backend from `RedisBackendProvider`), whole-function invalidation also deletes the L2 entries *other* processes wrote for the calling tenant ([key registry](#whole-function-invalidation)), and the listener runs only on that backend. In L1-only mode (`backend=None`) invalidation is purely local. See [Multi-Instance Semantics](#multi-instance-semantics). > [!IMPORTANT] -> The within-TTL SWR described on this page runs **only in L1-only mode** (`backend=None`): past the freshness threshold, the SDK serves the cached value and **re-runs your function** in the background. With a backend configured (Redis, File, Memcached), `swr_enabled` has no effect — there is no within-TTL SWR in backed modes. The one backed SWR that exists is `@cache.io`'s past-TTL [`stale_ttl` mode](../configuration.md#stale-while-revalidate-stale_ttl), which uses the CachekitIO backend's read-side freshness signal. +> The within-TTL SWR described on this page runs **only in L1-only mode** (`backend=None`): past the freshness threshold, the SDK serves the cached value and **re-runs your function** in the background. With a backend configured (Redis, File, Memcached), `swr_enabled` has no effect — there is no within-TTL SWR in backed modes. The one backed SWR that exists is `@cache.io`'s past-TTL [`stale_ttl` mode](../configuration.md#stale-while-revalidate-stale_ttl), which uses the CacheKit Cloud backend's read-side freshness signal. --- @@ -236,12 +236,12 @@ assert calls == [1, 2, 1, 2] | Backend the decorator resolves | L2 entries deleted | |--------------------------------|--------------------| | Redis from `CACHEKIT_REDIS_URL` / `REDIS_URL` (no `backend=`), or a backend from `RedisBackendProvider` | Every entry **any process** wrote for the function, for the calling tenant | -| `RedisBackend` passed as `backend=`, File, Memcached, CachekitIO | Only entries **this process** wrote or read | +| `RedisBackend` passed as `backend=`, File, Memcached, CacheKit Cloud | Only entries **this process** wrote or read | | L1-only (`backend=None`) | No L2; this process's L1 only | On a generated key with a non-default serializer, each entry this process wrote or read also takes with it the key a pre-v0.20.0 release wrote for the same arguments (see [the v0.20.0 key change](../serializers/README.md#breaking-change-in-v0200-the-key-carries-the-real-serializer)). The pre-v0.20.0 key of an entry only another process, or this one before a restart, saw is not reached, with one exception on tenant-scoped Redis: a v0.20.0 or later decorator on the default serializer for the same function, namespace, tenant and `integrity_checking` setting writes that same `:{integrity_flag}s` key and registers it, so the drain deletes it while the registration lasts (see **Set lifetime** below). -**Round trips on the "this process" backends.** A plain `RedisBackend` deletes the process's keys with one `UNLINK` per 10 000 keys. Memcached groups them by server and sends one pipelined, acknowledged `delete` batch per server per 1 000 keys. File deletes one key per call. CachekitIO has no bulk delete, so it sends one `DELETE` per key, up to 16 at a time: 100 keys take about 7 round trips instead of 100. Each `DELETE` still counts against your tier's rate limit, and 16 at a time spends it much faster than one at a time. So once a `DELETE` is rate limited (a 429 with `Retry-After`), cachekit starts no more concurrent deletes: it sends the remaining keys one at a time, waiting out each `Retry-After`, for at most about as long as one-at-a-time deletes would have taken. Keys it could not delete in that time, keys refused with a 429 that has no `Retry-After` (a quota or balance deny), and keys whose `DELETE` failed for any other reason stay tracked for the next `invalidate_cache()`. When the time runs out, cachekit logs a WARNING `CachekitIO rate limit: stopped pacing invalidation deletes` with the number of keys left. If an `UNLINK` fails, cachekit logs a WARNING `Multi-key L2 delete failed` and retries that batch one key at a time, so one failed batch does not stop the rest of the invalidation. If a Memcached send fails, or its server is in the client's retry window, its keys count as failed and stay tracked for the next `invalidate_cache()`. +**Round trips on the "this process" backends.** A plain `RedisBackend` deletes the process's keys with one `UNLINK` per 10 000 keys. Memcached groups them by server and sends one pipelined, acknowledged `delete` batch per server per 1 000 keys. File deletes one key per call. CacheKit Cloud has no bulk delete, so it sends one `DELETE` per key, up to 16 at a time: 100 keys take about 7 round trips instead of 100. Each `DELETE` still counts against your tier's rate limit, and 16 at a time spends it much faster than one at a time. So once a `DELETE` is rate limited (a 429 with `Retry-After`), cachekit starts no more concurrent deletes: it sends the remaining keys one at a time, waiting out each `Retry-After`, for at most about as long as one-at-a-time deletes would have taken. Keys it could not delete in that time, keys refused with a 429 that has no `Retry-After` (a quota or balance deny), and keys whose `DELETE` failed for any other reason stay tracked for the next `invalidate_cache()`. When the time runs out, cachekit logs a WARNING `CachekitIO rate limit: stopped pacing invalidation deletes` with the number of keys left. If an `UNLINK` fails, cachekit logs a WARNING `Multi-key L2 delete failed` and retries that batch one key at a time, so one failed batch does not stop the rest of the invalidation. If a Memcached send fails, or its server is in the client's retry window, its keys count as failed and stay tracked for the next `invalidate_cache()`. **Key registry (tenant-scoped Redis).** Every L2 write also adds the key to a server-side set for the function, `t:{tenant}:ck:reg:{namespace}:{hash}`. No-args invalidation drains that set and deletes every key in it, plus any key this process remembers that the set missed. The drain runs in bounded steps of 10 000 keys, so it never blocks Redis for long, and a write that lands during the drain is either deleted or left for the next drain: in the set, or, if its tracking failed, in the writing process's own record of its keys. It adds no round trip: the `SADD` + `EXPIRE` go to Redis in the same pipeline as the write's `SET`, and cache hits pay nothing. If those two commands fail while the `SET` succeeds, the write still succeeds and cachekit logs a WARNING `Key tracking failed`: that key is then deleted only by this process's next drain, and other processes' drains miss it until its TTL. The warning fires at most once a minute per function in each process and carries the count of failures since the last one, so a registry outage doesn't flood the logs. If the `SET` fails, its key can still land in the set; the next drain deletes the missing key without error, and the set's expiry bounds it until then. @@ -281,8 +281,8 @@ Upgrading changes the following. - **Every `key=` entry is recomputed once.** No key this release reads or writes is one an earlier release writes. An earlier release's key starts with a namespace you configured, and this release's starts with 16 hex and `-`, so no argument passed to a process on either release can make one reach the other's entries. That holds through a rolling deploy and a rollback, `@cache.secure` included, unless you configured a namespace that itself starts with a function's identity and `-`, or, on a Redis database a plain `RedisBackend` shares with the tenant-scoped backend, a `key=` namespace of `t` or one starting `t:` on the plain backend (the plain backend adds no `t::` prefix, so such a namespace can spell the tenant-scoped backend's keys). - **Old entries are not deleted for you.** They retire by TTL, and never if none was set. The function's key registry set keeps its name, so on a function that takes parameters, one no-args `invalidate_cache()` from an upgraded process deletes every old entry still tracked in it; run it once per tenant, since it reaches only the tenant set in `tenant_context`. On a zero-parameter function, a no-args `invalidate_cache()` deletes only the new key and never drains a registry set. Old entries that are not tracked, because their set expired seven days after its last write or because the backend does not track keys, and every zero-parameter function's old entry, can be erased with the `scan_iter` + `unlink` script from [Upgrading to 0.20.0](../backends/README.md#upgrading-to-0200). On the tenant-scoped Redis backend, run it once per tenant with `pattern = "t:default:users:*"` for namespace `users` and tenant `default` (`"t:default:default:*"` for no namespace), percent-encoding the tenant as that page describes; on a plain `RedisBackend`, use `pattern = "users:*"`. Run it after the rollout completes, or processes still on the earlier release write the old keys back. A glob `*` also matches `:`, so the pattern deletes more than the old entries: the namespace's `fast_mode` and interop entries, which are recomputed once (by every SDK that shares the interop ones), and the old, `fast_mode` and interop entries of every namespace that starts `users:`, such as `users:admin`. It never matches this release's custom keys, which start with 16 hex. For any other namespace it leaves generated keys (`ns:users:…`) and key registry sets alone. Do not run it for a namespace that is `ns` or `func`, starts with `ns:` or `func:`, or contains `*`, `?` or `[`: there the pattern also matches generated keys (for `ns`, every namespaced generated key in the tenant; for `func`, every un-namespaced one), and the script deletes them. Without the tenant prefix, on a plain `RedisBackend`, it also deletes any other application's keys that start `users:`, so run it there only on a database cachekit has to itself. - **Rolling deploys and rollbacks leave stale entries.** Processes on the earlier release keep reading and writing the old shared key, and upgraded processes the new one. An `invalidate_cache(args)` on either release deletes only its own release's key, so the other release keeps serving its entry. Once the rollout completes, run one no-args `invalidate_cache()` per tenant from an upgraded process for each affected function. A rollback brings the shared entries back: the earlier release serves the old keys again, which this release deletes only through a no-args drain of the registry set, so erase them with the script above after rolling back. -- **Keys are 17 characters longer.** A custom key within 17 characters of a backend's limit, Memcached's 250 bytes or CachekitIO's 400 characters, is rejected on every call after the upgrade, so that function stops being cached in L2. -- **CachekitIO files every custom key in its `default` namespace.** The service reads a namespace only from a key that starts `ns:` or `nsapi:`, and a custom key now starts with 16 hex. Keys under any other namespace were already filed in `default`, so nothing changes for them. For a `key=` namespace that is `ns` or `nsapi`, or starts with `ns:` or `nsapi:`, entries move from the service namespace named in the key to `default`, and a colon-free key under `ns` or `nsapi`, which the service rejected, is now accepted and cached there. An API key scoped to other namespaces needs access to `default` to keep caching them. `default` is an open write space, so any API key that can reach it can then read, overwrite, delete or lock those entries, including the key class the `ns:`/`nsapi:` write-space split used to keep out; on a function without `@cache.secure` that means planting values the function serves. Use `@cache.secure` for such a function, or move it to a namespace other than `ns`/`nsapi`. +- **Keys are 17 characters longer.** A custom key within 17 characters of a backend's limit, Memcached's 250 bytes or CacheKit Cloud's 400 characters, is rejected on every call after the upgrade, so that function stops being cached in L2. +- **CacheKit Cloud files every custom key in its `default` namespace.** The service reads a namespace only from a key that starts `ns:` or `nsapi:`, and a custom key now starts with 16 hex. Keys under any other namespace were already filed in `default`, so nothing changes for them. For a `key=` namespace that is `ns` or `nsapi`, or starts with `ns:` or `nsapi:`, entries move from the service namespace named in the key to `default`, and a colon-free key under `ns` or `nsapi`, which the service rejected, is now accepted and cached there. An API key scoped to other namespaces needs access to `default` to keep caching them. `default` is an open write space, so any API key that can reach it can then read, overwrite, delete or lock those entries, including the key class the `ns:`/`nsapi:` write-space split used to keep out; on a function without `@cache.secure` that means planting values the function serves. Use `@cache.secure` for such a function, or move it to a namespace other than `ns`/`nsapi`. - **Anything that addresses `{namespace}:{your key}` directly stops matching.** That includes an operator's delete script and a delete sent by another SDK's client. Put the identity, computed from the function's `module.qualname`, and `-` in front: ```python @@ -315,7 +315,7 @@ The process then runs one invalidation listener: a background thread with its ow - **Off by default.** No preset and no decorator argument turns it on. With the variable unset, a process opens no thread and no extra connection. Every process on this backend announces its own invalidations either way, so a cron job or an operator shell that calls `invalidate_cache()` needs no setting for listening workers to evict. - **Delivery.** At most once. A subscribed process usually evicts within milliseconds, and cachekit's tests hold it to one second. An event sent while the listener is not subscribed is lost, and that L1 copy expires by its L1 TTL as before: before the process's first cache operation that reaches Redis, while the listener starts, and while it reconnects. The start waits for Redis to confirm the subscription, so a sync cache operation that starts the listener reads L2 only once it is subscribed. An async operation starts it without waiting, and other threads' operations go on while a start is in progress, so a value they read from L2 just before the subscription can outlive an invalidation sent then, until its L1 TTL. A connection that Redis closes, on a restart, a failover or `CLIENT KILL`, is noticed at once: the listener reconnects and subscribes again on its own, logging a WARNING `Invalidation listener error (errors since the last warning: N)` while Redis is unreachable, at most once a minute per process, carrying the count of errors since the last one; each error in between logs at DEBUG. The connection PINGs Redis after 10 idle seconds, which keeps idle-timeout proxies and NAT gateways from dropping it, but redis-py does not wait for the reply, so a connection that dies silently, with packets dropped and no reset, is noticed only when TCP gives up on it. On Linux the listener's TCP or TLS connection sets `TCP_USER_TIMEOUT` to 30 seconds, so a PING that goes unacknowledged fails the connection and the listener reconnects within about 40 seconds of the peer going silent. On other platforms it is noticed only when TCP's own retransmission limit gives up, which can take many minutes. Events sent meanwhile are lost. -- **Backends.** Only the tenant-scoped Redis backend carries events (`CACHEKIT_REDIS_URL` / `REDIS_URL` without `backend=`, or a backend from `RedisBackendProvider`). With the variable set, a function cached on any other backend, such as a `RedisBackend` passed as `backend=`, File, Memcached or CachekitIO, gets no cross-process eviction, and cachekit logs one WARNING per process, `... does not carry invalidation events`. A process has one listener, on the Redis server of the first cache operation that starts it, so give every function in a listening process the same Redis. +- **Backends.** Only the tenant-scoped Redis backend carries events (`CACHEKIT_REDIS_URL` / `REDIS_URL` without `backend=`, or a backend from `RedisBackendProvider`). With the variable set, a function cached on any other backend, such as a `RedisBackend` passed as `backend=`, File, Memcached or CacheKit Cloud, gets no cross-process eviction, and cachekit logs one WARNING per process, `... does not carry invalidation events`. A process has one listener, on the Redis server of the first cache operation that starts it, so give every function in a listening process the same Redis. - **Redis permissions.** To listen, a restricted ACL user needs the `subscribe` and `ping` commands (`+@connection` includes `ping`) and access to the channel (`&cachekit:py:invalidate:v1`), besides `publish` on it to announce. From Redis 7 a new ACL user gets no channels unless granted, so `~* +@all` alone is refused. The start waits up to 5 seconds for Redis to confirm the subscription and answer a PING. A refusal, or no answer, fails the start with a WARNING `Invalidation listener failed to start`, and the next cache operation that reaches Redis (an L1 miss) retries it, no sooner than a minute later: a process that serves only L1 hits does not retry. Once the listener runs, a refusal, after an ACL change for instance, is a WARNING `Invalidation listener refused by Redis`, and the listener subscribes again every minute on its own, with no restart and no cache operation. - **One Redis, several apps.** Redis delivers pub/sub messages across database numbers, so apps on different databases of one Redis server see each other's events. An event names its function by namespace and `module.qualname`, so the most it costs another app is an extra L1 miss on a function with the same name and namespace. - **Untrusted messages.** Anyone allowed to publish on the channel can make every listening process evict L1 entries, and can spend its resources. A message is decoded only after a size check, so a forged one cannot run code or stop the listener, and a malformed one is dropped, with a WARNING `Invalidation event dropped (drops since the last warning: N)` at most once a minute per process, carrying the count of drops since the last one; each drop in between logs at DEBUG. But Redis delivers a message whole before cachekit can check it: a large one costs each listener its size in memory, and one beyond Redis's pub/sub output-buffer limit (32 MB by default) makes Redis drop every listener's connection until it reconnects. A whole-function event costs time in proportion to the keys the process recorded for that function. Grant `publish` on the channel only to your application's own Redis users (see [Invalidation Channel](../../SECURITY.md#invalidation-channel-redis-pubsub)). @@ -406,7 +406,7 @@ def test_function(): | `io()` | ✓² | ¹ Within-TTL SWR runs only in L1-only mode (`backend=None`) — with a backend configured, `swr_enabled` has no effect (see the callout at the top of this page). -² `@cache.io` ships past-TTL SWR via [`stale_ttl`](../configuration.md#stale-while-revalidate-stale_ttl) (default-on), using the CachekitIO backend's freshness signal — a different mechanism from the L1-only within-TTL refresh described here. +² `@cache.io` ships past-TTL SWR via [`stale_ttl`](../configuration.md#stale-while-revalidate-stale_ttl) (default-on), using the CacheKit Cloud backend's freshness signal — a different mechanism from the L1-only within-TTL refresh described here. ³ `@cache.secure` raises `ConfigurationError` with `backend=None` (L1-only stores raw objects, which cannot be ciphertext), so the L1-only SWR never runs for it. --- diff --git a/docs/features/rust-serialization.md b/docs/features/rust-serialization.md index c8590410..0031a9ba 100644 --- a/docs/features/rust-serialization.md +++ b/docs/features/rust-serialization.md @@ -24,7 +24,7 @@ xxHash3-64 integrity hash (detects corruption) ↓ [Optional] AES-256-GCM encryption (if @cache.secure; AutoSerializer is refused) ↓ -Storage backend (Redis / CachekitIO / File) +Storage backend (Redis / CacheKit Cloud / File) ``` **Retrieval is the exact reverse pipeline.** ArrowSerializer and OrjsonSerializer skip ByteStorage; for them only `@cache.secure`'s AES-256-GCM step runs in cachekit's Rust extension. diff --git a/docs/features/ssrf-protection.md b/docs/features/ssrf-protection.md index be1fa608..41f618a4 100644 --- a/docs/features/ssrf-protection.md +++ b/docs/features/ssrf-protection.md @@ -2,7 +2,7 @@ # SSRF Protection -Server-Side Request Forgery (SSRF) protection for the CachekitIO backend. +Server-Side Request Forgery (SSRF) protection for the CacheKit Cloud backend. ## What This Means for @cache.io Users @@ -12,7 +12,7 @@ If you're seeing URL validation errors in your app, see the [Troubleshooting Gui ## Overview -When using `CachekitIOBackend` to connect to the cachekit.io SaaS, the SDK includes built-in SSRF protection to prevent attackers from using your application to make requests to internal networks or cloud metadata endpoints. +When using `CachekitIOBackend` to connect to CacheKit Cloud, the SDK includes built-in SSRF protection to prevent attackers from using your application to make requests to internal networks or cloud metadata endpoints. ## What is SSRF? @@ -85,7 +85,7 @@ config = CachekitIOBackendConfig(api_key="...", api_url="https://[::ffff:127.0.0 ### 3. Hostname Allowlist -Only the two cachekit.io API hostnames are allowed by default, matched exactly: +Only the two CacheKit Cloud API hostnames are allowed by default, matched exactly: ```python notest from cachekit.backends.cachekitio import CachekitIOBackendConfig diff --git a/docs/features/write-behind.md b/docs/features/write-behind.md index 052a5926..25dee477 100644 --- a/docs/features/write-behind.md +++ b/docs/features/write-behind.md @@ -35,7 +35,7 @@ Every serialized preset takes it: `@cache.io(write_behind=True)`, `@cache.produc ## What stays on the caller's path - **Serialization and encryption.** The value is serialized (and encrypted) on the caller's thread, so `InteropError` and `KeyringConfigurationError` still raise to the caller, and the writer sends exactly the bytes that went into L1. -- **The async distributed-lock path.** An async miss on a backend with a distributed lock (CachekitIO, and the tenant-scoped Redis backend that environment auto-detection or `RedisBackendProvider` builds) writes inside its lock, as before: a write deferred past the lock's release would send waiting callers to an empty L2, and each would recompute. Write-behind applies to sync misses and to async misses that run without the lock: every async miss on a plain `RedisBackend`, Memcached or File, which have no lock, and every async miss of a function decorated with `distributed_lock=False` ([Turning It Off](distributed-locking.md#turning-it-off-distributed_lockfalse)), on any backend. +- **The async distributed-lock path.** An async miss on a backend with a distributed lock (CacheKit Cloud, and the tenant-scoped Redis backend that environment auto-detection or `RedisBackendProvider` builds) writes inside its lock, as before: a write deferred past the lock's release would send waiting callers to an empty L2, and each would recompute. Write-behind applies to sync misses and to async misses that run without the lock: every async miss on a plain `RedisBackend`, Memcached or File, which have no lock, and every async miss of a function decorated with `distributed_lock=False` ([Turning It Off](distributed-locking.md#turning-it-off-distributed_lockfalse)), on any backend. - **The sync streaming write.** On a sync miss, a plaintext Arrow value written to the File backend streams to disk on the caller's thread, as before. An async miss never streams: it serializes the value in full, so its File write is deferred like any other. ## The writer @@ -65,4 +65,4 @@ The [invalidation listener](l1-invalidation.md#cross-process-l1-eviction) narrow - **Fork:** a forked child starts with an empty queue; the parent's queued writes are the parent's to send. A child forked without at-fork hooks (uWSGI without `--py-call-uwsgi-fork-hooks`) starts no writer thread and writes on the caller. A waiting writer thread holds no lock, so it cannot deadlock a child, but it keeps the process multi-threaded for up to 1 second after the last write, so Python 3.12+ `os.fork()` can warn that the process is multi-threaded. A process with an L1 cache (the default) already runs L1's sweeper thread for good, so it warns with or without write-behind. - **Read-after-write across processes:** another process reading the key before the write lands misses and recomputes. With the queue drained, the lag is one backend write; under a burst it can grow until the queue bound sends callers back to synchronous writes. -- **CachekitIO read-back:** the API promises no read-your-writes across connections, and a read the server answers from an edge copy taken before the write can still miss for that copy's coherence window. The writer may send on a different connection from the caller's, so a read-back in the same process (L1 off, or the entry evicted from L1) is subject to that window too, which a synchronous write on the caller's connection usually avoids. +- **CacheKit Cloud read-back:** the API promises no read-your-writes across connections, and a read the server answers from an edge copy taken before the write can still miss for that copy's coherence window. The writer may send on a different connection from the caller's, so a read-back in the same process (L1 off, or the entry evicted from L1) is subject to that window too, which a synchronous write on the caller's connection usually avoids. diff --git a/docs/features/zero-knowledge-encryption.md b/docs/features/zero-knowledge-encryption.md index 6752b665..a5a76d57 100644 --- a/docs/features/zero-knowledge-encryption.md +++ b/docs/features/zero-knowledge-encryption.md @@ -183,7 +183,7 @@ encrypted, until the rollout drains them. Once the last old-version process is gone, the old keys are never read again, but they are not deleted: they retire only by TTL (never, if none was set) and stay decryptable while the key is. If they hold personal data, delete them once the last old writer is gone — on Redis, `SCAN` for `::*` and `UNLINK` the matches; -the File backend can only clear its whole `cache_dir`; Memcached and CachekitIO retire entries only by +the File backend can only clear its whole `cache_dir`; Memcached and CacheKit Cloud retire entries only by TTL. Never `FLUSHDB` a shared database. ## What Can Go Wrong @@ -215,7 +215,7 @@ export CACHEKIT_MASTER_KEY=$(openssl rand -hex 32) `@cache.secure` resolves its backend the way every preset does ([Backend Resolution Priority](../backends/README.md#backend-resolution-priority)), so without an explicit backend or a `set_default_backend()` default, the environment decides where the ciphertext goes. With `REDIS_URL` set and `CACHEKIT_API_KEY` unset, -`@cache.secure` encrypts to Redis, not to the SaaS. The values are still ciphertext; what changes +`@cache.secure` encrypts to Redis, not to CacheKit Cloud. The values are still ciphertext; what changes is which system holds them. When a particular backend is a requirement, pass it explicitly: @@ -507,8 +507,8 @@ tenant-scoped Redis backend (env auto-detection, `RedisBackendProvider`; `t:defa tenant set) reports the calling tenant's `t:{tenant}:`. An entry copied from `app-a:` to `app-b:`, or from `t:acme:` to `t:globex:`, is refused. A custom backend that prefixes keys must expose that prefix as `key_prefix` for it to be bound. Backend encodings of the key (the File backend's hashed file -name, CachekitIO's percent-encoded URL path) are not part of it, and neither is the tenant -scoping the CachekitIO server applies. +name, CacheKit Cloud's percent-encoded URL path) are not part of it, and neither is the tenant +scoping the CacheKit Cloud server applies. There is one AAD per read. A read never retries with another form of the key, such as the key without its prefix, so a failed authentication is final. @@ -599,7 +599,7 @@ the key verbatim, after the function's identity and the namespace personal data from it — derive them with an HMAC whose key never reaches the backend. Whoever operates the backend can therefore learn which record was read or written, when and how -often, without decrypting anything. On the CachekitIO backend the key travels percent-encoded in +often, without decrypting anything. On the CacheKit Cloud backend the key travels percent-encoded in the URL path (`/v1/cache/{key}`), so it also lands in access logs along the request path and stays there for their retention period, not the cache TTL. Ciphertext length reveals the approximate plaintext size, and because the default serializer compresses before encrypting, it also tracks @@ -745,7 +745,7 @@ didn't recently disable encryption for that function, investigate. > Client-side encryption may *reduce* GDPR, HIPAA or PCI DSS scope, subject to assessment and > your other controls, and only on an explicit path (see > [Activation](#activation-the-master-key-is-a-source-not-a-switch)); it is not a compliance -> guarantee. Encryption covers values only: the cache key is cleartext (and, on CachekitIO, lands +> guarantee. Encryption covers values only: the cache key is cleartext (and, on CacheKit Cloud, lands > in access logs; see [Cleartext Cache Key](#cleartext-cache-key-accepted-exposure)). ### GDPR @@ -845,7 +845,7 @@ from cachekit import cache from cachekit.backends.cachekitio import CachekitIOBackend from cachekit.serializers import OrjsonSerializer -# An HTTP API backend: CachekitIOBackend talks to cachekit.io over HTTPS +# An HTTP API backend: CachekitIOBackend talks to CacheKit Cloud over HTTPS @cache.secure(master_key=secret_key, serializer=OrjsonSerializer(), backend=CachekitIOBackend()) def get_api_secrets(tenant_id: str): return {"api_key": "sk_live_...", "secret": "..."} # illustrative diff --git a/docs/free-threading.md b/docs/free-threading.md index d455a6b6..da155baf 100644 --- a/docs/free-threading.md +++ b/docs/free-threading.md @@ -121,7 +121,7 @@ The exclusion also means the lane never sees what a default install gets: `pip install cachekit` pulls in hiredis. A default install keeps the GIL off anyway, in one of two ways. -A program that uses no Redis backend (L1-only, CachekitIO, File or Memcached) +A program that uses no Redis backend (L1-only, CacheKit Cloud, File or Memcached) never loads redis-py: `import cachekit` imports no redis-py, and `RedisBackend` is loaded only on first use, through the module `__getattr__` of `cachekit.backends`. With `CACHEKIT_DISABLE_HIREDIS` unset nothing is @@ -206,8 +206,8 @@ sees the shared state a real call touches: the L1 lock, per-function stats, the metrics path, the Rust `ByteStorage` and the backend client. It is a manual bench and no CI job runs it. It runs three cells at 1, 4 and 16 threads: an L1 hit, an L2 hit over an in-process backend, and an L2 hit -through the shipped CachekitIO backend against a loopback TLS fake -(`tests/performance/loopback_saas.py`). The CachekitIO cell measures +through the shipped `CachekitIOBackend` against a loopback TLS fake +(`tests/performance/loopback_saas.py`). The CacheKit Cloud cell measures client-side contention only, never service latency. The fake serves each connection on one worker thread, so the summary flags a cell `SERVER-BOUND` when a worker was more than 80% busy: there the fake, not the client, sets @@ -247,7 +247,7 @@ not a free-threaded build, or if `--gil-python` is one. It refuses an existing that repeats an arm's repetition. The summary also drops a process whose GIL state changed while the cells ran. -The CachekitIO cell no longer taints under no-GIL. On httpx it did: threads +The CacheKit Cloud cell no longer taints under no-GIL. On httpx it did: threads sharing one HTTP/2 connection raced in httpcore's send path ([encode/httpcore#1118](https://github.com/encode/httpcore/pull/1118)), failing 23–32% of raw requests with 8 threads on 3.14t and 1–4% with the GIL, and its diff --git a/docs/getting-started.md b/docs/getting-started.md index 026a7495..2814cda9 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -23,7 +23,7 @@ ## Quick Start CacheKit provides a unified caching API with pluggable backends. You write `@cache` -once; the backend (Redis, CachekitIO, Memcached, or File / L1-only) is selected from +once; the backend (Redis, CacheKit Cloud, Memcached, or File / L1-only) is selected from your environment. See [Choose Your Backend](#choose-your-backend) for the full menu. With nothing configured, `@cache` caches in L1 only, in process, like `lru_cache`. It @@ -71,7 +71,7 @@ The same `@cache` now caches in L1 and Redis. ```python from cachekit import cache -@cache # Default: 1 hour TTL (3600s) +@cache(ttl=3600) # Expire after 1 hour; with no ttl, entries do not expire def expensive_function(): return do_expensive_computation() @@ -84,7 +84,7 @@ result = expensive_function() **Prerequisites**: Redis running + `REDIS_URL` environment variable set -**Defaults**: 1 hour TTL, no namespace, compression enabled +**Defaults**: no TTL (backend entries do not expire; L1-only entries are kept for up to a year), no namespace, compression enabled --- @@ -242,7 +242,7 @@ export REDIS_URL="redis://localhost:6379" export CACHEKIT_REDIS_URL="redis://localhost:6379" export CACHEKIT_CONNECTION_POOL_SIZE=50 -# CachekitIO Cloud (closed beta). CACHEKIT_API_KEY is also a backend selector: set it +# CacheKit Cloud (closed beta). CACHEKIT_API_KEY is also a backend selector: set it # INSTEAD of CACHEKIT_REDIS_URL, never both (two selectors leave caches that rely on env # auto-detection uncached). # export CACHEKIT_API_KEY=ck_your_api_key @@ -257,13 +257,13 @@ Four paths depending on your situation: | Backend | When to use | Setup effort | |:--------|:------------|:-------------| | **Redis** (recommended) | Production, existing infra, full control | Medium — run Redis yourself | -| **CachekitIO Cloud** (beta) | Skip Redis ops, managed edge caching | Low — API key only | +| **CacheKit Cloud** (closed beta) | Skip Redis ops, managed edge caching | Low — API key only | | **Memcached** (optional) | High-throughput, existing Memcached infra | Low — `pip install cachekit[memcached]` | | **File / L1-only** | Local dev, tests, no external deps | None | ### Redis (Recommended) -Self-hosted, full control. The default and recommended path. Everything in this guide applies. +Self-hosted, full control. The recommended path for production; the default, with nothing configured, is L1 only. Everything in this guide applies. ```bash docker run -p 6379:6379 redis @@ -278,22 +278,22 @@ def get_user(user_id: int): return fetch_from_db(user_id) ``` -### CachekitIO Cloud +### CacheKit Cloud > [!NOTE] -> **cachekit.io is in closed beta — [request access](https://cachekit.io)** +> **CacheKit Cloud is in closed beta: sign-up is by waitlist at [dash.cachekit.io](https://dash.cachekit.io)** Managed edge caching — no Redis to run. Set your API key and switch the decorator to `@cache.io()`. ```bash -# CachekitIO Cloud (closed beta) +# CacheKit Cloud (closed beta) export CACHEKIT_API_KEY=ck_your_api_key ``` ```python notest from cachekit import cache -@cache.io(ttl=3600) # cachekit.io is in closed beta — request access at https://cachekit.io +@cache.io(ttl=3600) # CacheKit Cloud is in closed beta: join the waitlist at https://dash.cachekit.io def get_user(user_id: int): return fetch_from_db(user_id) ``` @@ -353,7 +353,7 @@ result = critical_function() # Works even if Redis is offline ### Multi-Pod Safe by Default -- Distributed locking prevents cache stampedes +- Distributed locking guards against cache stampedes across pods, best effort (async functions on a lock-capable backend; sync functions dedup within one process only) - All pods see consistent data - No local memory cache surprises diff --git a/docs/guides/backend-guide.md b/docs/guides/backend-guide.md index 7cbb8df8..7d5545c2 100644 --- a/docs/guides/backend-guide.md +++ b/docs/guides/backend-guide.md @@ -9,5 +9,5 @@ See the [Backend Documentation](../backends/README.md) for an overview. - [Redis](../backends/redis.md) — Recommended production backend, connection pooling - [File](../backends/file.md) — Local filesystem, zero dependencies - [Memcached](../backends/memcached.md) — High-throughput, consistent hashing -- [CachekitIO](../backends/cachekitio.md) — Managed SaaS, zero infrastructure +- [CacheKit Cloud](../backends/cachekitio.md) — Managed, zero infrastructure - [Custom](../backends/custom.md) — Implement your own backend diff --git a/docs/performance.md b/docs/performance.md index c016b6d4..05fbdfae 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -87,7 +87,7 @@ An L1-only hit takes 0.311μs for the 100-user dict and 0.31μs for the DataFram - **The function's names.** The memo answers only while the function's `__module__` and `__qualname__` are the objects it was built with, as its cache keys embed them: renaming the function re-keys its calls. - **The use time** for the cache's LRU order, and **the hit count** for `cache_info()`, both atomic. -A memoised L1-only hit records no Prometheus metric. Only a CachekitIO request reads the per-function stats context, so a call sets it only once it is past its L1 lookup and may reach the backend: no L1-only call and no backed L1 hit sets it. A miss takes more Python frames than it did before the memo, so a recursive `@cache(backend=None)` function reaches the recursion limit sooner: on CPython 3.14 at the default limit of 1,000, it recurses 197 levels deep instead of 247. +A memoised L1-only hit records no Prometheus metric. Only a CacheKit Cloud request reads the per-function stats context, so a call sets it only once it is past its L1 lookup and may reach the backend: no L1-only call and no backed L1 hit sets it. A miss takes more Python frames than it did before the memo, so a recursive `@cache(backend=None)` function reaches the recursion limit sooner: on CPython 3.14 at the default limit of 1,000, it recurses 197 levels deep instead of 247. The object cache keeps the memo in step under its lock. A store, or a read under the lock, points the call's slot at its entry, and every removal (expiry, eviction, `invalidate_cache()`, `cache_clear()`, the reset in a forked child) ends the entry's hits and drops its slots before it returns. So a hit never serves a value once its removal has returned, and the memo keeps no removed value. It keeps a call's arguments only while their slot lasts, and at most 1,024 slots per function: a full memo empties before it takes a new key. Full, it holds about 110 to 140KiB when each call passes one `int`, and under 4.5MiB when every call fills its 256 characters with 4-byte ones, the most a call can make it keep (3.5 to 3.8MiB measured on CPython 3.12, 3.14 and free-threaded 3.14 and 3.15). @@ -321,7 +321,7 @@ The regression gate is the instruction budget: `make perf-ir` on your machine, a | Path | What one call does | CPython 3.12 | CPython 3.14 | |------|--------------------|-------------:|-------------:| | `l1_hit` | `@cache(backend=None)` L1 hit | 2,597 | 2,487 | -| `l1_hit_backed` | `@cache` with a backend configured, L1 hit (never reaches the in-process dict backend; decodes the verified MessagePack L1 holds after the first hit). The backend keeps an L1 hit-latency report, as a CachekitIO backend with telemetry on does, so each call pays the 1-in-256 timing draw and the timed hits' clock reads ([Telemetry](backends/cachekitio.md#telemetry)) | 81,354 | 81,592 | +| `l1_hit_backed` | `@cache` with a backend configured, L1 hit (never reaches the in-process dict backend; decodes the verified MessagePack L1 holds after the first hit). The backend keeps an L1 hit-latency report, as a CacheKit Cloud backend with telemetry on does, so each call pays the 1-in-256 timing draw and the timed hits' clock reads ([Telemetry](backends/cachekitio.md#telemetry)) | 81,354 | 81,592 | | `l1_hit_backed_auto` | `@cache` with `serializer="auto"` and a backend that keeps no hit-latency report, L1 hit (decodes the verified MessagePack the auto serializer keeps) | 92,337 | 93,325 | | `minimal_l1_hit` | `@cache.minimal(backend=None)` L1 hit | 2,598 | 2,487 | | `l2_hit` | `@cache`, L1 disabled, L2 hit, as one in-process single-flight trip | 163,783 | 158,141 | diff --git a/docs/serializers/README.md b/docs/serializers/README.md index 7c5ce75b..75996220 100644 --- a/docs/serializers/README.md +++ b/docs/serializers/README.md @@ -193,7 +193,7 @@ def get_data(): > its `shortened` pattern matches only once no other namespace or function shares their > first 50 characters, or flush the database if it is dedicated to cachekit. The File > backend stores one file per hashed key in `cache_dir`, so the only flush -> is the whole directory. Memcached and CachekitIO offer no pattern delete, so old entries +> is the whole directory. Memcached and CacheKit Cloud offer no pattern delete, so old entries > there retire only by TTL. During a rolling deploy, flush **after the last replica still > writing the old keys is gone** — anything written behind the flush is orphaned. diff --git a/docs/serializers/arrow.md b/docs/serializers/arrow.md index d2cf8ffd..9449ba6e 100644 --- a/docs/serializers/arrow.md +++ b/docs/serializers/arrow.md @@ -152,7 +152,7 @@ A read of a compressed payload (the default) decompresses it into new buffers. A (File backend only today) and the value is plaintext (no encryption), serialization streams ~8 MiB record batches directly into the cache file instead of materializing the whole Arrow IPC payload in memory first — cutting write peak RSS from ~5.6x to ~2.3x the DataFrame's -logical size. Wire backends (Redis, CachekitIO, Memcached) and encrypted values use the +logical size. Wire backends (Redis, CacheKit Cloud, Memcached) and encrypted values use the buffered path unchanged. See [File Backend](../backends/file.md#bounded-memory-large-values-arrow). ### Zero-Copy Reads on the File Backend diff --git a/docs/serializers/encryption.md b/docs/serializers/encryption.md index 3782533b..aba46e89 100644 --- a/docs/serializers/encryption.md +++ b/docs/serializers/encryption.md @@ -39,7 +39,7 @@ from cachekit.serializers import OrjsonSerializer # 64 hex chars from your secret store, e.g. generated once with: openssl rand -hex 32 secret_key = os.environ["CACHEKIT_MASTER_KEY"] -# A file backend keeps this example self-contained; production uses Redis or cachekit.io. +# A file backend keeps this example self-contained; production uses Redis or CacheKit Cloud. # Encryption needs a backend: backend=None (L1-only) stores raw objects and is refused. backend = FileBackend(FileBackendConfig(cache_dir=tempfile.mkdtemp())) @@ -147,7 +147,7 @@ def get_secrets(tenant_id: str): # Backend receives encrypted blob, never sees plaintext values ``` -With a remote backend such as cachekit.io, the backend stores only opaque ciphertext. It has no access to keys and cannot decrypt values. That supports a HIPAA/PCI DSS scope-*reduction* argument, subject to assessment and your other controls. It does not take regulated data out of scope on its own, and the cache key still travels in cleartext (see [Compliance Implications](../features/zero-knowledge-encryption.md#compliance-implications)). +With a remote backend such as CacheKit Cloud, the backend stores only opaque ciphertext. It has no access to keys and cannot decrypt values. That supports a HIPAA/PCI DSS scope-*reduction* argument, subject to assessment and your other controls. It does not take regulated data out of scope on its own, and the cache key still travels in cleartext (see [Compliance Implications](../features/zero-knowledge-encryption.md#compliance-implications)). ## Performance diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 909b0d73..fa6f3e37 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -214,12 +214,12 @@ decrypt-only in `CACHEKIT_PREVIOUS_MASTER_KEYS` and follow the --- -## CachekitIO Backend Issues +## CacheKit Cloud Backend Issues
-Can't Connect to cachekit.io +Can't Connect to CacheKit Cloud -**Issue**: Requests to cachekit.io fail immediately or time out +**Issue**: Requests to CacheKit Cloud fail immediately or time out **What it means**: - API key not configured @@ -253,7 +253,7 @@ curl -sf https://api.cachekit.io/healthz
401 Unauthorized -**Issue**: cachekit.io returns `401 Unauthorized` +**Issue**: CacheKit Cloud returns `401 Unauthorized` **What it means**: - API key is invalid, revoked, or expired @@ -263,11 +263,11 @@ curl -sf https://api.cachekit.io/healthz 1. **Confirm the key is correct**: ```bash -# Compare against the key shown in your cachekit.io dashboard +# Compare against the key shown in your CacheKit Cloud dashboard echo $CACHEKIT_API_KEY ``` -2. **Request a new key** at [cachekit.io](https://cachekit.io) and rotate: +2. **Request a new key** in the dashboard at [dash.cachekit.io](https://dash.cachekit.io) and rotate: ```bash export CACHEKIT_API_KEY=new_key_here ``` @@ -279,7 +279,7 @@ export CACHEKIT_API_KEY=new_key_here
429 Rate Limited -**Issue**: cachekit.io returns `429 Too Many Requests` +**Issue**: CacheKit Cloud returns `429 Too Many Requests` **What it means**: - Request rate exceeds your plan's limit @@ -295,9 +295,9 @@ def expensive_query(id): return fetch(id) ``` -2. **Sync calls do not retry a rate-limited request** — the function runs, its result is still stored in L1, and the failure does not count toward the circuit breaker. Async calls do not retry it either: a 429 on the lock request ends the lock wait, and the function runs without the lock (see [CachekitIO HTTP Errors](error-codes.md#cachekitio-http-errors)). If you're hitting 429 consistently, reduce request concurrency or upgrade your plan. +2. **Sync calls do not retry a rate-limited request** — the function runs, its result is still stored in L1, and the failure does not count toward the circuit breaker. Async calls do not retry it either: a 429 on the lock request ends the lock wait, and the function runs without the lock (see [CacheKit Cloud HTTP Errors](error-codes.md#cachekit-cloud-http-errors)). If you're hitting 429 consistently, reduce request concurrency or upgrade your plan. -3. **Check your current usage** at [cachekit.io](https://cachekit.io) dashboard. +3. **Check your current usage** in the dashboard at [dash.cachekit.io](https://dash.cachekit.io).
@@ -332,7 +332,7 @@ curl -sf $CACHEKIT_API_URL/healthz
Connection Timeout -**Issue**: Requests to cachekit.io hang and eventually time out +**Issue**: Requests to CacheKit Cloud hang and eventually time out **What it means**: - High network latency between your environment and api.cachekit.io @@ -357,7 +357,7 @@ curl -o /dev/null -s -w "Connect: %{time_connect}s Total: %{time_total}s\n" \ https://api.cachekit.io/healthz ``` -4. **A timeout does not fail the call**: the request is logged, the function runs, and its result is still stored in L1. Timeouts do not count toward the circuit breaker. A timed-out async lock request ends the lock wait at once (see [CachekitIO HTTP Errors](error-codes.md#cachekitio-http-errors)). +4. **A timeout does not fail the call**: the request is logged, the function runs, and its result is still stored in L1. Timeouts do not count toward the circuit breaker. A timed-out async lock request ends the lock wait at once (see [CacheKit Cloud HTTP Errors](error-codes.md#cachekit-cloud-http-errors)).
@@ -498,7 +498,7 @@ logging.getLogger("redis").setLevel(logging.DEBUG) logging.basicConfig(level=logging.DEBUG) ``` -With the CachekitIO backend, a `DEBUG` root also shows urllib3's request lines, raw cache keys in their paths, never the API key or lock tokens. Read [transport logs](../SECURITY.md#cache-key-redaction-in-logs-cwe-532) if your keys carry identifiers. +With the CacheKit Cloud backend, a `DEBUG` root also shows urllib3's request lines, raw cache keys in their paths, never the API key or lock tokens. Read [transport logs](../SECURITY.md#cache-key-redaction-in-logs-cwe-532) if your keys carry identifiers.
diff --git a/llms.txt b/llms.txt index 9bec30b7..3fa85fac 100644 --- a/llms.txt +++ b/llms.txt @@ -1,8 +1,8 @@ # cachekit -> Backend-agnostic caching for Python — intent-based decorators with circuit breaker, distributed locking, Prometheus metrics, and optional zero-knowledge AES-256-GCM encryption, on a Rust-powered core. Zero-config L1 in-memory; scales to Redis, Memcached, File, or CachekitIO. +> Backend-agnostic caching for Python — intent-based decorators with circuit breaker, distributed locking, Prometheus metrics, and optional zero-knowledge AES-256-GCM encryption, on a Rust-powered core. Zero-config L1 in-memory; scales to Redis, Memcached, File, or CacheKit Cloud. -cachekit provides decorator-based caching with circuit breaker, distributed locking, Prometheus metrics, L1+L2 dual-layer caching, and optional zero-knowledge encryption. Supports 4 built-in backends (Redis, Memcached, File, CachekitIO) plus custom backends via the BaseBackend protocol. +cachekit provides decorator-based caching with circuit breaker, distributed locking, Prometheus metrics, L1+L2 dual-layer caching, and optional zero-knowledge encryption. Supports 4 built-in backends (Redis, Memcached, File, CacheKit Cloud) plus custom backends via the BaseBackend protocol. ## Quick Start @@ -21,7 +21,9 @@ def get_price(symbol: str): def process_payment(amount): return gateway.charge(amount) -@cache.secure # Security-critical: client-side AES-256-GCM encryption +# Security-critical: client-side AES-256-GCM. Needs master_key= or CACHEKIT_MASTER_KEY: +# without either, @cache.secure raises ValueError. +@cache.secure(master_key=key) def get_patient(id: int): return db.fetch_patient(id) ``` @@ -35,7 +37,7 @@ def get_patient(id: int): | **Redis** | included | Production, distributed | | **Memcached** | `pip install cachekit[memcached]` | High-throughput, existing infra | | **File** | included | Single-machine, zero-dependency | -| **CachekitIO** | included | Managed SaaS, zero infrastructure | +| **CacheKit Cloud** (`CachekitIOBackend`) | included | Managed, zero infrastructure (closed beta) | ### Redis ```python @@ -72,11 +74,11 @@ def cached_fn(): return data ``` -### CachekitIO (Managed SaaS) +### CacheKit Cloud (managed, closed beta) ```python # Environment: CACHEKIT_API_KEY=your-api-key -@cache.io() # Uses CachekitIO SaaS backend +@cache.io() # Uses the CacheKit Cloud backend; sign-up is by waitlist at https://dash.cachekit.io def cached_fn(): return data ``` @@ -96,11 +98,12 @@ class MyBackend: | Preset | Circuit Breaker | Monitoring | Encryption | |--------|:-:|:-:|:-:| -| `@cache` / `@cache.minimal` | - | - | - | +| `@cache` (with a backend) | Yes | Full | - | +| `@cache.minimal` | - | - | - | | `@cache.production` | Yes | Full | - | | `@cache.secure` | Yes | Full | AES-256-GCM | | `@cache.io()` | Yes | Full | - | -| `@cache.dev` | - | Verbose | - | +| `@cache.dev` | Yes | Logging; no cache-operation Prometheus metrics (the circuit-breaker state gauge is still exported) | - | | `@cache.test` | - | - | - | ## Serializers @@ -136,7 +139,7 @@ CACHEKIT_MEMCACHED_KEY_PREFIX=myapp: CACHEKIT_FILE_CACHE_DIR=/tmp/cache CACHEKIT_FILE_MAX_SIZE_MB=500 -# CachekitIO +# CacheKit Cloud CACHEKIT_API_KEY=your-api-key CACHEKIT_API_URL=https://api.cachekit.io @@ -161,7 +164,7 @@ CACHEKIT_MASTER_KEY=hex-encoded-32-byte-key - [Redis](docs/backends/redis.md): Recommended production backend - [File](docs/backends/file.md): Local filesystem caching - [Memcached](docs/backends/memcached.md): High-throughput caching -- [CachekitIO](docs/backends/cachekitio.md): Managed SaaS backend +- [CacheKit Cloud](docs/backends/cachekitio.md): Managed backend (closed beta) - [Custom Backends](docs/backends/custom.md): Implement BaseBackend ### Serializers @@ -182,7 +185,7 @@ CACHEKIT_MASTER_KEY=hex-encoded-32-byte-key - [L1 Invalidation & SWR](docs/features/l1-invalidation.md): Invalidation scope (incl. cross-process whole-function on tenant-scoped Redis from the environment or `RedisBackendProvider`), opt-in cross-process L1 eviction, stale-while-revalidate - [Reference Caching](docs/features/reference-caching.md): @cache.local() for non-serializable objects - [Rust Serialization](docs/features/rust-serialization.md): ByteStorage layer (LZ4, xxHash3, AES-256-GCM) -- [SSRF Protection](docs/features/ssrf-protection.md): CachekitIO URL allowlisting +- [SSRF Protection](docs/features/ssrf-protection.md): CacheKit Cloud URL allowlisting - [Write-Behind](docs/features/write-behind.md): Opt-in `write_behind=True`: a miss returns before its L2 write lands - [Architecture](docs/data-flow-architecture.md): L1+L2 internals diff --git a/src/cachekit/backends/cachekitio/backend.py b/src/cachekit/backends/cachekitio/backend.py index c2c90033..28711330 100644 --- a/src/cachekit/backends/cachekitio/backend.py +++ b/src/cachekit/backends/cachekitio/backend.py @@ -188,7 +188,8 @@ def _parse_fresh_for(value: str | None) -> int | None: _RESERVED_KEY_SEGMENTS = frozenset({"", ".", "..", "health", "ttl", "lock"}) _API_KEY_HINT = ( - "\n\ncachekit.io requires an API key: pass api_key=... or set CACHEKIT_API_KEY\nGet an API key at: https://cachekit.io" + "\n\nCacheKit Cloud requires an API key: pass api_key=... or set CACHEKIT_API_KEY\n" + "CacheKit Cloud is in closed beta: join the waitlist at https://dash.cachekit.io" ) @@ -384,7 +385,7 @@ def __init__( problems = "; ".join(f"{'.'.join(str(part) for part in err['loc']) or 'config'}: {err['msg']}" for err in errors) key_absent = any(err["loc"] == ("api_key",) and err["type"] in ("missing", "too_short") for err in errors) hint = _API_KEY_HINT if key_absent else "" - raise ConfigurationError(f"Invalid cachekit.io backend configuration — {problems}{hint}") + raise ConfigurationError(f"Invalid CacheKit Cloud backend configuration — {problems}{hint}") # One thread-safe client per config and process, for sync and async methods alike: an async method sends # on it through asyncio.to_thread. Holding the lease keeps the client open; dropping it closes the client. diff --git a/tests/unit/backends/test_cachekitio_backend.py b/tests/unit/backends/test_cachekitio_backend.py index 9eec2776..fe12d7ff 100644 --- a/tests/unit/backends/test_cachekitio_backend.py +++ b/tests/unit/backends/test_cachekitio_backend.py @@ -141,8 +141,9 @@ def test_empty_key_raises_at_construction(self, monkeypatch: pytest.MonkeyPatch, monkeypatch.delenv("CACHEKIT_API_KEY", raising=False) if env_key is not None: monkeypatch.setenv("CACHEKIT_API_KEY", env_key) - with pytest.raises(ConfigurationError, match="requires an API key"): + with pytest.raises(ConfigurationError, match="requires an API key") as info: CachekitIOBackend(api_key=key) # type: ignore[arg-type] + assert "join the waitlist at https://dash.cachekit.io" in str(info.value) # https://cachekit.io has no sign-up @pytest.mark.parametrize( "key",