diff --git a/Cargo.lock b/Cargo.lock index 48729219..64962685 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -198,9 +198,9 @@ checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "cachekit-core" -version = "0.7.2" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "101672176881ef05b17e8399476eb6fc2082e6a5c33788c84c9bb4d1a8d512d7" +checksum = "d9217fe47d4024d7b0d1dda460bbcfc0f613bd80fb94c4fbbc4e7e6d829d7342" dependencies = [ "aes", "aes-gcm", @@ -892,9 +892,9 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lz4_flex" -version = "0.12.2" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90071f8077f8e40adfc4b7fe9cd495ce316263f19e75c2211eeff3fdf475a3d9" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" [[package]] name = "memchr" diff --git a/docs/features/rust-serialization.md b/docs/features/rust-serialization.md index e51a7f64..c8590410 100644 --- a/docs/features/rust-serialization.md +++ b/docs/features/rust-serialization.md @@ -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. --- diff --git a/docs/free-threading.md b/docs/free-threading.md index cf7117bc..5ec47eca 100644 --- a/docs/free-threading.md +++ b/docs/free-threading.md @@ -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 diff --git a/docs/performance.md b/docs/performance.md index 8ec28060..a8980313 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -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 | diff --git a/rust/fuzz/Cargo.lock b/rust/fuzz/Cargo.lock index d931d351..cb992e31 100644 --- a/rust/fuzz/Cargo.lock +++ b/rust/fuzz/Cargo.lock @@ -71,9 +71,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "cachekit-core" -version = "0.7.2" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "101672176881ef05b17e8399476eb6fc2082e6a5c33788c84c9bb4d1a8d512d7" +checksum = "d9217fe47d4024d7b0d1dda460bbcfc0f613bd80fb94c4fbbc4e7e6d829d7342" dependencies = [ "aes", "aes-gcm", @@ -321,9 +321,9 @@ dependencies = [ [[package]] name = "lz4_flex" -version = "0.12.2" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90071f8077f8e40adfc4b7fe9cd495ce316263f19e75c2211eeff3fdf475a3d9" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" [[package]] name = "num-traits" diff --git a/rust/fuzz/Cargo.toml b/rust/fuzz/Cargo.toml index 5c3eef16..5c0a3af3 100644 --- a/rust/fuzz/Cargo.toml +++ b/rust/fuzz/Cargo.toml @@ -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 diff --git a/tests/performance/ir_baselines.json b/tests/performance/ir_baselines.json index 3a08d4b7..c5a21aee 100644 --- a/tests/performance/ir_baselines.json +++ b/tests/performance/ir_baselines.json @@ -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, @@ -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, @@ -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" diff --git a/tests/unit/protocol/test_envelope_wire_vectors.py b/tests/unit/protocol/test_envelope_wire_vectors.py index 4c01df3f..44b84041 100644 --- a/tests/unit/protocol/test_envelope_wire_vectors.py +++ b/tests/unit/protocol/test_envelope_wire_vectors.py @@ -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