Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8aa6d34
fix(decorators): clear an L1-only cache whole on invalidate-all and l…
27Bslash6 Oct 31, 2026
1473975
fix(decorators): cache_clear() clears every entry, on a function with…
27Bslash6 Oct 31, 2026
5f9d182
fix(decorators): a backed cache_clear() without parameters deletes it…
27Bslash6 Oct 31, 2026
0a96f66
fix(rust): answer the L1-only hit in safe Rust
27Bslash6 Oct 31, 2026
ea3096c
docs: quote the hosted L1-only hit figure of the safe extension
27Bslash6 Oct 31, 2026
df550d5
Merge branch 'main' into the safe-Rust L1 hit fix
27Bslash6 Nov 1, 2026
2e3fe05
Merge branch 'main' into the safe-Rust L1 hit fix
27Bslash6 Nov 1, 2026
7e328bd
Merge branch 'main' into the safe-Rust L1 hit fix
27Bslash6 Nov 1, 2026
1e3fdf7
docs: correct the figures and wording derived from the old L1-only hit
27Bslash6 Nov 1, 2026
c9caf41
fix: satisfy clippy's doc_markdown and state what the geiger check sees
27Bslash6 Nov 1, 2026
421a4dc
docs: name the check that rejects unsafe code in cachekit-rs
27Bslash6 Nov 1, 2026
eb20150
Merge branch 'main' into the safe-Rust L1 hit fix
27Bslash6 Nov 1, 2026
ca5e717
docs(perf): the 300,000-call paths cost under 3,000 instructions, not…
27Bslash6 Nov 1, 2026
9306d9e
Merge branch 'main' into the safe-Rust L1 hit fix
27Bslash6 Nov 1, 2026
a0eda25
Merge branch 'main' into the safe-Rust L1 hit fix
27Bslash6 Nov 2, 2026
8a1768a
Merge branch 'main' into the safe-Rust L1 hit fix
27Bslash6 Nov 2, 2026
0c6174d
fix(rust): forbid unsafe code across cachekit-rs again
27Bslash6 Nov 2, 2026
f32e2cb
fix: address coderabbit review — removing an L1-only entry tests the …
27Bslash6 Nov 2, 2026
4ca65ac
docs(security): say CI's Lint Rust clippy compiles every non-test module
27Bslash6 Nov 2, 2026
777e2b6
fix: address coderabbit review — name the TTL deadline and complete t…
27Bslash6 Nov 2, 2026
fb955de
Merge branch 'main' into the safe-Rust L1 hit fix
27Bslash6 Nov 2, 2026
396bab1
docs(backends): give the memo-miss instruction ratio for the measured…
27Bslash6 Nov 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,17 +266,17 @@ def test_cached_function():

## Performance

