cachekit 0.18.0__tar.gz → 0.19.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cachekit-0.18.0 → cachekit-0.19.0}/Cargo.lock +1 -1
- {cachekit-0.18.0 → cachekit-0.19.0}/PKG-INFO +14 -2
- {cachekit-0.18.0 → cachekit-0.19.0}/README.md +11 -1
- {cachekit-0.18.0 → cachekit-0.19.0}/pyproject.toml +44 -20
- {cachekit-0.18.0 → cachekit-0.19.0}/rust/Cargo.toml +1 -1
- {cachekit-0.18.0 → cachekit-0.19.0}/rust/README.md +11 -1
- {cachekit-0.18.0 → cachekit-0.19.0}/rust/TEST_EXPANSION_SUMMARY.md +2 -2
- {cachekit-0.18.0 → cachekit-0.19.0}/rust/src/lib.rs +28 -2
- cachekit-0.19.0/rust/src/msgpack_bounds.rs +87 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/rust/src/python_bindings.rs +108 -38
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/__init__.py +1 -1
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/base.py +13 -7
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/cachekitio/backend.py +34 -7
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/cachekitio/error_handler.py +11 -6
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/errors.py +11 -4
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/file/backend.py +81 -22
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/memcached/backend.py +4 -1
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/memcached/error_handler.py +18 -8
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/provider.py +10 -8
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/redis/backend.py +5 -5
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/redis/error_handler.py +12 -7
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/redis/provider.py +25 -11
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/cache_handler.py +197 -103
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/decorators/intent.py +5 -1
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/decorators/orchestrator.py +26 -9
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/decorators/session.py +16 -8
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/decorators/wrapper.py +276 -105
- cachekit-0.19.0/src/cachekit/hash_utils.py +146 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/hiredis_compat.py +5 -3
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/interop.py +2 -4
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/l1_cache.py +15 -5
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/logging.py +30 -129
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/monitoring/pool_monitor.py +5 -5
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/object_cache.py +1 -1
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/reliability/async_metrics.py +4 -2
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/reliability/metrics_collection.py +47 -32
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/reliability/profiles.py +0 -14
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/serializers/__init__.py +3 -2
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/serializers/auto_serializer.py +237 -122
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/serializers/base.py +119 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/serializers/orjson_serializer.py +1 -1
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/serializers/standard_serializer.py +15 -9
- cachekit-0.18.0/rust/supply-chain/audits.toml +0 -4
- cachekit-0.18.0/rust/supply-chain/config.toml +0 -1341
- cachekit-0.18.0/rust/supply-chain/imports.lock +0 -2
- cachekit-0.18.0/src/cachekit/hash_utils.py +0 -50
- {cachekit-0.18.0 → cachekit-0.19.0}/Cargo.toml +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/LICENSE +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/rust/Makefile +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/rust/tsan_suppressions.txt +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/__init__.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/base_config.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/cachekitio/__init__.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/cachekitio/client.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/cachekitio/config.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/cachekitio/session.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/file/__init__.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/file/config.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/memcached/__init__.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/memcached/config.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/redis/__init__.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/redis/client.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/backends/redis/config.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/config/__init__.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/config/decorator.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/config/nested.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/config/settings.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/config/singleton.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/config/validation.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/decorators/__init__.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/decorators/local_wrapper.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/decorators/main.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/decorators/stats_context.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/decorators/tenant_context.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/decorators/utils/__init__.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/di.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/health.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/imports.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/key_generator.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/monitoring/__init__.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/monitoring/correlation_tracking.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/monitoring/protocols.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/py.typed +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/reliability/__init__.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/reliability/circuit_breaker.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/reliability/error_classification.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/reliability/load_control.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/serializers/arrow_serializer.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/serializers/encryption_wrapper.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/serializers/interop_serializer.py +0 -0
- {cachekit-0.18.0 → cachekit-0.19.0}/src/cachekit/serializers/wrapper.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cachekit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.19.0
|
|
4
4
|
Classifier: Development Status :: 4 - Beta
|
|
5
5
|
Classifier: Intended Audience :: Developers
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -28,6 +28,8 @@ Requires-Dist: blake3>=1.0.5
|
|
|
28
28
|
Requires-Dist: msgpack>=1.2.1
|
|
29
29
|
Requires-Dist: xxhash>=3.5.0
|
|
30
30
|
Requires-Dist: httpx[http2]>=0.28.1
|
|
31
|
+
Requires-Dist: anyio>=4.14.2
|
|
32
|
+
Requires-Dist: h2>=4.4.1
|
|
31
33
|
Requires-Dist: numpy>=2.0.2 ; extra == 'data'
|
|
32
34
|
Requires-Dist: pandas>=1.3.0 ; extra == 'data'
|
|
33
35
|
Requires-Dist: pyarrow>=21.0.0 ; extra == 'data'
|
|
@@ -288,6 +290,7 @@ def test_cached_function():
|
|
|
288
290
|
- Connection pooling with thread affinity (+28% throughput)
|
|
289
291
|
- Distributed locking prevents cache stampedes
|
|
290
292
|
- Pluggable backend abstraction (Redis, CachekitIO, File, Memcached, custom)
|
|
293
|
+
- 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/2d56cce231e193141f09df9316f9afac17a1538e/test-vectors/decode-bounds.json) vectors
|
|
291
294
|
|
|
292
295
|
> [!NOTE]
|
|
293
296
|
> All reliability features are **enabled by default** with `@cache.production`. Use `@cache.minimal` to disable them for maximum throughput.
|
|
@@ -351,7 +354,7 @@ trial decryption — and old entries age out via TTL, no cache flush required. S
|
|
|
351
354
|
|
|
352
355
|
cachekit employs comprehensive security tooling:
|
|
353
356
|
|
|
354
|
-
- **
|
|
357
|
+
- **Dependency Security**: cargo-deny for license compliance + cargo-audit for RustSec scanning
|
|
355
358
|
- **Formal Verification**: Kani proves correctness of compression, checksums, encryption
|
|
356
359
|
- **Runtime Analysis**: Miri + sanitizers for memory safety
|
|
357
360
|
- **Fuzzing**: Coverage-guided testing with >80% code coverage
|
|
@@ -411,6 +414,15 @@ exposition setup.
|
|
|
411
414
|
<details>
|
|
412
415
|
<summary><strong>Thread Safety Details</strong></summary>
|
|
413
416
|
|
|
417
|
+
**Free-threaded CPython (3.14t):** the core suites run green on
|
|
418
|
+
free-threaded 3.14 with the GIL verified disabled (CI job
|
|
419
|
+
`test-freethreaded`), and the Rust extension declares free-threaded safety
|
|
420
|
+
(`gil_used = false`). Free-threaded wheels are **not yet published** and
|
|
421
|
+
free-threaded builds are not officially supported — blocked on upstream
|
|
422
|
+
wheels (orjson, hiredis; numpy/pandas/pyarrow for `[data]`). See
|
|
423
|
+
[measured performance results](docs/free-threading.md#measured-performance) and the
|
|
424
|
+
full concurrency audit: [docs/free-threading.md](docs/free-threading.md).
|
|
425
|
+
|
|
414
426
|
**Per-Function Statistics:**
|
|
415
427
|
- Statistics tracked per function identity (`module.qualname`), shared across all calls and across re-decorations of the same function
|
|
416
428
|
- Thread-safe via RLock (all methods safe for concurrent access)
|
|
@@ -235,6 +235,7 @@ def test_cached_function():
|
|
|
235
235
|
- Connection pooling with thread affinity (+28% throughput)
|
|
236
236
|
- Distributed locking prevents cache stampedes
|
|
237
237
|
- Pluggable backend abstraction (Redis, CachekitIO, File, Memcached, custom)
|
|
238
|
+
- 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/2d56cce231e193141f09df9316f9afac17a1538e/test-vectors/decode-bounds.json) vectors
|
|
238
239
|
|
|
239
240
|
> [!NOTE]
|
|
240
241
|
> All reliability features are **enabled by default** with `@cache.production`. Use `@cache.minimal` to disable them for maximum throughput.
|
|
@@ -298,7 +299,7 @@ trial decryption — and old entries age out via TTL, no cache flush required. S
|
|
|
298
299
|
|
|
299
300
|
cachekit employs comprehensive security tooling:
|
|
300
301
|
|
|
301
|
-
- **
|
|
302
|
+
- **Dependency Security**: cargo-deny for license compliance + cargo-audit for RustSec scanning
|
|
302
303
|
- **Formal Verification**: Kani proves correctness of compression, checksums, encryption
|
|
303
304
|
- **Runtime Analysis**: Miri + sanitizers for memory safety
|
|
304
305
|
- **Fuzzing**: Coverage-guided testing with >80% code coverage
|
|
@@ -358,6 +359,15 @@ exposition setup.
|
|
|
358
359
|
<details>
|
|
359
360
|
<summary><strong>Thread Safety Details</strong></summary>
|
|
360
361
|
|
|
362
|
+
**Free-threaded CPython (3.14t):** the core suites run green on
|
|
363
|
+
free-threaded 3.14 with the GIL verified disabled (CI job
|
|
364
|
+
`test-freethreaded`), and the Rust extension declares free-threaded safety
|
|
365
|
+
(`gil_used = false`). Free-threaded wheels are **not yet published** and
|
|
366
|
+
free-threaded builds are not officially supported — blocked on upstream
|
|
367
|
+
wheels (orjson, hiredis; numpy/pandas/pyarrow for `[data]`). See
|
|
368
|
+
[measured performance results](docs/free-threading.md#measured-performance) and the
|
|
369
|
+
full concurrency audit: [docs/free-threading.md](docs/free-threading.md).
|
|
370
|
+
|
|
361
371
|
**Per-Function Statistics:**
|
|
362
372
|
- Statistics tracked per function identity (`module.qualname`), shared across all calls and across re-decorations of the same function
|
|
363
373
|
- Thread-safe via RLock (all methods safe for concurrent access)
|
|
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cachekit"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.19.0"
|
|
8
8
|
description = "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."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -66,6 +66,22 @@ dependencies = [
|
|
|
66
66
|
"xxhash>=3.5.0",
|
|
67
67
|
# HTTP client for SaaS backend (cachekit.io)
|
|
68
68
|
"httpx[http2]>=0.28.1",
|
|
69
|
+
# anyio is transitive via the mandatory httpx dependency above, so it ships
|
|
70
|
+
# to EVERY install, not just dev. Declared here rather than as a
|
|
71
|
+
# [tool.uv] constraint because that table is uv-local: it never reaches
|
|
72
|
+
# requires-dist, so `pip install cachekit` would ignore it. 4.14.2 fixes
|
|
73
|
+
# GHSA-82r6-8w77-94w6 / CVE-2026-63374 (IDNA-2003 hostname encoding lets a
|
|
74
|
+
# hijacked connection to an internationalised domain pass TLS certificate
|
|
75
|
+
# validation; CVSS 9.3), GHSA-5p39-cfhj-2xmp / CVE-2026-64847 (undrained
|
|
76
|
+
# process-pool stderr pipe deadlocks the worker) and GHSA-3w57-8xmc-8v26 /
|
|
77
|
+
# CVE-2026-63349 (extra_groups ignored, parent supplementary groups kept).
|
|
78
|
+
"anyio>=4.14.2",
|
|
79
|
+
# h2 arrives via the http2 extra on that same mandatory httpx dependency, so
|
|
80
|
+
# it ships to every install too, and was declared as a [tool.uv] constraint
|
|
81
|
+
# with the same no-op effect. 4.4.1 fixes GHSA-6hr6-w5qg-qmwg (duplicate Host
|
|
82
|
+
# headers forwarded across an HTTP/2 -> HTTP/1.1 downgrade — a request
|
|
83
|
+
# smuggling primitive).
|
|
84
|
+
"h2>=4.4.1",
|
|
69
85
|
]
|
|
70
86
|
|
|
71
87
|
[project.optional-dependencies]
|
|
@@ -150,7 +166,6 @@ asyncio_default_fixture_loop_scope = "function"
|
|
|
150
166
|
addopts = [
|
|
151
167
|
"--strict-markers",
|
|
152
168
|
"--verbose",
|
|
153
|
-
"--basetemp=/tmp/pytest",
|
|
154
169
|
"--doctest-modules", # Validate docstring examples
|
|
155
170
|
"--doctest-continue-on-failure", # Report all doctest failures, not just first
|
|
156
171
|
"--markdown-docs", # Validate markdown documentation examples
|
|
@@ -200,8 +215,12 @@ exclude_lines = [
|
|
|
200
215
|
]
|
|
201
216
|
|
|
202
217
|
[dependency-groups]
|
|
203
|
-
|
|
204
|
-
|
|
218
|
+
# Core test toolchain — everything tests/unit + tests/critical need on ANY
|
|
219
|
+
# interpreter, including free-threaded CPython: the free-threaded CI lane
|
|
220
|
+
# installs ONLY this group (LAB-511). A dep may live here only if it ships
|
|
221
|
+
# free-threaded wheels or builds cleanly from source on 3.14t; deps that
|
|
222
|
+
# don't (orjson, numpy, pandas, pyarrow) stay in dev below.
|
|
223
|
+
test = [
|
|
205
224
|
"fakeredis>=2.21.0",
|
|
206
225
|
"pytest>=7.0.0",
|
|
207
226
|
"pytest-asyncio>=0.21.0",
|
|
@@ -210,6 +229,19 @@ dev = [
|
|
|
210
229
|
"pytest-markdown-docs>=0.6.0",
|
|
211
230
|
"pytest-redis>=3.0.0",
|
|
212
231
|
"pymemcache>=4.0.0",
|
|
232
|
+
# Utilities
|
|
233
|
+
"faker>=20.0.0",
|
|
234
|
+
"httpx>=0.28.1",
|
|
235
|
+
"hypothesis>=6.0.0",
|
|
236
|
+
"requests>=2.33.0; python_version >= '3.10'",
|
|
237
|
+
"psutil>=5.9.0",
|
|
238
|
+
"python-dotenv>=1.0.0",
|
|
239
|
+
"pyyaml>=6.0.3",
|
|
240
|
+
"pytest-xdist>=3.8.0",
|
|
241
|
+
"time-machine>=2.19.0",
|
|
242
|
+
]
|
|
243
|
+
dev = [
|
|
244
|
+
{ include-group = "test" },
|
|
213
245
|
# Competitive comparison suite (tests/competitive/ benchmarks cachekit vs these)
|
|
214
246
|
"cachetools>=5.3.0",
|
|
215
247
|
"aiocache>=0.12.0",
|
|
@@ -218,14 +250,7 @@ dev = [
|
|
|
218
250
|
"ruff>=0.6.0",
|
|
219
251
|
# Utilities
|
|
220
252
|
"bashlex>=0.18",
|
|
221
|
-
"faker>=20.0.0",
|
|
222
|
-
"httpx>=0.28.1",
|
|
223
|
-
"hypothesis>=6.0.0",
|
|
224
253
|
"pip-audit>=2.7.0",
|
|
225
|
-
"requests>=2.33.0; python_version >= '3.10'",
|
|
226
|
-
"psutil>=5.9.0",
|
|
227
|
-
"python-dotenv>=1.0.0",
|
|
228
|
-
"pyyaml>=6.0.3",
|
|
229
254
|
# Data science support (for testing AutoSerializer with numpy/pandas)
|
|
230
255
|
"numpy>=2.0.2",
|
|
231
256
|
"pandas>=1.3.0",
|
|
@@ -233,15 +258,17 @@ dev = [
|
|
|
233
258
|
# OrjsonSerializer support — now the [json] optional extra; kept here so the
|
|
234
259
|
# orjson tests, doctests, and markdown-docs still resolve it in dev/CI.
|
|
235
260
|
"orjson>=3.9.0",
|
|
236
|
-
"pytest-xdist>=3.8.0",
|
|
237
|
-
"time-machine>=2.19.0",
|
|
238
261
|
]
|
|
239
262
|
# Linux CI only - Atheris requires libFuzzer (not available on macOS without building LLVM)
|
|
240
263
|
fuzz = [
|
|
241
264
|
"atheris>=2.3.0",
|
|
242
265
|
]
|
|
243
266
|
|
|
244
|
-
# Override vulnerable transitive dependencies
|
|
267
|
+
# Override vulnerable DEV-ONLY transitive dependencies.
|
|
268
|
+
# This table is uv-local: it constrains resolution of this repo's lockfile and
|
|
269
|
+
# never reaches requires-dist, so a floor placed here does NOT protect anyone who
|
|
270
|
+
# runs `pip install cachekit`. A transitive that reaches users belongs in
|
|
271
|
+
# [project] dependencies instead — see the anyio/h2 entries above.
|
|
245
272
|
[tool.uv]
|
|
246
273
|
constraint-dependencies = [
|
|
247
274
|
"urllib3>=2.7.0",
|
|
@@ -249,10 +276,7 @@ constraint-dependencies = [
|
|
|
249
276
|
"werkzeug>=3.1.4",
|
|
250
277
|
# pip is a dev-only transitive dep (pip-audit -> pip-api -> pip). 26.1.2 fixes
|
|
251
278
|
# PYSEC-2026-196 (entry-point path traversal), GHSA-58qw-9mgm-455v (tar/zip
|
|
252
|
-
# confusion) and GHSA-jp4c-xjxw-mgf9 (self-update import ordering).
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
# GHSA-6hr6-w5qg-qmwg (duplicate Host headers forwarded on HTTP/2 ->
|
|
256
|
-
# HTTP/1.1 downgrade — request smuggling primitive).
|
|
257
|
-
"h2>=4.4.1",
|
|
279
|
+
# confusion) and GHSA-jp4c-xjxw-mgf9 (self-update import ordering); 26.2 fixes
|
|
280
|
+
# PYSEC-2026-3721 (doubly-encoded index URLs install to arbitrary paths).
|
|
281
|
+
"pip>=26.2",
|
|
258
282
|
]
|
|
@@ -235,6 +235,7 @@ def test_cached_function():
|
|
|
235
235
|
- Connection pooling with thread affinity (+28% throughput)
|
|
236
236
|
- Distributed locking prevents cache stampedes
|
|
237
237
|
- Pluggable backend abstraction (Redis, CachekitIO, File, Memcached, custom)
|
|
238
|
+
- 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/2d56cce231e193141f09df9316f9afac17a1538e/test-vectors/decode-bounds.json) vectors
|
|
238
239
|
|
|
239
240
|
> [!NOTE]
|
|
240
241
|
> All reliability features are **enabled by default** with `@cache.production`. Use `@cache.minimal` to disable them for maximum throughput.
|
|
@@ -298,7 +299,7 @@ trial decryption — and old entries age out via TTL, no cache flush required. S
|
|
|
298
299
|
|
|
299
300
|
cachekit employs comprehensive security tooling:
|
|
300
301
|
|
|
301
|
-
- **
|
|
302
|
+
- **Dependency Security**: cargo-deny for license compliance + cargo-audit for RustSec scanning
|
|
302
303
|
- **Formal Verification**: Kani proves correctness of compression, checksums, encryption
|
|
303
304
|
- **Runtime Analysis**: Miri + sanitizers for memory safety
|
|
304
305
|
- **Fuzzing**: Coverage-guided testing with >80% code coverage
|
|
@@ -358,6 +359,15 @@ exposition setup.
|
|
|
358
359
|
<details>
|
|
359
360
|
<summary><strong>Thread Safety Details</strong></summary>
|
|
360
361
|
|
|
362
|
+
**Free-threaded CPython (3.14t):** the core suites run green on
|
|
363
|
+
free-threaded 3.14 with the GIL verified disabled (CI job
|
|
364
|
+
`test-freethreaded`), and the Rust extension declares free-threaded safety
|
|
365
|
+
(`gil_used = false`). Free-threaded wheels are **not yet published** and
|
|
366
|
+
free-threaded builds are not officially supported — blocked on upstream
|
|
367
|
+
wheels (orjson, hiredis; numpy/pandas/pyarrow for `[data]`). See
|
|
368
|
+
[measured performance results](docs/free-threading.md#measured-performance) and the
|
|
369
|
+
full concurrency audit: [docs/free-threading.md](docs/free-threading.md).
|
|
370
|
+
|
|
361
371
|
**Per-Function Statistics:**
|
|
362
372
|
- Statistics tracked per function identity (`module.qualname`), shared across all calls and across re-decorations of the same function
|
|
363
373
|
- Thread-safe via RLock (all methods safe for concurrent access)
|
|
@@ -55,7 +55,7 @@ Expanded Rust test suite from 900+ tests to **1100+ tests** by adding critical s
|
|
|
55
55
|
- `test_subtle_multi_byte_patterns`: XOR, swap, increment, block corruption
|
|
56
56
|
|
|
57
57
|
**Validation**:
|
|
58
|
-
-
|
|
58
|
+
- xxHash3-64 checksums detect all tested multi-byte corruption patterns
|
|
59
59
|
- Corruption at any offset (start/middle/end) detected
|
|
60
60
|
- Subtle patterns (swap, increment, aligned blocks) caught
|
|
61
61
|
|
|
@@ -234,7 +234,7 @@ cargo test --tests --features compression,encryption
|
|
|
234
234
|
|
|
235
235
|
1. **Nonce Generation**: Counter-based approach ([random_iv(8)][counter(4)]) is provably collision-free up to 2^32 operations per instance
|
|
236
236
|
2. **Atomicity**: AtomicU64 with SeqCst ordering ensures thread safety across PyO3 boundary
|
|
237
|
-
3. **Corruption Detection**:
|
|
237
|
+
3. **Corruption Detection**: xxHash3-64 checksums detect all tested corruption patterns (single/multi-byte, any offset, subtle patterns)
|
|
238
238
|
4. **Truncation Handling**: All truncation scenarios properly rejected with clear error messages
|
|
239
239
|
5. **Large Payload Efficiency**: System handles 50MB+ payloads without OOM, with good compression ratios
|
|
240
240
|
6. **Concurrent Safety**: No race conditions or corruption detected in stress tests with 100+ threads
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
//! `PyO3` bindings for `cachekit-core`
|
|
2
2
|
//!
|
|
3
3
|
//! This crate provides thin Python wrappers around the cachekit-core library.
|
|
4
|
-
//!
|
|
4
|
+
//! Business logic lives in cachekit-core, with one SDK-owned exception: the untrusted
|
|
5
|
+
//! msgpack decode bound in `msgpack_bounds` (LAB-2503), pending a core-shared walk.
|
|
5
6
|
|
|
6
7
|
// Re-export core types for use in Python bindings
|
|
7
8
|
pub use cachekit_core::{ByteStorage, OperationMetrics, StorageEnvelope};
|
|
8
9
|
|
|
10
|
+
/// Untrusted msgpack structural bound — pure Rust, not gated on `python`
|
|
11
|
+
pub mod msgpack_bounds;
|
|
12
|
+
|
|
9
13
|
#[cfg(feature = "encryption")]
|
|
10
14
|
pub use cachekit_core::{
|
|
11
15
|
derive_domain_key,
|
|
@@ -21,17 +25,39 @@ pub mod python_bindings;
|
|
|
21
25
|
use pyo3::prelude::*;
|
|
22
26
|
|
|
23
27
|
/// Python module definition - exports raw byte storage and encryption
|
|
28
|
+
///
|
|
29
|
+
/// `gil_used = false` (the PyO3 0.28+ default, made explicit): declares the
|
|
30
|
+
/// module thread-safe under free-threaded CPython so importing it does not
|
|
31
|
+
/// force the GIL back on. Verified by the LAB-511 audit: every `#[pyclass]`
|
|
32
|
+
/// exposes only `&self` methods, and shared state in cachekit-core is
|
|
33
|
+
/// `AtomicU64` (nonce counter) or `Mutex` (metrics) — no interior mutability
|
|
34
|
+
/// the GIL was papering over. PyO3 enforces `Send + Sync` on every pyclass at
|
|
35
|
+
/// compile time.
|
|
24
36
|
#[cfg(feature = "python")]
|
|
25
|
-
#[pymodule]
|
|
37
|
+
#[pymodule(gil_used = false)]
|
|
26
38
|
fn _rust_serializer(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
27
39
|
// Add byte storage class
|
|
28
40
|
m.add_class::<python_bindings::PyByteStorage>()?;
|
|
29
41
|
|
|
42
|
+
// ByteStorage.retrieve() envelope-verification-failure taxonomy (LAB-2736). Same
|
|
43
|
+
// __module__ patch as KeyringConfigurationError below: create_exception! sets it to the
|
|
44
|
+
// bare "_rust_serializer", which breaks pickling back to a parent process otherwise.
|
|
45
|
+
let envelope_integrity_error = m.py().get_type::<python_bindings::EnvelopeIntegrityError>();
|
|
46
|
+
envelope_integrity_error.setattr("__module__", "cachekit._rust_serializer")?;
|
|
47
|
+
m.add("EnvelopeIntegrityError", envelope_integrity_error)?;
|
|
48
|
+
|
|
30
49
|
// Standalone integrity primitive — registered unconditionally (usable with
|
|
31
50
|
// the checksum feature alone; must not vanish when encryption is off)
|
|
32
51
|
m.add_function(wrap_pyfunction!(python_bindings::checksum_py, m)?)?;
|
|
33
52
|
m.add_function(wrap_pyfunction!(python_bindings::verify_checksum_py, m)?)?;
|
|
34
53
|
|
|
54
|
+
// Untrusted-decode structural bound (LAB-2503) — zero-copy header walk that
|
|
55
|
+
// serializers/base.py::unpackb_bounded runs before every msgpack.unpackb
|
|
56
|
+
m.add_function(wrap_pyfunction!(
|
|
57
|
+
python_bindings::check_msgpack_structure_py,
|
|
58
|
+
m
|
|
59
|
+
)?)?;
|
|
60
|
+
|
|
35
61
|
// Add encryption functionality if feature is enabled
|
|
36
62
|
#[cfg(feature = "encryption")]
|
|
37
63
|
{
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
//! Structural bound for untrusted MessagePack (LAB-2503; protocol spec/interop-mode.md →
|
|
2
|
+
//! Decode bounds). The one algorithm this crate owns rather than delegates to cachekit-core;
|
|
3
|
+
//! a core-shared walk usable from py/rs/wasm is the follow-up. Mirrors the opcode table of
|
|
4
|
+
//! cachekit-rs `check_structure` so the two SDKs reject the same documents.
|
|
5
|
+
|
|
6
|
+
/// Header-only walk over one `MessagePack` document: str/bin/ext payloads are skipped by
|
|
7
|
+
/// offset, never read, and nothing is allocated beyond one `u64` per open collection.
|
|
8
|
+
///
|
|
9
|
+
/// Trailing bytes after the root element are left to the decoder (`ExtraData`).
|
|
10
|
+
///
|
|
11
|
+
/// # Errors
|
|
12
|
+
///
|
|
13
|
+
/// Names the violated bound, before any decoder pre-allocates a container, for:
|
|
14
|
+
/// - nesting deeper than `max_depth`;
|
|
15
|
+
/// - a header declaring more payload bytes than the input holds;
|
|
16
|
+
/// - more pending elements (across every open collection) than remaining bytes can back —
|
|
17
|
+
/// every element costs >= 1 byte, so a decoder's total container pre-allocation is then
|
|
18
|
+
/// bounded by the input length instead of by `depth × declared_len`;
|
|
19
|
+
/// - the reserved marker 0xc1 and input that ends mid-document.
|
|
20
|
+
pub fn check_msgpack_structure(bytes: &[u8], max_depth: usize) -> Result<(), String> {
|
|
21
|
+
fn be(bytes: &[u8], pos: usize, width: usize) -> Result<u64, String> {
|
|
22
|
+
let end = pos
|
|
23
|
+
.checked_add(width)
|
|
24
|
+
.filter(|e| *e <= bytes.len())
|
|
25
|
+
.ok_or_else(|| "ends inside a length prefix".to_owned())?;
|
|
26
|
+
Ok(bytes[pos..end]
|
|
27
|
+
.iter()
|
|
28
|
+
.fold(0u64, |acc, b| (acc << 8) | u64::from(*b)))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let mut pos = 0usize;
|
|
32
|
+
let mut pending: u64 = 1; // elements owed across all open collections (the root is one)
|
|
33
|
+
let mut open: Vec<u64> = Vec::new(); // elements still owed per open collection = depth
|
|
34
|
+
while pending > 0 {
|
|
35
|
+
while open.last() == Some(&0) {
|
|
36
|
+
open.pop();
|
|
37
|
+
}
|
|
38
|
+
let marker = *bytes
|
|
39
|
+
.get(pos)
|
|
40
|
+
.ok_or_else(|| "ends before the document is complete".to_owned())?;
|
|
41
|
+
pos += 1;
|
|
42
|
+
pending -= 1;
|
|
43
|
+
if let Some(innermost) = open.last_mut() {
|
|
44
|
+
*innermost -= 1;
|
|
45
|
+
}
|
|
46
|
+
// (length-prefix bytes, payload bytes after the prefix, child elements)
|
|
47
|
+
let (prefix, payload, children): (usize, u64, u64) = match marker {
|
|
48
|
+
0x00..=0x7f | 0xc0 | 0xc2 | 0xc3 | 0xe0..=0xff => (0, 0, 0),
|
|
49
|
+
0x80..=0x8f => (0, 0, 2 * u64::from(marker & 0x0f)),
|
|
50
|
+
0x90..=0x9f => (0, 0, u64::from(marker & 0x0f)),
|
|
51
|
+
0xa0..=0xbf => (0, u64::from(marker & 0x1f), 0),
|
|
52
|
+
0xc1 => return Err("contains the reserved marker 0xc1".to_owned()),
|
|
53
|
+
0xc4 | 0xd9 => (1, be(bytes, pos, 1)?, 0),
|
|
54
|
+
0xc5 | 0xda => (2, be(bytes, pos, 2)?, 0),
|
|
55
|
+
0xc6 | 0xdb => (4, be(bytes, pos, 4)?, 0),
|
|
56
|
+
0xc7 => (1, be(bytes, pos, 1)? + 1, 0), // ext: length prefix, then type byte + data
|
|
57
|
+
0xc8 => (2, be(bytes, pos, 2)? + 1, 0),
|
|
58
|
+
0xc9 => (4, be(bytes, pos, 4)? + 1, 0),
|
|
59
|
+
0xca..=0xd3 => (0, 1u64 << (marker & 0x03), 0), // f32/f64/u8..u64/i8..i64: 4,8,1,2,4,8,1,2,4,8
|
|
60
|
+
0xd4..=0xd8 => (0, 1 + (1u64 << (marker - 0xd4)), 0), // fixext: type byte + 1/2/4/8/16
|
|
61
|
+
0xdc => (2, 0, be(bytes, pos, 2)?),
|
|
62
|
+
0xdd => (4, 0, be(bytes, pos, 4)?),
|
|
63
|
+
0xde => (2, 0, 2 * be(bytes, pos, 2)?),
|
|
64
|
+
0xdf => (4, 0, 2 * be(bytes, pos, 4)?),
|
|
65
|
+
};
|
|
66
|
+
pos += prefix;
|
|
67
|
+
let remaining = (bytes.len() - pos) as u64;
|
|
68
|
+
if payload > remaining {
|
|
69
|
+
return Err("declares more bytes than the input holds".to_owned());
|
|
70
|
+
}
|
|
71
|
+
// <= remaining, so this cannot fail; `try_from` rather than `as usize` satisfies
|
|
72
|
+
// clippy::cast_possible_truncation, line-for-line with cachekit-rs `check_structure`.
|
|
73
|
+
pos += usize::try_from(payload)
|
|
74
|
+
.map_err(|_| "declares more bytes than the input holds".to_owned())?;
|
|
75
|
+
if children > 0 {
|
|
76
|
+
if open.len() >= max_depth {
|
|
77
|
+
return Err(format!("nests deeper than {max_depth} levels"));
|
|
78
|
+
}
|
|
79
|
+
open.push(children);
|
|
80
|
+
}
|
|
81
|
+
pending += children;
|
|
82
|
+
if pending > remaining - payload {
|
|
83
|
+
return Err("declares more elements than the input can back".to_owned());
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
Ok(())
|
|
87
|
+
}
|
|
@@ -1,14 +1,54 @@
|
|
|
1
1
|
//! Python bindings for cachekit-core
|
|
2
2
|
//!
|
|
3
|
-
//! This module provides thin PyO3 wrappers around cachekit-core functionality
|
|
4
|
-
//!
|
|
3
|
+
//! This module provides thin PyO3 wrappers around cachekit-core functionality, plus the
|
|
4
|
+
//! buffer-borrow helper they share. Business logic lives in cachekit-core, except the
|
|
5
|
+
//! SDK-owned msgpack decode bound in `crate::msgpack_bounds`.
|
|
5
6
|
|
|
7
|
+
use crate::msgpack_bounds::check_msgpack_structure;
|
|
8
|
+
use cachekit_core::byte_storage::ByteStorageError;
|
|
6
9
|
use cachekit_core::ByteStorage;
|
|
7
10
|
use pyo3::buffer::PyBuffer;
|
|
8
11
|
use pyo3::exceptions::PyValueError;
|
|
9
12
|
use pyo3::prelude::*;
|
|
10
13
|
use pyo3::types::PyBytes;
|
|
11
14
|
|
|
15
|
+
pyo3::create_exception!(
|
|
16
|
+
_rust_serializer,
|
|
17
|
+
EnvelopeIntegrityError,
|
|
18
|
+
PyValueError,
|
|
19
|
+
"A ByteStorage envelope parsed but failed verification: checksum mismatch, decompression\n\
|
|
20
|
+
bomb/failure, or a decoded size mismatch against the envelope header.\n\
|
|
21
|
+
\n\
|
|
22
|
+
Distinguishes a verified-but-corrupt envelope (this exception) from bytes that were never\n\
|
|
23
|
+
a ByteStorage envelope at all (`DeserializationFailed`, e.g. written with integrity\n\
|
|
24
|
+
checking off) — the latter stays a plain `ValueError`. Whether a caller may fall through\n\
|
|
25
|
+
on that `ValueError` is the caller's contract (`AutoSerializer.deserialize` does so only\n\
|
|
26
|
+
for a metadata-less direct call); this exception must always fail closed.\n\
|
|
27
|
+
\n\
|
|
28
|
+
Subclasses ValueError so existing `pytest.raises(ValueError)` assertions on `retrieve()`\n\
|
|
29
|
+
failures stay valid. `AutoSerializer.deserialize` catches this specifically and re-raises\n\
|
|
30
|
+
it as `SerializationError` without falling through."
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
/// Map a cachekit-core `retrieve()` failure onto the Python exception taxonomy.
|
|
34
|
+
///
|
|
35
|
+
/// `DeserializationFailed` means `envelope_bytes` never parsed as a `StorageEnvelope` — not
|
|
36
|
+
/// corruption, just "not an envelope" — so it stays a plain `ValueError`, the fall-through
|
|
37
|
+
/// signal `AutoSerializer.deserialize` depends on. Every other variant is mapped to
|
|
38
|
+
/// `EnvelopeIntegrityError` and must fail closed: the post-parse checks (checksum, decompressed
|
|
39
|
+
/// size, decompression itself, the compression-ratio bomb guard) are genuine corruption or
|
|
40
|
+
/// tampering, and `InputTooLarge` — raised on the raw `envelope_bytes` length before parsing is
|
|
41
|
+
/// even attempted — is deliberately bucketed the same way rather than treated as "not an
|
|
42
|
+
/// envelope": falling through would hand an oversized blob to the plain-msgpack decode path
|
|
43
|
+
/// instead of rejecting it outright, trading one size guard for a weaker one.
|
|
44
|
+
fn retrieve_error_to_py(err: ByteStorageError) -> PyErr {
|
|
45
|
+
let message = format!("Retrieval failed: {}", err);
|
|
46
|
+
match err {
|
|
47
|
+
ByteStorageError::DeserializationFailed(_) => PyValueError::new_err(message),
|
|
48
|
+
_ => EnvelopeIntegrityError::new_err(message),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
12
52
|
/// Python wrapper for ByteStorage
|
|
13
53
|
#[pyclass(name = "ByteStorage")]
|
|
14
54
|
pub struct PyByteStorage {
|
|
@@ -44,6 +84,67 @@ fn borrowable_offset(buf: &PyBuffer<u8>, base: &Bound<'_, PyBytes>) -> Option<us
|
|
|
44
84
|
.then(|| ptr - start)
|
|
45
85
|
}
|
|
46
86
|
|
|
87
|
+
/// A read-only view of a Python buffer-protocol object's bytes, borrowed without a copy
|
|
88
|
+
/// whenever that is provably sound and copied otherwise. Holds whatever keeps the memory
|
|
89
|
+
/// alive (the `bytes` object, or the owned copy) so `as_slice` needs no `unsafe`.
|
|
90
|
+
enum BytesView<'py> {
|
|
91
|
+
/// `(base, offset, len)`: a window onto an immutable `bytes` object kept alive by the
|
|
92
|
+
/// Bound — the whole object, or the read-only C-contiguous `memoryview` of it that
|
|
93
|
+
/// `SerializationWrapper.unwrap` produces, proven by `borrowable_offset`. Zero-copy.
|
|
94
|
+
Borrowed(Bound<'py, PyBytes>, usize, usize),
|
|
95
|
+
/// Mutable, non-`bytes`-backed, strided, or empty exporter: the only safe answer is a copy.
|
|
96
|
+
Owned(Vec<u8>),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
impl BytesView<'_> {
|
|
100
|
+
fn as_slice(&self) -> &[u8] {
|
|
101
|
+
match self {
|
|
102
|
+
BytesView::Borrowed(base, off, len) => &base.as_bytes()[*off..*off + *len],
|
|
103
|
+
BytesView::Owned(v) => v,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// Borrow `obj`'s bytes zero-copy when the BACKING STORAGE is provably immutable, else copy.
|
|
109
|
+
///
|
|
110
|
+
/// `readonly()` describes the view, not the exporter (`memoryview(bytearray).toreadonly()`
|
|
111
|
+
/// passes it while another thread can still mutate the bytearray), and a PEP 688
|
|
112
|
+
/// `__buffer__` exporter can name a decoy `bytes` in `.obj` — so the gate is the containment
|
|
113
|
+
/// proof in `borrowable_offset`, whose payoff is that the borrow is an ORDINARY SLICE of that
|
|
114
|
+
/// `bytes`: bounds-checked by Rust, no `unsafe`, nothing for a stale comment to misstate.
|
|
115
|
+
fn bytes_view<'py>(py: Python<'py>, obj: &Bound<'py, PyAny>) -> PyResult<BytesView<'py>> {
|
|
116
|
+
if let Ok(b) = obj.cast::<PyBytes>() {
|
|
117
|
+
return Ok(BytesView::Borrowed(b.clone(), 0, b.len()?));
|
|
118
|
+
}
|
|
119
|
+
let buf = PyBuffer::<u8>::get(obj)?;
|
|
120
|
+
let base = obj
|
|
121
|
+
.getattr("obj")
|
|
122
|
+
.ok()
|
|
123
|
+
.and_then(|base| base.cast_into::<PyBytes>().ok());
|
|
124
|
+
if let Some(base) = base {
|
|
125
|
+
if let Some(off) = borrowable_offset(&buf, &base) {
|
|
126
|
+
return Ok(BytesView::Borrowed(base, off, buf.item_count()));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
Ok(BytesView::Owned(buf.to_vec(py)?))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/// Reject a MessagePack document whose headers would make decoding it allocate out of
|
|
133
|
+
/// proportion to its size — see `check_msgpack_structure`. Zero-copy for `bytes` and for
|
|
134
|
+
/// read-only `memoryview`s of `bytes`; raises ValueError naming the violated bound.
|
|
135
|
+
#[pyfunction]
|
|
136
|
+
#[pyo3(name = "check_msgpack_structure")]
|
|
137
|
+
pub fn check_msgpack_structure_py(
|
|
138
|
+
py: Python<'_>,
|
|
139
|
+
data: &Bound<'_, PyAny>,
|
|
140
|
+
max_depth: usize,
|
|
141
|
+
) -> PyResult<()> {
|
|
142
|
+
let view = bytes_view(py, data)?;
|
|
143
|
+
check_msgpack_structure(view.as_slice(), max_depth).map_err(|what| {
|
|
144
|
+
PyValueError::new_err(format!("Unpack failed: MessagePack document {what}"))
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
47
148
|
#[pymethods]
|
|
48
149
|
impl PyByteStorage {
|
|
49
150
|
#[new]
|
|
@@ -86,44 +187,13 @@ impl PyByteStorage {
|
|
|
86
187
|
py: Python,
|
|
87
188
|
envelope_bytes: &Bound<'_, PyAny>,
|
|
88
189
|
) -> PyResult<(Vec<u8>, String)> {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
let
|
|
92
|
-
let data
|
|
93
|
-
// `bytes` is immutable and kept alive by the Bound for the whole call:
|
|
94
|
-
// a zero-copy borrow with no data-race exposure.
|
|
95
|
-
b.as_bytes()
|
|
96
|
-
} else {
|
|
97
|
-
buf = PyBuffer::get(envelope_bytes)?;
|
|
98
|
-
// Borrowing across the GIL release below is only sound when the BACKING
|
|
99
|
-
// STORAGE is immutable — readonly() describes the view, not the exporter
|
|
100
|
-
// (memoryview(bytearray).toreadonly() passes it while another thread can
|
|
101
|
-
// still mutate the bytearray). Attribute trust is not enough either: a
|
|
102
|
-
// PEP 688 __buffer__ exporter can name a decoy `bytes` in `.obj`. So the
|
|
103
|
-
// gate is a containment proof (borrowable_offset), and its payoff is that
|
|
104
|
-
// the borrow becomes expressible as an ORDINARY SLICE of that `bytes` —
|
|
105
|
-
// bounds-checked by Rust, no `unsafe`, nothing for a stale comment to
|
|
106
|
-
// misstate. Anything unproven falls back to a copy.
|
|
107
|
-
base_bytes = envelope_bytes
|
|
108
|
-
.getattr("obj")
|
|
109
|
-
.ok()
|
|
110
|
-
.and_then(|base| base.cast_into::<PyBytes>().ok());
|
|
111
|
-
let borrowed = base_bytes.as_ref().and_then(|base| {
|
|
112
|
-
borrowable_offset(&buf, base)
|
|
113
|
-
.map(|off| &base.as_bytes()[off..off + buf.item_count()])
|
|
114
|
-
});
|
|
115
|
-
match borrowed {
|
|
116
|
-
Some(slice) => slice,
|
|
117
|
-
None => {
|
|
118
|
-
// Mutable, non-bytes-backed, non-contiguous, or empty exporter.
|
|
119
|
-
owned = buf.to_vec(py)?;
|
|
120
|
-
&owned
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
};
|
|
190
|
+
// Borrowing across the GIL release below is only sound when the backing storage
|
|
191
|
+
// is immutable — bytes_view proves that or copies (see its doc).
|
|
192
|
+
let view = bytes_view(py, envelope_bytes)?;
|
|
193
|
+
let data = view.as_slice();
|
|
124
194
|
// Detach from the GIL for decompression + checksum (see store()).
|
|
125
195
|
py.detach(|| self.inner.retrieve(data))
|
|
126
|
-
.map_err(
|
|
196
|
+
.map_err(retrieve_error_to_py)
|
|
127
197
|
}
|
|
128
198
|
|
|
129
199
|
/// Get compression ratio for given data
|
|
@@ -11,6 +11,7 @@ enable advanced features with graceful degradation.
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
from collections.abc import AsyncIterator, Callable
|
|
14
|
+
from contextlib import AbstractAsyncContextManager
|
|
14
15
|
from typing import Any, BinaryIO, Optional, Protocol, runtime_checkable
|
|
15
16
|
|
|
16
17
|
# Re-export BackendError for convenience (public API)
|
|
@@ -269,8 +270,11 @@ class LockableBackend(Protocol):
|
|
|
269
270
|
features like cache stampede prevention and critical sections.
|
|
270
271
|
|
|
271
272
|
Not all backends support this capability:
|
|
272
|
-
- Supported:
|
|
273
|
-
|
|
273
|
+
- Supported: ``PerRequestRedisBackend`` — what ``RedisBackendProvider`` and
|
|
274
|
+
therefore the env-resolved Redis path hand out — and ``CachekitIOBackend``
|
|
275
|
+
(SaaS ``POST /v1/cache/{key}/lock``).
|
|
276
|
+
- Not supported: ``RedisBackend`` constructed directly and passed as
|
|
277
|
+
``backend=``, ``FileBackend``, L1-only (in-memory).
|
|
274
278
|
|
|
275
279
|
Contract — bare cache key:
|
|
276
280
|
``acquire_lock`` receives the **bare cache key**, identical to what
|
|
@@ -293,12 +297,12 @@ class LockableBackend(Protocol):
|
|
|
293
297
|
>>> # result = expensive_computation()
|
|
294
298
|
"""
|
|
295
299
|
|
|
296
|
-
|
|
300
|
+
def acquire_lock(
|
|
297
301
|
self,
|
|
298
302
|
key: str,
|
|
299
303
|
timeout: float,
|
|
300
304
|
blocking_timeout: Optional[float] = None,
|
|
301
|
-
) ->
|
|
305
|
+
) -> AbstractAsyncContextManager[bool]:
|
|
302
306
|
"""Acquire a distributed lock on key.
|
|
303
307
|
|
|
304
308
|
Args:
|
|
@@ -309,9 +313,11 @@ class LockableBackend(Protocol):
|
|
|
309
313
|
timeout: How long to hold the lock (seconds) before auto-release
|
|
310
314
|
blocking_timeout: Max time to wait for lock acquisition (None = non-blocking)
|
|
311
315
|
|
|
312
|
-
|
|
313
|
-
True if lock was acquired
|
|
314
|
-
False if
|
|
316
|
+
Returns:
|
|
317
|
+
An async context manager yielding True if the lock was acquired,
|
|
318
|
+
False if the wait timed out. Implementations are ``async``
|
|
319
|
+
generators wrapped in ``@asynccontextmanager``, so this protocol
|
|
320
|
+
declares the *decorated* shape.
|
|
315
321
|
|
|
316
322
|
Raises:
|
|
317
323
|
BackendError: If backend operation fails
|