Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/features/rust-serialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ LZ4 is chosen for its speed/ratio balance:
| Already-compressed data | ~1x (negligible overhead) |
| Random bytes | ~1x |

ByteStorage LZ4 compression of serialized envelopes (MessagePack: StandardSerializer and AutoSerializer) runs automatically whenever integrity checking is on (the default; `@cache.secure` forces it on for a serializer named by string, while a serializer instance you pass keeps its own setting); it has no separate switch. A payload under 64 bytes is not run through the compressor: ByteStorage writes it as one uncompressed LZ4 literal block, which any LZ4 decoder reads, so a small value is stored at its own size plus the envelope's overhead. Arrow (DataFrame) payloads use a separate Arrow IPC codec, selectable via the `CACHEKIT_ARROW_COMPRESSION` environment variable (`zstd`, `lz4`, or `none`) — that setting applies only to Arrow payloads and does not disable ByteStorage LZ4 for other serializers.
ByteStorage LZ4 compression of serialized envelopes (MessagePack: StandardSerializer and AutoSerializer) runs automatically whenever integrity checking is on (the default; `@cache.secure` forces it on for a serializer named by string, while a serializer instance you pass keeps its own setting); it has no separate switch. A payload under 64 bytes is not run through the compressor: ByteStorage writes it as one uncompressed LZ4 literal block, which any LZ4 decoder reads, so a small value is stored at its own size plus the envelope's overhead. From 64 bytes to under 4 KiB, ByteStorage compresses with one LZ4 hash table and one output scratch kept per thread instead of allocating them on every call, so each thread that stores such a value retains about 12.5 KiB (an 8 KiB table and a 4.4 KiB scratch) for its lifetime; the compressed bytes are the same either way. Arrow (DataFrame) payloads use a separate Arrow IPC codec, selectable via the `CACHEKIT_ARROW_COMPRESSION` environment variable (`zstd`, `lz4`, or `none`) — that setting applies only to Arrow payloads and does not disable ByteStorage LZ4 for other serializers.

---