What a call costs inside your process, from the wall-clock guards in `tests/performance/` run on a GitHub-hosted `ubuntu-latest` runner (AMD EPYC 7763, 4 vCPUs) with CPython 3.14.7. Each figure is the mean of five run medians with its 95% band.
What a call costs inside your process, from the wall-clock guards in `tests/performance/` run on a GitHub-hosted `ubuntu-latest` runner (AMD EPYC 7763, 4 vCPUs) with CPython 3.14.7. Each figure is the mean of five run medians with its 95% band. The two L1-only rows come from [a later run](https://github.com/cachekit-io/cachekit-py/actions/runs/41210621694) on the same CPU model and Python as the other two.

| Path | Time per call |
|:-----|--------------:|
| `@cache(backend=None)` L1 hit, 100-user nested dict (23.5 KB as MessagePack) | 0.311 ± 0.000 µs |
| `@cache(backend=None)` L1 hit, 10K-row DataFrame | 0.31 ± 0.00 µs |
| `@cache(backend=None)` L1 hit, 100-user nested dict (23.5 KB as MessagePack) | 0.341 ± 0.001 µs |
| `@cache(backend=None)` L1 hit, 10K-row DataFrame | 0.34 ± 0.00 µs |
| L1 hit with an L2 backend configured, same dict | 284 ± 2 µs |
| Redis L2 hit with L1 disabled, same dict, Redis in a container on the same machine | 539 ± 3 µs |

- With no backend, L1 keeps the returned object itself, whatever the payload's size, and a repeat call with a few short scalar arguments is answered by one call into the Rust extension that takes no lock ([which calls](https://github.com/cachekit-io/cachekit-py/blob/main/docs/performance.md#decorator--l1-hit-hot-path)). Any other call builds its key every time, as does every call to a function with a backend.
- With an L2 backend, L1 holds the serialized bytes and every hit deserializes them: for this dict, about 910 times the L1-only hit. This run predates the Rust decode every such hit now runs, which bounds and decodes the MessagePack in one pass at about half the instructions. Encryption adds a decrypt to each such hit; this run measured the difference at 9.3 ± 1.0 µs.
- With an L2 backend, L1 holds the serialized bytes and every hit deserializes them: for this dict, about 830 times the L1-only hit. This run predates the Rust decode every such hit now runs, which bounds and decodes the MessagePack in one pass at about half the instructions. Encryption adds a decrypt to each such hit; this run measured the difference at 9.3 ± 1.0 µs.
- An L2 hit adds the backend's round trip. A Redis on another machine adds its network latency to the last row.

Hosted runners come with other CPU models too: in a run of an earlier commit, an EPYC 9V45 runner took about half as long on every row, and other machines give other figures. The [Performance Guide](https://github.com/cachekit-io/cachekit-py/blob/main/docs/performance.md#github-hosted-runner) names the guard behind each figure and how to run it, and the repository's `Bench` workflow repeats the run.
Expand Down
2 changes: 2 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,8 @@ On the tenant-scoped Redis backend, every successful invalidation is announced o
| **Medium** | < 15 min | Post-merge | cargo-geiger (zero unsafe functions in cachekit-rs). The Miri job runs no tests: this crate has none, and byte_storage and encryption are re-exports from cachekit-core |
| **Deep** | < 2 hr | Nightly | Sanitizers (ASan, TSan, MSan), security report |

cachekit-rs declares `#![forbid(unsafe_code)]`, which no `allow(unsafe_code)` or `expect(unsafe_code)` in the crate can lift. On every PR, the `Lint Rust` clippy step in CI (`.github/workflows/ci.yml`, default features) compiles every non-test module and rejects unsafe code in them. Fast's clippy builds without the `python` feature, so it never compiles the Python-facing modules. After merge, the cargo-geiger report counts unsafe code in the crate; its check fails only on unsafe functions.

<details>
<summary><strong>📁 Workflow Files</strong></summary>

Expand Down
2 changes: 1 addition & 1 deletion docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def your_function(args):

#### Core Parameters

- **`ttl`** (`int | None`, default: `None` on bare `@cache`; each preset sets its own — see the [preset matrix](configuration.md#intent-presets)) - Cache time-to-live in seconds. `None` = no expiration; on a preset that is an explicit opt-in.
- **`ttl`** (`int | None`, default: `None` on bare `@cache`; each preset sets its own — see the [preset matrix](configuration.md#intent-presets)) - Cache time-to-live in seconds. `None` = no expiration; on a preset that is an explicit opt-in. An L1-only cache (`backend=None`, `@cache.local()`, or any plaintext, non-interop decorator that finds no backend configured, per [Nothing Configured](backends/README.md#4-nothing-configured-l1-only)) times it, and its stale-while-revalidate refresh, on the OS monotonic clock, which a patched `time.monotonic` does not move ([testing expiry](backends/none.md#ttls-follow-the-os-monotonic-clock)).
- **`namespace`** (`str | None`, default: `None`) - Cache key prefix for organization
- **`serializer`** (`str | SerializerProtocol`, default: `"default"`) - Serializer name (`"default"`, `"std"`, `"auto"`, `"arrow"`, `"orjson"`) or `SerializerProtocol` instance
- **`integrity_checking`** (`bool`, default: `True`; any non-bool raises `ConfigurationError`) - Enable xxHash3-64 checksums for corruption detection (non-cryptographic — detects bit rot and storage bugs, NOT tampering; tamper resistance requires encryption). Behaviour when a writer and reader disagree on this flag: [cross-config reads](serializers/auto.md#cross-config-reads-integrity_checking-mismatch)
Expand Down
4 changes: 2 additions & 2 deletions docs/backends/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ class BaseBackend(Protocol):

| Backend | Latency | Persistence | Cross-Process | TTL | Locking |
|---------|---------|-------------|---------------|-----|---------|
| **L1 (In-Memory)** | 0.31μs per `backend=None` hit with a scalar argument the memo answers ([measured](../performance.md#github-hosted-runner)); with a backend, a hit also deserializes | No | No | No | No |
| **L1 (In-Memory)** | 0.34μs per `backend=None` hit with a scalar argument the memo answers ([measured](../performance.md#github-hosted-runner)); with a backend, a hit also deserializes | No | No | No | No |
| **[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 |
Expand Down Expand Up @@ -408,7 +408,7 @@ first call (and runs uncached if nothing listens there): give it `backend=RedisB

| Backend | Latency | Use Case | Notes |
|---------|---------|----------|-------|
| **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 |
| **L1 (In-Memory)** | 0.34μ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 |
Expand Down
26 changes: 25 additions & 1 deletion docs/backends/none.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,33 @@ get_origin()
assert len(calls) == 2 # ran on both calls: nothing was stored
```

## TTLs Follow the OS Monotonic Clock

An L1-only entry expires, and its stale-while-revalidate refresh and retry back-off come due, on the OS monotonic clock, which the Rust extension reads itself, so the lock-free hit and the cache judge every deadline on one clock. Patching `time.monotonic` (pytest's `monkeypatch`, freezegun's `freeze_time` or `tick`) does not move it, so a test cannot expire an entry or bring a refresh due by moving fake time. The same holds for `@cache.local()`, and for any plaintext, non-interop decorator that finds no backend configured, as it runs in this mode ([Nothing Configured](README.md#4-nothing-configured-l1-only)). A cache with a backend is unchanged.

To test what your code does once an entry has expired, remove the entry instead, so the next call misses as it would after expiry. This tests the miss, not the TTL: an entry expires only once its `ttl` has elapsed on the OS clock. For example:

```python
from cachekit import cache

calls = []


@cache(backend=None, ttl=60)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
def get_rate(currency: str) -> float:
calls.append(currency)
return 1.08


get_rate("EUR")
get_rate.invalidate_cache("EUR") # in place of moving time past ttl; cache_clear() removes every entry
get_rate("EUR")
assert calls == ["EUR", "EUR"]
```

## Characteristics

- Latency: 0.31μs per hit with one `int` argument and no TTL, on a GitHub-hosted AMD EPYC 7763 ([measured](../performance.md#github-hosted-runner)), one call into the Rust extension that takes no lock; a call the [memo](../performance.md#decorator--l1-hit-hot-path) does not take builds its key, looks it up under the cache's lock and costs about 22 times the instructions; in-memory, no network
- Latency: 0.34μs per hit with one `int` argument and no TTL, on a GitHub-hosted AMD EPYC 7763 ([measured](../performance.md#github-hosted-runner)), one call into the Rust extension that takes no lock; a call the [memo](../performance.md#decorator--l1-hit-hot-path) does not take builds its key and looks it up under the cache's lock: a hit with one list argument costs about 19 times the instructions of a memoised one; in-memory, no network
- Shared across processes: No (per-process only)
- Persistence: No (lost on restart)
- TTL support: Yes
Expand Down
2 changes: 1 addition & 1 deletion docs/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ cachekit's own figures, measured on a GitHub-hosted runner (AMD EPYC 7763, CPyth

| Operation | cachekit |
|-----------|----------|
| Cache hit, `@cache(backend=None)`, 100-user nested dict, one `int` argument (a memoised hit) | 0.311μs |
| Cache hit, `@cache(backend=None)`, 100-user nested dict, one `int` argument (a memoised hit) | 0.341μs |
| Cache hit, L1 with an L2 backend configured, same dict (deserializes) | 284μs, measured before hits skipped re-checking bytes already checked (about 20% less since) |
| Cache hit, Redis L2 in a container on the same machine, L1 disabled | 539μs |

Expand Down
2 changes: 1 addition & 1 deletion docs/free-threading.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ covered, plus what the free-threaded CI lane surfaced and sites added since:
| `backends/redis/provider.py` `with_timeout` windows (`_open_windows`: pool → configured `socket_timeout` and the open windows' timeouts) | Per-PID `Lock` from `_pid_lock` (`_windows_locks`) around each window's open and close and around `listener_pool()`'s read of the pool's kwargs | Safe. Opening and closing each read and write the record and the pool's `socket_timeout` together, and a window can open and close on any thread's event loop over one shared pool, so without the lock a window closing the last record could drop one another thread just joined. No `await` runs under the lock. A child gets its own lock, as in the row above. A child forked while a window is open inherits that window but not the task holding it, so it could never close it: an `os.register_at_fork` hook drops every inherited record and puts each pool's configured timeout back, and each record carries the PID that opened it, so a fork made from C, which runs no hook, drops it on the pool's next window or listener clone instead; until then such a child opens its connections at the inherited window's timeout (no per-operation PID check, as in the object-cache row; `--py-call-uwsgi-fork-hooks` avoids it). A close restores the pool's timeout before it drops the record, so a fork between the two steps still leaves the hook a record to reset from. Close-order and fork tests (single-threaded) in `tests/unit/test_invalidation_listener.py` |
| `decorators/wrapper.py` `_cached_keys` | Builtin `set`, snapshot-copied before iteration in invalidation | Safe — single ops atomic; a copy racing an add can only miss a concurrently-written key, which invalidate-all semantics tolerate |
| `object_cache.py` + `_rust_serializer.HitMemo` lock-free L1-only hit (the [hit path](performance.md#decorator--l1-hit-hot-path) of an L1-only function) | No lock on a hit: `HitMemo.get` looks the call's memo key up in the function's memo, a builtin `dict` of `HitEntry` objects, then loads the entry's `alive` flag (acquire) and compares its deadline, then stores its use time (relaxed) and counts the hit on `HitCounter`. Only the function's `ObjectCache` writes the memo, under its lock: a put, or a read under the lock, memoises its entry, and every removal (expiry, eviction, `delete`, replacement, `clear`, the at-fork reset) kills the entry (release) and then drops its memo slots before it releases the lock. Each `HitEntry` lists the memo keys whose slots hold it, behind a `Mutex` that only those writes take and that is never held across Python code | Safe. A hit that starts after a removal returned, in the order the threads' own synchronisation gives them (a lock, an event, a join), finds no slot or finds the entry dead, so it never serves the removed value; a hit racing the removal may serve the value it found alive, as the removal had not returned. No removed value stays referenced from the memo. A refresh points the slots at the new entry before it kills the stale one. Two hits racing on one entry store either use time, which moves its LRU position by at most the time between them. `tests/unit/test_l1_lock_free_hit.py` pins each removal route and runs hitters, fillers and invalidators on shared keys under a 1 MB budget, so entries are evicted all along: no value served after its removal returned, on 3.14 and on 3.14t with the GIL off |
| `object_cache.py` `ObjectCache` + `_reset_caches_after_fork` | `RLock` on every public method. The entries, their byte total, their expiry and recency records, their in-flight refresh markers and the lock form one `_ObjectCacheState`, which every critical section binds once; the memo of an L1-only function's cache (the row above) is written only under that lock. An `os.register_at_fork` hook walks a `WeakSet` of live caches in a forked child | Safe. The hook runs while the child is single-threaded, so it takes no lock that can block and starts no thread. A cache whose lock was free at fork keeps its entries and loses its in-flight refresh markers, since the threads running those refreshes did not survive the fork, so the child's next stale read refreshes again. A cache whose lock was held kills every entry's lock-free hit, empties its memo and gets a fresh, empty state in one attribute store: a parent thread that held it is gone, and a forking thread that forked inside a critical section (from a signal handler or a finalizer) may never return there, as in a `multiprocessing` child. A forking thread that does return finishes on the state it bound, and takes back any memo slot it then sets for an entry of that state. The generation counter stays on the cache, so no refresh token is reused across states. A child forked without at-fork hooks (uWSGI without `--py-call-uwsgi-fork-hooks`) gets no repair: its first call hangs if a parent thread held that cache's lock at fork, and a key whose refresh was in flight is served stale until it expires. The check that could catch that, an owner-PID check, would cost a `getpid()` on every cached call. A sync function refreshes no key there at all (the SWR row). Tests in `tests/unit/test_object_cache.py::TestObjectCacheFork` |
| `object_cache.py` `ObjectCache` + `_reset_caches_after_fork` | `RLock` on every public method. The entries, their byte total, their expiry and recency records, their in-flight refresh markers and the lock form one `_ObjectCacheState`, which every critical section binds once; the memo of an L1-only function's cache (the row above) is written only under that lock. An `os.register_at_fork` hook walks a `WeakSet` of live caches in a forked child | Safe. The hook runs while the child is single-threaded, so it takes no lock that can block and starts no thread. A cache whose lock was free at fork keeps its entries and loses its in-flight refresh markers, since the threads running those refreshes did not survive the fork, so the child's next stale read refreshes again. A cache whose lock was held kills every entry's lock-free hit, empties its memo and gets a fresh, empty state in one attribute store: a parent thread that held it is gone, and a forking thread that forked inside a critical section (from a signal handler or a finalizer) may never return there, as in a `multiprocessing` child. A forking thread that does return finishes on the state it bound: it sets no memo slot for an entry of that state, and takes back one that a fork during the memo write left. The generation counter stays on the cache, so no refresh token is reused across states. A child forked without at-fork hooks (uWSGI without `--py-call-uwsgi-fork-hooks`) gets no repair: its first call hangs if a parent thread held that cache's lock at fork, and a key whose refresh was in flight is served stale until it expires. The check that could catch that, an owner-PID check, would cost a `getpid()` on every cached call. A sync function refreshes no key there at all (the SWR row). Tests in `tests/unit/test_object_cache.py::TestObjectCacheFork` |
| `reliability/metrics_collection.py` `get_async_metrics_collector` | Double-checked module-global singleton | Safe — single-assignment publication of a fully-constructed object; worst case a benign duplicate worker-restart check |
| `reliability/metrics_collection.py` `MetricsCollector` + `get_all_metrics` + `_metrics_lock()` | Module dicts of counters and gauges (`cache_operations_total`, `circuit_breaker_state`, ...) behind one `RLock` per PID, created with `dict.setdefault` | **Fixed** — every read and write takes the lock. A child gets its own, so a lock a parent thread held at a fork from C, with no at-fork hook run, is never waited on; before it was one module `RLock`, so a hookless child's first health check or `StructuredLogger.cache_operation()` could hang. The child's counts start from the values it inherited. Tested in `tests/unit/test_structured_logging.py` (`TestEntriesAfterFork`) |
| `reliability/async_metrics.py` `_metrics_cache` + `_metrics_cache_lock()` | Double-checked module dict of Prometheus metric objects; one `Lock` per PID, created with `dict.setdefault`; held across `os.fork` by at-fork hooks | Safe — the lock-free read sees `None` or a fully constructed metric (single-assignment publication). `setdefault` is atomic under per-object locking, so threads racing to create a new process's lock all get the same one; a child forked without the hooks never takes its parent's lock. Tests in `tests/unit/test_async_metrics_shared_series.py` |
Expand Down
Loading
Loading