Expand Down
2 changes: 1 addition & 1 deletion docs/free-threading.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ covered, plus what the free-threaded CI lane surfaced and sites added since:
| `reliability/async_metrics.py` `AsyncMetricsCollector.record_cache_operation` + `_ThreadState` + `_Shards` + `_ShardView` | Every backed cache operation counts once per collector and updates three Prometheus series. It used to write a shared `_operation_count` (unlocked, so lossy with the GIL off) and either the series' value locks or the batching queue | **Fixed** — each thread writes only its own `_ThreadState`, held in a `threading.local`: one cell per series it records into, and one operation count per collector. The series' values are `_ShardView`s that sum the live cells plus an exact retired total, read under one per-PID lock that no record takes, so a scrape and `get_stats()` count every operation recorded before them. Freed at thread exit, a `_ThreadState` moves its counts into the retired totals and drops its cells in one locked step, so reads never decrease and cells stay bounded to the live threads; sums are retired as exact partials (`math.fsum`), so a retirement never re-rounds a total one ulp lower, which `rate()` would read as a counter reset (an infinite term collapses the sum to `inf`). The lock is reentrant, because a garbage collection under it can run a finalizer on the same thread. A count move such a finalizer starts there is deferred to the next section that is not nested. One example is a cyclic collection freeing a thread's state that a traceback kept alive past the exit, so no read sees a count half moved. The at-fork hooks hold the lock across `os.fork`; a forked child starts from the counts it inherited. In prometheus_client's multiprocess mode cache operations record into the series directly. Tests in `tests/unit/test_async_metrics_per_thread.py` |
| `backends/memcached/backend.py` `MemcachedBackend` | pymemcache `HashClient(use_pooling=True)`: one `PooledClient` per server, whose `ObjectPool` `threading.Lock` guards only the free/used deque pop and append, never socket I/O | Safe across threads: each operation owns its checked-out connection, so no socket is shared. The pool raises at `max_pool_size` rather than waiting (`tests/unit/backends/test_memcached_pooling.py`). pymemcache's failover state (`HashClient._failed_clients`) is unlocked: while a failed server is retried, concurrent operations can raise a spurious `BackendError` wrapping `KeyError` (documented in `docs/backends/memcached.md#concurrency`). Not fork-safe, by design: a child that reused its parent's backend would share the parent's pooled sockets, and could deadlock on a pool lock a parent thread held at fork. A per-PID lock would cure only the deadlock, not the shared sockets, so the rule is that a forked child builds its own backend (documented in `docs/backends/memcached.md#concurrency`) |
| `reliability/async_metrics.py` `AsyncMetricsCollector._maybe_switch_mode`, `shutdown`, `_take_over_if_forked` | Auto sync/batched switch; `_should_check_mode` is an unlocked check-then-set, so two producers can both run a switch. A forked child inherits the batching state (queue, stop event, worker, pool lock) and prometheus_client's metric locks | **Fixed** — one `Lock` held across the switch, so concurrent switches start one worker. The lock is per PID (a per-instance dict filled with `setdefault`, like `_metrics_locks`), so a child forked while a thread is mid-switch never waits on the copy that thread still holds. `shutdown()` takes the same lock, bars any later switch from starting a worker, and sets sync mode before stopping the worker, so every later record lands synchronously; a record another thread had already chosen to queue can miss the worker's final drain (at most one per such thread). Returning to batched mode starts a worker on the existing queue only once the previous worker has exited (its exit drain assumes a single consumer); while it is alive the collector stays synchronous and retries at the next mode check, never joining on the caller's thread. Within a process the queue is never replaced, so producers always find one. **Fork:** an owner-PID take-over gives an inherited collector fresh batching state and sync mode in a forked child. The at-fork hook (after a fork made from C, the first take-over or collector built in the child instead) gives cachekit's own metrics and the default registry fresh prometheus_client locks; an application's own metrics are not covered, and neither are values in prometheus_client's multiprocess mode (`PROMETHEUS_MULTIPROC_DIR`), which share one lock the reset cannot reach, so a child forked while a parent thread holds it can still hang. No collector, inherited or built there, starts a thread in the child of a fork made from C, where at-fork hooks are skipped and `is_alive()` stays stale, nor in a child that first imports cachekit after such a fork of a parent that ran threads (uWSGI `--lazy-apps`; the detection of the `l1_cache.py` row). On free-threaded builds such a fork without `--py-call-uwsgi-fork-hooks` is unsupported: any Python code in that child can hang on a per-object lock a parent thread held, before cachekit can act. The `_take_over_if_forked`, `_reset_metric_locks` and `_reset_metric_locks_once` docstrings give the mechanics and the open cases. Cache operations never queue, so only generic metrics and mode checks touch the batching state. There is no per-record `getpid()` on the sync path. Tests in `tests/unit/test_async_metrics_mode_switch.py` |
| Rust extension (`rust/src/`, cachekit-core 0.7.2) | `#[pymodule(gil_used = false)]`; every `#[pyclass]` exposes only `&self` methods; the nonce counters (the process-wide instance counter and each encryptor's own), core's only shared state in the features cachekit-py enables, are `AtomicU64`; PyO3 enforces `Send + Sync` on pyclasses at compile time | Safe — declared free-threading-ready. (The wasm32 `Cell` nonce variant is single-threaded by target.) |
| Rust extension (`rust/src/`, cachekit-core 0.7.4) | `#[pymodule(gil_used = false)]`; every `#[pyclass]` exposes only `&self` methods; the nonce counters (the process-wide instance counter and each encryptor's own), core's only shared state in the features cachekit-py enables, are `AtomicU64` (core's LZ4 table and scratch are thread-local, so no thread reads another's); PyO3 enforces `Send + Sync` on pyclasses at compile time | Safe — declared free-threading-ready. (The wasm32 `Cell` nonce variant is single-threaded by target.) |

## The CI safety net

Expand Down
8 changes: 4 additions & 4 deletions docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,15 +326,15 @@ The regression gate is the instruction budget: `make perf-ir` on your machine, a
| `miss` | `@cache`, L1 disabled, L2 miss, compute, L2 write, all as one in-process single-flight trip | 213,364 | 204,897 |
| `secure_l1_hit` | `@cache.secure` L1 hit (builds its key, decrypts the ciphertext L1 holds) | 134,323 | 134,972 |
| `l2_hit_async_metrics` | `l2_hit` with the metrics collector in batched mode | 171,976 | 167,728 |
| `serializer_default` | `StandardSerializer` round trip, small dict | 58,733 | 59,574 |
| `serializer_default` | `StandardSerializer` round trip, small dict | 47,895 | 48,623 |
| `serializer_default_records` | `StandardSerializer` round trip, list of 100 six-field records (dict-heavy decode) | 1,420,388 | 1,424,292 |
| `serializer_auto` | `AutoSerializer` round trip | 65,942 | 66,628 |
| `serializer_auto` | `AutoSerializer` round trip | 64,722 | 65,653 |
| `serializer_orjson` | `OrjsonSerializer` round trip | 22,135 | 22,825 |
| `serializer_arrow` | `ArrowSerializer` round trip, 100-row DataFrame | 1,969,626 | 1,980,777 |
| `serializer_arrow_usgs` | `ArrowSerializer` round trip, real 12,535-row DataFrame (see Real data below) | 61,781,406 | 61,324,948 |
| `serializer_auto_usgs` | `AutoSerializer` round trip, same frame (it hands the frame to Arrow) | 61,793,577 | 61,344,459 |
| `serializer_encrypted` | `EncryptionWrapper` encrypt + decrypt round trip | 100,097 | 101,938 |
| `byte_storage_store_40b` | `ByteStorage.store()` alone, 40-byte compressible input (under cachekit-core's 64-byte literals-only LZ4 threshold) | 2,656 | 2,532 |
| `serializer_encrypted` | `EncryptionWrapper` encrypt + decrypt round trip | 89,289 | 90,918 |
| `byte_storage_store_40b` | `ByteStorage.store()` alone, 40-byte compressible input (under cachekit-core's 64-byte literals-only LZ4 threshold) | 2,690 | 2,565 |
| `byte_storage_store_64k` | `ByteStorage.store()` alone, 64 KiB of address-like text | 720,631 | 720,751 |
| `file_set` | `FileBackend.set()` overwriting one key in a 1,000-entry cache (no eviction) | 76,035 | 74,112 |
| `object_cache_put_full` | `ObjectCache.put()` of a new key into a full 10,000-entry byte budget (evicts the LRU entry; nothing expired) | 165,014 | 160,608 |
Expand Down
8 changes: 4 additions & 4 deletions rust/fuzz/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion rust/fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ rmp-serde = "1"
# Bump it in the same diff as every parent-crate core bump.
[dependencies.cachekit_storage]
package = "cachekit-core"
version = "=0.7.2"
version = "=0.7.4"
features = ["compression", "checksum", "messagepack", "encryption"]

# Prevent this from interfering with normal build
Expand Down
16 changes: 8 additions & 8 deletions tests/performance/ir_baselines.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"interpreters": {
"cpython-3.12-x86_64": {
"budgets": {
"byte_storage_store_40b": 2656,
"byte_storage_store_40b": 2690,
"byte_storage_store_64k": 720631,
"file_set": 76035,
"l1_hit": 2595,
Expand All @@ -15,18 +15,18 @@
"secure_l1_hit": 134323,
"serializer_arrow": 1969626,
"serializer_arrow_usgs": 61781406,
"serializer_auto": 65942,
"serializer_auto": 64722,
"serializer_auto_usgs": 61793577,
"serializer_default": 58733,
"serializer_default": 47895,
"serializer_default_records": 1420388,
"serializer_encrypted": 100097,
"serializer_encrypted": 89289,
"serializer_orjson": 22135
},
"python": "3.12.12"
},
"cpython-3.14-x86_64": {
"budgets": {
"byte_storage_store_40b": 2532,
"byte_storage_store_40b": 2565,
"byte_storage_store_64k": 720751,
"file_set": 74112,
"l1_hit": 2487,
Expand All @@ -39,11 +39,11 @@
"secure_l1_hit": 134972,
"serializer_arrow": 1980777,
"serializer_arrow_usgs": 61324948,
"serializer_auto": 66628,
"serializer_auto": 65653,
"serializer_auto_usgs": 61344459,
"serializer_default": 59574,
"serializer_default": 48623,
"serializer_default_records": 1424292,
"serializer_encrypted": 101938,
"serializer_encrypted": 90918,
"serializer_orjson": 22825
},
"python": "3.14.3"
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/protocol/test_envelope_wire_vectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@

CARGO_LOCK = Path(__file__).parents[3] / "Cargo.lock"
# The cachekit-core release whose publish job ran core's allocation probe (docstring item 6).
PROBE_VERIFIED_CORE_VERSION = "0.7.2"
PROBE_VERIFIED_CORE_VERSION = "0.7.4"

FIXTURE_PATH = Path(__file__).parent / "fixtures" / "wire-format.json"
FIXTURE_SHA256 = "806de79dca2e0e6b221b8ec191526207d32f9fe5d88597098dd2549427b4993c" # pragma: allowlist secret
Expand Down
Loading