cachekit 0.23.0__tar.gz → 0.24.1__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.23.0 → cachekit-0.24.1}/Cargo.lock +1 -1
- {cachekit-0.23.0 → cachekit-0.24.1}/PKG-INFO +2 -1
- {cachekit-0.23.0 → cachekit-0.24.1}/README.md +1 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/pyproject.toml +11 -1
- {cachekit-0.23.0 → cachekit-0.24.1}/rust/Cargo.toml +1 -1
- {cachekit-0.23.0 → cachekit-0.24.1}/rust/README.md +1 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/__init__.py +1 -1
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/base.py +6 -3
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/file/backend.py +28 -6
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/provider.py +7 -6
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/cache_handler.py +78 -22
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/config/decorator.py +12 -3
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/config/settings.py +2 -3
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/decorators/wrapper.py +26 -12
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/reliability/metrics_collection.py +4 -4
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/serializers/arrow_serializer.py +50 -12
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/serializers/wrapper.py +31 -4
- {cachekit-0.23.0 → cachekit-0.24.1}/Cargo.toml +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/LICENSE +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/rust/Makefile +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/rust/TEST_EXPANSION_SUMMARY.md +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/rust/src/lib.rs +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/rust/src/msgpack_bounds.rs +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/rust/src/python_bindings.rs +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/rust/tsan_suppressions.txt +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/_fork.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/__init__.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/_uninterrupted.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/base_config.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/cachekitio/__init__.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/cachekitio/backend.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/cachekitio/client.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/cachekitio/config.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/cachekitio/error_handler.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/cachekitio/session.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/errors.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/file/__init__.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/file/config.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/memcached/__init__.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/memcached/backend.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/memcached/config.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/memcached/error_handler.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/redis/__init__.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/redis/backend.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/redis/client.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/redis/config.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/redis/error_handler.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/backends/redis/provider.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/config/__init__.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/config/nested.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/config/singleton.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/config/validation.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/decorators/__init__.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/decorators/intent.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/decorators/local_wrapper.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/decorators/main.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/decorators/orchestrator.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/decorators/session.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/decorators/single_flight.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/decorators/stats_context.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/decorators/tenant_context.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/decorators/utils/__init__.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/di.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/hash_utils.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/health.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/hiredis_compat.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/imports.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/interop.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/invalidation.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/key_generator.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/l1_cache.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/logging.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/monitoring/__init__.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/monitoring/protocols.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/object_cache.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/py.typed +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/reliability/__init__.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/reliability/async_metrics.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/reliability/circuit_breaker.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/reliability/error_classification.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/reliability/load_control.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/serializers/__init__.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/serializers/auto_serializer.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/serializers/base.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/serializers/encryption_wrapper.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/serializers/interop_serializer.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/serializers/orjson_serializer.py +0 -0
- {cachekit-0.23.0 → cachekit-0.24.1}/src/cachekit/serializers/standard_serializer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cachekit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.24.1
|
|
4
4
|
Classifier: Development Status :: 4 - Beta
|
|
5
5
|
Classifier: Intended Audience :: Developers
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -456,6 +456,7 @@ get_score(2) # served from cache
|
|
|
456
456
|
info = get_score.cache_info()
|
|
457
457
|
# CacheInfo has 9 fields: hits, misses, l1_hits, l2_hits, maxsize,
|
|
458
458
|
# currsize, l2_avg_latency_ms, last_operation_at, session_id
|
|
459
|
+
assert (info.hits, info.misses) == (1, 1) # the second call was a hit
|
|
459
460
|
assert info.l1_hits + info.l2_hits == info.hits # every hit is L1 or L2
|
|
460
461
|
```
|
|
461
462
|
|
|
@@ -402,6 +402,7 @@ get_score(2) # served from cache
|
|
|
402
402
|
info = get_score.cache_info()
|
|
403
403
|
# CacheInfo has 9 fields: hits, misses, l1_hits, l2_hits, maxsize,
|
|
404
404
|
# currsize, l2_avg_latency_ms, last_operation_at, session_id
|
|
405
|
+
assert (info.hits, info.misses) == (1, 1) # the second call was a hit
|
|
405
406
|
assert info.l1_hits + info.l2_hits == info.hits # every hit is L1 or L2
|
|
406
407
|
```
|
|
407
408
|
|
|
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cachekit"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.24.1"
|
|
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"}
|
|
@@ -136,6 +136,7 @@ ignore = [
|
|
|
136
136
|
[tool.ruff.lint.per-file-ignores]
|
|
137
137
|
"__init__.py" = ["F401"]
|
|
138
138
|
"tests/**/*" = ["S101", "S105", "S110", "S112", "S311", "D", "F841", "N802"]
|
|
139
|
+
"journeys/**/*" = ["S101"] # a journey states its promise as an assert
|
|
139
140
|
"src/cachekit/logging.py" = ["S110"] # Logging errors would create recursion
|
|
140
141
|
"src/cachekit/reliability/async_metrics.py" = ["S101"] # Internal invariant checks
|
|
141
142
|
"src/cachekit/serializers/__init__.py" = ["S101"] # Module-level validation
|
|
@@ -157,6 +158,8 @@ python_classes = ["Test*"]
|
|
|
157
158
|
python_functions = ["test_*"]
|
|
158
159
|
asyncio_mode = "auto"
|
|
159
160
|
asyncio_default_fixture_loop_scope = "function"
|
|
161
|
+
# An xfail that starts passing fails the run, so a fixed defect cannot hide behind its marker.
|
|
162
|
+
xfail_strict = true
|
|
160
163
|
addopts = [
|
|
161
164
|
"--strict-markers",
|
|
162
165
|
"--verbose",
|
|
@@ -252,6 +255,9 @@ dev = [
|
|
|
252
255
|
# OrjsonSerializer support — now the [json] optional extra; kept here so the
|
|
253
256
|
# orjson tests, doctests, and markdown-docs still resolve it in dev/CI.
|
|
254
257
|
"orjson>=3.9.0",
|
|
258
|
+
# ArrowSerializer return_format="polars" tests (zero-copy column aliasing). Not in `test`:
|
|
259
|
+
# polars ships abi3 wheels only, which a free-threaded interpreter cannot install.
|
|
260
|
+
"polars>=2.0.0",
|
|
255
261
|
]
|
|
256
262
|
# Linux CI only - Atheris requires libFuzzer (not available on macOS without building LLVM)
|
|
257
263
|
fuzz = [
|
|
@@ -272,4 +278,8 @@ constraint-dependencies = [
|
|
|
272
278
|
# confusion) and GHSA-jp4c-xjxw-mgf9 (self-update import ordering); 26.2 fixes
|
|
273
279
|
# PYSEC-2026-3721 (doubly-encoded index URLs install to arbitrary paths).
|
|
274
280
|
"pip>=26.2",
|
|
281
|
+
# certifi is a dev-only transitive dep (pip-audit -> requests -> certifi).
|
|
282
|
+
# 2026.10.19 fixes PYSEC-2026-4320 (distrusts the emSign ECC Root CA - G3,
|
|
283
|
+
# whose intermediates had compromised keys).
|
|
284
|
+
"certifi>=2026.10.19",
|
|
275
285
|
]
|
|
@@ -402,6 +402,7 @@ get_score(2) # served from cache
|
|
|
402
402
|
info = get_score.cache_info()
|
|
403
403
|
# CacheInfo has 9 fields: hits, misses, l1_hits, l2_hits, maxsize,
|
|
404
404
|
# currsize, l2_avg_latency_ms, last_operation_at, session_id
|
|
405
|
+
assert (info.hits, info.misses) == (1, 1) # the second call was a hit
|
|
405
406
|
assert info.l1_hits + info.l2_hits == info.hits # every hit is L1 or L2
|
|
406
407
|
```
|
|
407
408
|
|
|
@@ -186,9 +186,12 @@ class BufferHandle(Protocol):
|
|
|
186
186
|
"""A borrowed, zero-copy view of a cached value plus the resource backing it.
|
|
187
187
|
|
|
188
188
|
Returned by ``BufferReadableBackend.get_buffer``. ``view`` aliases backend-owned memory (e.g.
|
|
189
|
-
mmap'd file pages), not a heap copy
|
|
190
|
-
|
|
191
|
-
it must never be stored (e.g. in L1) nor returned past the read
|
|
189
|
+
mmap'd file pages), not a heap copy. The consumer reads it, then calls ``close()``; the view
|
|
190
|
+
is unusable afterwards (unless the consumer exported the view itself, which keeps it readable
|
|
191
|
+
until that export dies), so it must never be stored (e.g. in L1) nor returned past the read
|
|
192
|
+
call frame (#171). An object built over the view (an Arrow table) keeps the backing memory
|
|
193
|
+
alive past ``close()`` through its buffer exports: the implementation must never free memory
|
|
194
|
+
that is still exported.
|
|
192
195
|
"""
|
|
193
196
|
|
|
194
197
|
view: memoryview
|
|
@@ -22,6 +22,7 @@ import os
|
|
|
22
22
|
import platform
|
|
23
23
|
import stat
|
|
24
24
|
import struct
|
|
25
|
+
import sys
|
|
25
26
|
import threading
|
|
26
27
|
import time
|
|
27
28
|
from collections.abc import Callable
|
|
@@ -72,6 +73,12 @@ MAX_TTL_SECONDS: int = 10 * 365 * 24 * 60 * 60 # 10 years max
|
|
|
72
73
|
# unbounded region. Above this, get_buffer() returns None and the caller falls back to os.read.
|
|
73
74
|
MMAP_MAX_BYTES: int = 512 * 1024 * 1024 # 512 MB
|
|
74
75
|
|
|
76
|
+
# Python 3.13+ maps without keeping a duplicate of the file descriptor (trackfd=False). Before 3.13
|
|
77
|
+
# every mmap object holds one descriptor until it is freed, so a result that keeps its mapping alive
|
|
78
|
+
# also keeps a descriptor open, and a few hundred held results exhaust RLIMIT_NOFILE.
|
|
79
|
+
MMAP_RELEASES_FD: bool = sys.version_info >= (3, 13)
|
|
80
|
+
_MMAP_KWARGS: dict[str, Any] = {"trackfd": False} if MMAP_RELEASES_FD else {}
|
|
81
|
+
|
|
75
82
|
|
|
76
83
|
def _warn_throttled(throttle: _WarnThrottle, msg: str, *args: object) -> None:
|
|
77
84
|
"""Log ``msg`` at WARNING once per throttle window, with the failures since the last one; DEBUG otherwise."""
|
|
@@ -134,9 +141,24 @@ class _MmapHandle:
|
|
|
134
141
|
"""Owns a read-only mmap of a cache file plus a memoryview of its payload (past the 14-byte
|
|
135
142
|
header). Zero-copy: the view aliases mapped pages, never a heap copy.
|
|
136
143
|
|
|
137
|
-
The CALLER must ``close()`` once
|
|
138
|
-
|
|
139
|
-
|
|
144
|
+
The CALLER must ``close()`` once deserialize returns. Lifetime contract (CPython):
|
|
145
|
+
|
|
146
|
+
- ``close()`` releases the handle's own view, so touching ``handle.view`` afterwards raises
|
|
147
|
+
``ValueError`` (unless a consumer exported ``handle.view`` itself: then the release refuses
|
|
148
|
+
and the view stays readable until that export dies). Either way the handle must never
|
|
149
|
+
escape the read call frame nor reach L1 (#171 blocker C).
|
|
150
|
+
- Anything deserialize built over the mapping (an Arrow table, a polars frame, or pandas
|
|
151
|
+
columns that are views) holds buffer exports of it. ``mmap.close()`` refuses with
|
|
152
|
+
``BufferError`` while any export lives; ``close()`` swallows that, and the mapping is
|
|
153
|
+
unmapped when the last export is released. A result therefore never dangles.
|
|
154
|
+
- A mapping that outlives the read costs no file descriptor on Python 3.13+ (``trackfd=False``).
|
|
155
|
+
Before 3.13 the mmap object holds a duplicate descriptor until it is freed, which is why the
|
|
156
|
+
cache handler maps only pandas results there: they are meant to copy out, and only a pandas
|
|
157
|
+
column that stays a view of the mapped buffers keeps the mapping and its descriptor.
|
|
158
|
+
- The file's pages stay valid while mapped because cachekit never rewrites a cache file in
|
|
159
|
+
place: writes go to a temp file and ``rename``, eviction and delete ``unlink``, and both
|
|
160
|
+
leave an existing mapping intact. A process that truncates a cache file in place while a
|
|
161
|
+
result is held makes the next touch of those pages raise ``SIGBUS``.
|
|
140
162
|
"""
|
|
141
163
|
|
|
142
164
|
__slots__ = ("_mm", "view")
|
|
@@ -151,11 +173,11 @@ class _MmapHandle:
|
|
|
151
173
|
"""Release the view then the mapping. Idempotent (safe to call more than once)."""
|
|
152
174
|
try:
|
|
153
175
|
self.view.release() # must release exports before mmap.close(), else BufferError
|
|
154
|
-
except
|
|
176
|
+
except BufferError: # a consumer exported this view itself; it keeps the mapping alive
|
|
155
177
|
pass
|
|
156
178
|
try:
|
|
157
179
|
self._mm.close()
|
|
158
|
-
except
|
|
180
|
+
except BufferError: # a result still holds exports of the mapping: refcounting unmaps it later
|
|
159
181
|
pass
|
|
160
182
|
|
|
161
183
|
|
|
@@ -395,7 +417,7 @@ class FileBackend:
|
|
|
395
417
|
if st_size <= HEADER_SIZE or st_size > MMAP_MAX_BYTES:
|
|
396
418
|
return None
|
|
397
419
|
|
|
398
|
-
mm = mmap.mmap(fd, st_size, access=mmap.ACCESS_READ)
|
|
420
|
+
mm = mmap.mmap(fd, st_size, access=mmap.ACCESS_READ, **_MMAP_KWARGS)
|
|
399
421
|
handle = _MmapHandle(mm)
|
|
400
422
|
mm = None # ownership transferred to the handle; don't close it in finally
|
|
401
423
|
return handle
|
|
@@ -251,12 +251,13 @@ class DefaultBackendProvider(BackendProviderInterface):
|
|
|
251
251
|
def _is_configured(self) -> bool:
|
|
252
252
|
"""Whether the environment names a backend: any prefixed selector, or ``REDIS_URL``.
|
|
253
253
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
254
|
+
A selector ``_matched()`` finds counts first, because ``_detect()`` routes on it: with two
|
|
255
|
+
spellings of one set, pydantic keeps one (an empty one can win), while ``_detect()`` still routes
|
|
256
|
+
on the exact-case one. Then ``_BackendEnv`` reads every name through pydantic-settings with
|
|
257
|
+
``case_sensitive=False``, exactly as ``RedisBackendConfig`` reads them: a lowercase ``redis_url``
|
|
258
|
+
always reached that Redis through ``get_backend()``'s fallback. ``_detect()`` stays case-sensitive
|
|
259
|
+
on purpose: a lowercase prefixed selector counts as configured here and still resolves Redis
|
|
260
|
+
there, as before; aligning either method changes routing.
|
|
260
261
|
Never raises: two selectors count as configured, so ``get_backend()`` raises the ambiguity
|
|
261
262
|
error where the decorator already handles a backend that fails to build.
|
|
262
263
|
"""
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
import asyncio
|
|
10
10
|
import contextlib
|
|
11
11
|
import contextvars
|
|
12
|
+
import sys
|
|
12
13
|
import threading
|
|
13
14
|
import types
|
|
14
15
|
import warnings
|
|
@@ -27,6 +28,8 @@ from cachekit.backends.base import (
|
|
|
27
28
|
LockableBackend,
|
|
28
29
|
TTLInspectableBackend,
|
|
29
30
|
)
|
|
31
|
+
from cachekit.backends.file.backend import HEADER_SIZE as FILE_HEADER_SIZE
|
|
32
|
+
from cachekit.backends.file.backend import MMAP_RELEASES_FD
|
|
30
33
|
from cachekit.backends.provider import (
|
|
31
34
|
BackendProviderInterface,
|
|
32
35
|
DefaultBackendProvider,
|
|
@@ -72,6 +75,14 @@ if TYPE_CHECKING:
|
|
|
72
75
|
# it chooses its decoder by inspecting the bytes.
|
|
73
76
|
CROSS_SDK_SERIALIZER_NAMES = ("default", "std", "standard", "orjson", "arrow")
|
|
74
77
|
|
|
78
|
+
# Uncompressed Arrow IPC alignment: the body follows the File header, the CK frame
|
|
79
|
+
# prefix and ArrowSerializer's 8-byte xxHash3-64 checksum; 64 is Arrow's recommended alignment.
|
|
80
|
+
_ARROW_IPC_ALIGN = 64
|
|
81
|
+
_ARROW_CHECKSUM_SIZE = 8
|
|
82
|
+
# Frame tags an ArrowSerializer can record: its string name, or the class name of an instance. A set
|
|
83
|
+
# lookup on the tag screens every other serializer out before the type check, on every read and write.
|
|
84
|
+
_ARROW_FRAME_TAGS = frozenset({"arrow", "ArrowSerializer"})
|
|
85
|
+
|
|
75
86
|
# Global DI container instance with default registrations
|
|
76
87
|
container = DIContainer()
|
|
77
88
|
container.register(LoggerProvider, DefaultLoggerProvider)
|
|
@@ -659,7 +670,7 @@ class CacheSerializationHandler:
|
|
|
659
670
|
|
|
660
671
|
Args:
|
|
661
672
|
serializer_name: Serializer instance or name. Accepts either:
|
|
662
|
-
- String name: "default" (MessagePack), "arrow" (
|
|
673
|
+
- String name: "default" (MessagePack), "arrow" (DataFrames as Arrow IPC), "orjson" (JSON)
|
|
663
674
|
- SerializerProtocol instance: Custom serializer implementing the protocol
|
|
664
675
|
encryption: Tri-state encryption control (wraps serializer with EncryptionWrapper):
|
|
665
676
|
- None (default): no intent stated. Plaintext when no master key is present;
|
|
@@ -1137,7 +1148,12 @@ class CacheSerializationHandler:
|
|
|
1137
1148
|
else:
|
|
1138
1149
|
# Convert metadata to dict if needed
|
|
1139
1150
|
metadata_dict = metadata.to_dict() if hasattr(metadata, "to_dict") else {}
|
|
1140
|
-
|
|
1151
|
+
if self._serializer_string_name in _ARROW_FRAME_TAGS:
|
|
1152
|
+
# One prefix builder for this path and write_serialized_to, so buffered and streamed
|
|
1153
|
+
# entries stay byte-identical (the payload is copied once, as in wrap()).
|
|
1154
|
+
wrapped = self._frame_prefix(metadata_dict) + serialized_data
|
|
1155
|
+
else:
|
|
1156
|
+
wrapped = SerializationWrapper.wrap(serialized_data, metadata_dict, self._serializer_string_name)
|
|
1141
1157
|
except ValueError:
|
|
1142
1158
|
# Tenant extraction, cache_key missing, or an interop data-model
|
|
1143
1159
|
# rejection (InteropError) — FAIL CLOSED / fail loud (re-raise).
|
|
@@ -1162,28 +1178,60 @@ class CacheSerializationHandler:
|
|
|
1162
1178
|
)
|
|
1163
1179
|
return wrapped
|
|
1164
1180
|
|
|
1181
|
+
def _is_cachekit_arrow(self) -> bool:
|
|
1182
|
+
"""True iff the base serializer is exactly cachekit's ArrowSerializer, by object, not frame tag.
|
|
1183
|
+
|
|
1184
|
+
``serializer="arrow"`` and an ``ArrowSerializer(...)`` instance both qualify; the instance's
|
|
1185
|
+
frame tag is its class name, which is why the old ``== "arrow"`` test missed it. Exact type:
|
|
1186
|
+
a subclass may override ``serialize`` and stays on the buffered paths. Read through
|
|
1187
|
+
``sys.modules`` so a handler that never loaded the module never imports pyarrow here.
|
|
1188
|
+
"""
|
|
1189
|
+
if self._serializer_string_name not in _ARROW_FRAME_TAGS:
|
|
1190
|
+
return False
|
|
1191
|
+
module = sys.modules.get("cachekit.serializers.arrow_serializer")
|
|
1192
|
+
return module is not None and type(self._base_serializer) is module.ArrowSerializer
|
|
1193
|
+
|
|
1194
|
+
def _frame_prefix(self, metadata: dict[str, Any]) -> bytes:
|
|
1195
|
+
"""The CK frame prefix for a plaintext payload with ``metadata``.
|
|
1196
|
+
|
|
1197
|
+
Uncompressed cachekit Arrow gets its header padded so the Arrow IPC body starts on a
|
|
1198
|
+
64-byte boundary of a File-backend cache file (File header + prefix + 8-byte checksum):
|
|
1199
|
+
polars copies every misaligned numeric column, and pyarrow prefers aligned buffers.
|
|
1200
|
+
Compressed, encrypted and every other payload are left unpadded. The prefix depends only on
|
|
1201
|
+
the serializer configuration, so other backends carry the same padding (and an unaligned body).
|
|
1202
|
+
"""
|
|
1203
|
+
if not self.encryption and self._is_cachekit_arrow() and not metadata.get("compressed"):
|
|
1204
|
+
return SerializationWrapper.wrap_prefix(
|
|
1205
|
+
metadata, self._serializer_string_name, align=_ARROW_IPC_ALIGN, offset=FILE_HEADER_SIZE + _ARROW_CHECKSUM_SIZE
|
|
1206
|
+
)
|
|
1207
|
+
return SerializationWrapper.wrap_prefix(metadata, self._serializer_string_name)
|
|
1208
|
+
|
|
1165
1209
|
def supports_mmap_read(self) -> bool:
|
|
1166
|
-
"""True iff reads can use the
|
|
1210
|
+
"""True iff reads can use the mmap fast path (#171).
|
|
1167
1211
|
|
|
1168
|
-
Eligible only for PLAINTEXT Arrow
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1212
|
+
Eligible only for PLAINTEXT cachekit Arrow (``serializer="arrow"`` or an
|
|
1213
|
+
``ArrowSerializer(...)`` instance): encrypted values can never mmap (AES-GCM decrypt owns
|
|
1214
|
+
its buffer), and other serializers copy at their Rust/C boundary anyway. Every return_format
|
|
1215
|
+
is eligible on Python 3.13+, pandas only before it (see below). A result may keep the mapping
|
|
1216
|
+
alive past the read; ``_MmapHandle`` states that lifetime contract.
|
|
1173
1217
|
|
|
1174
1218
|
The backend must also support buffer reads (File/POSIX); that is checked separately, so a
|
|
1175
1219
|
True here on a non-File backend simply means get_buffer returns None and we fall back.
|
|
1176
1220
|
"""
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1221
|
+
# The tag screen first: it keeps every other serializer's read free of the helper call.
|
|
1222
|
+
if self.encryption or self._serializer_string_name not in _ARROW_FRAME_TAGS or not self._is_cachekit_arrow():
|
|
1223
|
+
return False
|
|
1224
|
+
# An Arrow or polars result keeps the mapping, and before Python 3.13 each mapping also keeps a
|
|
1225
|
+
# file descriptor, so those return formats map only where the mapping holds none. A pandas
|
|
1226
|
+
# result is meant to copy out; any pandas column that stays a view of the mapped Arrow
|
|
1227
|
+
# buffers still keeps the mapping (and, before 3.13, its descriptor) until it is released.
|
|
1228
|
+
return MMAP_RELEASES_FD or getattr(self._base_serializer, "return_format", None) == "pandas"
|
|
1182
1229
|
|
|
1183
1230
|
def supports_streaming_write(self) -> bool:
|
|
1184
1231
|
"""True iff writes can stream the envelope to a capable backend (LAB-766).
|
|
1185
1232
|
|
|
1186
|
-
Eligible only for PLAINTEXT Arrow
|
|
1233
|
+
Eligible only for PLAINTEXT cachekit Arrow (``serializer="arrow"`` or an
|
|
1234
|
+
``ArrowSerializer(...)`` instance):
|
|
1187
1235
|
- encrypted values can never stream (AES-256-GCM emits its auth tag only after the
|
|
1188
1236
|
whole ciphertext exists, so the secure path stays on buffered ``set(bytes)``);
|
|
1189
1237
|
- interop/v1 stores raw documents with no CK envelope (different serializer, no benefit);
|
|
@@ -1193,7 +1241,12 @@ class CacheSerializationHandler:
|
|
|
1193
1241
|
separately at the cache-handler layer, so True here on a non-streaming backend simply
|
|
1194
1242
|
means the write falls back to the buffered path.
|
|
1195
1243
|
"""
|
|
1196
|
-
return
|
|
1244
|
+
return (
|
|
1245
|
+
not self.encryption
|
|
1246
|
+
and not self.interop_mode
|
|
1247
|
+
and self._serializer_string_name in _ARROW_FRAME_TAGS
|
|
1248
|
+
and self._is_cachekit_arrow()
|
|
1249
|
+
)
|
|
1197
1250
|
|
|
1198
1251
|
def write_serialized_to(self, sink: BinaryIO, data: Any) -> None:
|
|
1199
1252
|
"""Streaming twin of :meth:`serialize_data` (LAB-766): write the SAME envelope bytes into
|
|
@@ -1223,7 +1276,7 @@ class CacheSerializationHandler:
|
|
|
1223
1276
|
raise SerializationError(f"write_serialized_to requires the arrow serializer, got {type(serializer).__name__}")
|
|
1224
1277
|
|
|
1225
1278
|
metadata = serializer.serialization_metadata()
|
|
1226
|
-
prefix =
|
|
1279
|
+
prefix = self._frame_prefix(metadata.to_dict())
|
|
1227
1280
|
sink.write(prefix)
|
|
1228
1281
|
budget = get_settings().max_value_size - len(prefix)
|
|
1229
1282
|
serializer.serialize_to_sink(data, sink, max_bytes=budget)
|
|
@@ -1490,9 +1543,10 @@ class CacheHit(NamedTuple):
|
|
|
1490
1543
|
"""A successful L2 read from :class:`CacheOperationHandler`.
|
|
1491
1544
|
|
|
1492
1545
|
envelope is the serialized bytes so the decorator can backfill L1 without
|
|
1493
|
-
re-serializing (re-encrypting) — None on the mmap fast path
|
|
1494
|
-
|
|
1495
|
-
blocker C)
|
|
1546
|
+
re-serializing (re-encrypting) — None on the mmap fast path (sync reads
|
|
1547
|
+
only), whose handle is confined to its frame and whose bytes never reach
|
|
1548
|
+
L1 (#171 blocker C); the value itself may be an Arrow or polars result
|
|
1549
|
+
over the mapping (see ``_MmapHandle``). size_bytes is the envelope's byte length on every hit, mmap
|
|
1496
1550
|
included, so payload-size stats never depend on holding the bytes.
|
|
1497
1551
|
A hit is distinguished from "no cache entry" by getting a CacheHit at
|
|
1498
1552
|
all vs. None — there is no boolean slot (LAB-3757).
|
|
@@ -1734,10 +1788,12 @@ class CacheOperationHandler:
|
|
|
1734
1788
|
if self._cache_handler is None:
|
|
1735
1789
|
raise RuntimeError("Cache handler must be set before calling get_cached_value")
|
|
1736
1790
|
|
|
1737
|
-
# mmap fast path (#171): plaintext Arrow
|
|
1791
|
+
# mmap fast path (#171): plaintext cachekit Arrow on a buffer-readable backend (File,
|
|
1738
1792
|
# POSIX) reads zero-copy. The handle is confined to this frame and closed in `finally`,
|
|
1739
|
-
# so the mmap never becomes the returned value and never reaches L1 (blocker C).
|
|
1740
|
-
#
|
|
1793
|
+
# so the mmap never becomes the returned value and never reaches L1 (blocker C). An
|
|
1794
|
+
# Arrow or polars result that aliases the mapping keeps it alive past that close (see
|
|
1795
|
+
# _MmapHandle). A None from get_buffer (ineligible file, or a non-buffer backend)
|
|
1796
|
+
# falls through to bytes.
|
|
1741
1797
|
if self.serialization_handler.supports_mmap_read():
|
|
1742
1798
|
handle = self._cache_handler.get_buffer(cache_key)
|
|
1743
1799
|
if handle is not None:
|
|
@@ -132,7 +132,7 @@ class DecoratorConfig:
|
|
|
132
132
|
ttl: Time-to-live in seconds (None = no expiration; every preset sets its own default — see each classmethod)
|
|
133
133
|
namespace: Optional namespace prefix for cache keys
|
|
134
134
|
serializer: Serializer instance or name. Accepts either:
|
|
135
|
-
- String name: "default" (MessagePack), "arrow" (
|
|
135
|
+
- String name: "default" (MessagePack), "arrow" (DataFrames as Arrow IPC)
|
|
136
136
|
- SerializerProtocol instance: Custom serializer implementing the protocol
|
|
137
137
|
Default: "default" (MessagePack+LZ4+xxHash3-64 via Rust)
|
|
138
138
|
An EncryptionWrapper instance or the "encrypted" name raises ConfigurationError when
|
|
@@ -141,8 +141,17 @@ class DecoratorConfig:
|
|
|
141
141
|
All serializers use xxHash3-64 (8 bytes).
|
|
142
142
|
Set to False for @cache.minimal (speed-first, no integrity guarantee)
|
|
143
143
|
key: Custom key function for complex types. Receives (*args, **kwargs) and returns str.
|
|
144
|
-
Use for numpy arrays, DataFrames
|
|
145
|
-
|
|
144
|
+
Use for numpy arrays, DataFrames and other arguments the default key cannot hash.
|
|
145
|
+
The stored key is "{identity}-{namespace or 'default'}:{returned str}", where identity
|
|
146
|
+
is 16 hex of the blake3 hash of the function's module.qualname, so functions with
|
|
147
|
+
different module.qualnames never share an entry. Functions sharing one (closures from
|
|
148
|
+
one factory, lambdas, __main__ in two scripts) still do whenever "{namespace}:{returned str}"
|
|
149
|
+
is the same: give them distinct namespaces, none of them another plus ":".
|
|
150
|
+
Not for cross-SDK sharing: interop mode rejects key=.
|
|
151
|
+
Return a str that differs whenever the arguments differ and uses only [A-Za-z0-9_.:-]
|
|
152
|
+
(CachekitIO rejects other characters, Memcached whitespace), e.g. for a numeric numpy array:
|
|
153
|
+
@cache(key=lambda arr: hashlib.blake2b(f"{arr.dtype.str}{arr.shape}".encode() + arr.tobytes()).hexdigest())
|
|
154
|
+
(tobytes() alone ignores dtype and shape; an object array's bytes are pointers)
|
|
146
155
|
refresh_ttl_on_get: Extend TTL on cache hit
|
|
147
156
|
ttl_refresh_threshold: Minimum remaining TTL fraction (0.0-1.0) to trigger refresh
|
|
148
157
|
backend: L2 backend (RedisBackend, HTTPBackend). None means L1-only (in-process, no network), as
|
|
@@ -152,9 +152,8 @@ class CachekitConfig(RedactingSettings):
|
|
|
152
152
|
description=(
|
|
153
153
|
"Arrow IPC compression codec for DataFrame caching (ArrowSerializer, compression='auto'). "
|
|
154
154
|
"'zstd'/'lz4' shrink the stored payload but must be decompressed into the heap on read. "
|
|
155
|
-
"'none' stores uncompressed Arrow IPC,
|
|
156
|
-
"
|
|
157
|
-
"higher) at the cost of a larger payload. Writes are codec-independent: plaintext "
|
|
155
|
+
"'none' stores uncompressed Arrow IPC, at the cost of a larger payload, and lets plaintext "
|
|
156
|
+
"File-backend hits stay zero-copy, depending on return_format and Python version (see ArrowSerializer). Writes are codec-independent: plaintext "
|
|
158
157
|
"Arrow streams to the File backend without materializing the payload (encrypted "
|
|
159
158
|
"values stay buffered). Env: CACHEKIT_ARROW_COMPRESSION."
|
|
160
159
|
),
|
|
@@ -288,6 +288,21 @@ class CacheInfo(NamedTuple):
|
|
|
288
288
|
>>> total = info.hits + info.misses
|
|
289
289
|
>>> round(info.hits / total, 2)
|
|
290
290
|
0.83
|
|
291
|
+
|
|
292
|
+
Read a decorated function's statistics with ``cache_info()``. With nothing configured, the
|
|
293
|
+
second call is an L1 hit:
|
|
294
|
+
|
|
295
|
+
>>> from cachekit import cache
|
|
296
|
+
>>> @cache()
|
|
297
|
+
... def square(x):
|
|
298
|
+
... return x * x
|
|
299
|
+
>>> square(4), square(4)
|
|
300
|
+
(16, 16)
|
|
301
|
+
>>> info = square.cache_info()
|
|
302
|
+
>>> info.hits, info.misses, info.l1_hits, info.l2_hits
|
|
303
|
+
(1, 1, 1, 0)
|
|
304
|
+
>>> info.maxsize is None and info.currsize is None
|
|
305
|
+
True
|
|
291
306
|
"""
|
|
292
307
|
|
|
293
308
|
hits: int # Total cache hits (L1 + L2)
|
|
@@ -634,7 +649,7 @@ def create_cache_wrapper(
|
|
|
634
649
|
ttl: Cache time-to-live in seconds (None = no expiration)
|
|
635
650
|
namespace: Cache key namespace prefix
|
|
636
651
|
serializer: Serializer instance or name. Accepts either:
|
|
637
|
-
- String name: "default" (MessagePack), "arrow" (
|
|
652
|
+
- String name: "default" (MessagePack), "arrow" (DataFrames as Arrow IPC)
|
|
638
653
|
- SerializerProtocol instance: Custom serializer implementing the protocol
|
|
639
654
|
An EncryptionWrapper instance or the "encrypted" name raises ConfigurationError:
|
|
640
655
|
encrypt with encryption=True (@cache.secure) and pass the inner serializer here.
|
|
@@ -816,6 +831,8 @@ def create_cache_wrapper(
|
|
|
816
831
|
_interop_sig = inspect.signature(func)
|
|
817
832
|
|
|
818
833
|
# Key registry id: names this function's server-side tracking set on a KeyTrackableBackend.
|
|
834
|
+
# _registry_hash is also the identity every custom key= key starts with (_resolve_cache_key):
|
|
835
|
+
# changing it re-keys every key= entry, and the golden-vector test pins it.
|
|
819
836
|
# 64-bit hash, not func_hash's 32: a registry collision makes one function's invalidation
|
|
820
837
|
# drain another's keys. namespace=None and namespace="default" write different auto-mode
|
|
821
838
|
# keys, so they get different sets (None -> empty segment). The "ck" namespace is reserved:
|
|
@@ -1501,7 +1518,13 @@ def create_cache_wrapper(
|
|
|
1501
1518
|
custom_key = custom_key_func(*call_args, **call_kwargs)
|
|
1502
1519
|
if not isinstance(custom_key, str):
|
|
1503
1520
|
raise TypeError(f"key function must return str, got {type(custom_key).__name__}")
|
|
1504
|
-
|
|
1521
|
+
# The function's 16-hex identity goes first, at a fixed width no namespace or key can shift, so
|
|
1522
|
+
# two functions whose key functions return the same string never share an entry. First also keeps
|
|
1523
|
+
# every key apart from the pre-fix "{namespace}:{key}" keys an earlier release writes, which start
|
|
1524
|
+
# with a configured namespace: a crafted argument cannot plant one (unless a plain RedisBackend
|
|
1525
|
+
# sharing the database uses namespace t or t:*, which can spell a tenant-scoped key). Generated
|
|
1526
|
+
# keys start ns: or func:, so this key never equals one. CachekitIO files it in its default namespace.
|
|
1527
|
+
return f"{_registry_hash}-{namespace or 'default'}:{custom_key}"
|
|
1505
1528
|
# fast_mode: the only mode _generated_key_mode leaves once interop and key= are out.
|
|
1506
1529
|
# Minimal key generation - no string formatting overhead (10-50μs savings)
|
|
1507
1530
|
from ..hash_utils import cache_key_hash
|
|
@@ -3135,16 +3158,7 @@ def create_cache_wrapper(
|
|
|
3135
3158
|
are unaffected.
|
|
3136
3159
|
|
|
3137
3160
|
Returns:
|
|
3138
|
-
CacheInfo: Named tuple
|
|
3139
|
-
|
|
3140
|
-
Examples:
|
|
3141
|
-
>>> @cache()
|
|
3142
|
-
... def factorial(n):
|
|
3143
|
-
... return n * factorial(n-1) if n else 1
|
|
3144
|
-
>>> factorial(5)
|
|
3145
|
-
120
|
|
3146
|
-
>>> factorial.cache_info()
|
|
3147
|
-
CacheInfo(hits=4, misses=6, maxsize=None, currsize=6)
|
|
3161
|
+
CacheInfo: Named tuple of nine fields; see ``CacheInfo`` for them and an example.
|
|
3148
3162
|
"""
|
|
3149
3163
|
return _stats.get_info()
|
|
3150
3164
|
|
|
@@ -35,7 +35,7 @@ class MetricsCollector:
|
|
|
35
35
|
Examples:
|
|
36
36
|
Create and use a counter:
|
|
37
37
|
|
|
38
|
-
>>> counter = MetricsCollector("
|
|
38
|
+
>>> counter = MetricsCollector("doctest_counter")
|
|
39
39
|
>>> counter.inc()
|
|
40
40
|
>>> counter.get()
|
|
41
41
|
1.0
|
|
@@ -45,21 +45,21 @@ class MetricsCollector:
|
|
|
45
45
|
|
|
46
46
|
Use with labels:
|
|
47
47
|
|
|
48
|
-
>>> counter = MetricsCollector("
|
|
48
|
+
>>> counter = MetricsCollector("doctest_labeled_counter")
|
|
49
49
|
>>> counter.inc({"operation": "get", "status": "success"})
|
|
50
50
|
>>> counter.get({"operation": "get", "status": "success"})
|
|
51
51
|
1.0
|
|
52
52
|
|
|
53
53
|
Set gauge value:
|
|
54
54
|
|
|
55
|
-
>>> gauge = MetricsCollector("
|
|
55
|
+
>>> gauge = MetricsCollector("doctest_gauge")
|
|
56
56
|
>>> gauge.set(42.0)
|
|
57
57
|
>>> gauge.get()
|
|
58
58
|
42.0
|
|
59
59
|
|
|
60
60
|
Chain with labels() method:
|
|
61
61
|
|
|
62
|
-
>>> counter = MetricsCollector("
|
|
62
|
+
>>> counter = MetricsCollector("doctest_chained")
|
|
63
63
|
>>> counter.labels(operation="set").inc()
|
|
64
64
|
>>> counter.get({"operation": "set"})
|
|
65
65
|
1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pyright: reportOptionalMemberAccess=false
|
|
2
2
|
# pyright: reportInvalidTypeForm=false
|
|
3
|
-
"""Apache Arrow IPC serializer for
|
|
3
|
+
"""Apache Arrow IPC serializer for DataFrame caching.
|
|
4
4
|
|
|
5
5
|
This module provides ArrowSerializer for high-performance DataFrame serialization
|
|
6
6
|
using Apache Arrow's Inter-Process Communication (IPC) format.
|
|
@@ -133,6 +133,22 @@ class _HashingSink:
|
|
|
133
133
|
return self._hasher.digest()
|
|
134
134
|
|
|
135
135
|
|
|
136
|
+
def _views_of(table: pa.Table, source: pa.Buffer) -> bool:
|
|
137
|
+
"""True iff every data buffer of ``table`` points into ``source``: an uncompressed IPC read.
|
|
138
|
+
|
|
139
|
+
A compressed read decompresses into new buffers. Checking every buffer, not the first, matters: a
|
|
140
|
+
codec may store a buffer that does not shrink raw, and the reader then slices that one from ``source``.
|
|
141
|
+
"""
|
|
142
|
+
lo, hi = source.address, source.address + source.size
|
|
143
|
+
return all(
|
|
144
|
+
lo <= buf.address < hi
|
|
145
|
+
for column in table.columns
|
|
146
|
+
for chunk in column.chunks
|
|
147
|
+
for buf in chunk.buffers()
|
|
148
|
+
if buf is not None and buf.size
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
136
152
|
class ArrowSerializer:
|
|
137
153
|
"""Apache Arrow IPC serializer for memory-efficient DataFrame caching with xxHash3-64 integrity.
|
|
138
154
|
|
|
@@ -155,6 +171,13 @@ class ArrowSerializer:
|
|
|
155
171
|
- Deserialize: the envelope is sliced with a memoryview (no full-body copy), wrapped via
|
|
156
172
|
pa.py_buffer (zero-copy), and Arrow->pandas conversion uses self_destruct + split_blocks
|
|
157
173
|
to free Arrow buffers during conversion. zstd is decompressed transparently by the reader.
|
|
174
|
+
- Plaintext File-backend hits (POSIX) read over a memory map of the cache file ("arrow" and
|
|
175
|
+
"polars" on Python 3.13+ only, where a held mapping costs no file descriptor). A compressed
|
|
176
|
+
payload is decompressed out of the map; an uncompressed one stays zero-copy per return_format:
|
|
177
|
+
"arrow" returns a table whose buffers all alias the mapping (no heap copy); "polars" keeps
|
|
178
|
+
numeric, bool and temporal columns zero-copy and copies strings into polars' layout; "pandas"
|
|
179
|
+
copies once into a writable DataFrame. A zero-copy result keeps the mapping alive until it
|
|
180
|
+
is garbage-collected.
|
|
158
181
|
|
|
159
182
|
Use cases:
|
|
160
183
|
- Data science pipelines (pandas/polars DataFrames)
|
|
@@ -200,9 +223,11 @@ class ArrowSerializer:
|
|
|
200
223
|
|
|
201
224
|
Args:
|
|
202
225
|
return_format: Output format for deserialized data ("pandas", "polars", "arrow")
|
|
203
|
-
- "pandas": Convert to pandas.DataFrame (default)
|
|
226
|
+
- "pandas": Convert to pandas.DataFrame (default). Every column accepts in-place
|
|
227
|
+
edits, as on an uncached result: none is a read-only view of the cached bytes.
|
|
204
228
|
- "polars": Convert to polars.DataFrame
|
|
205
|
-
- "arrow": Return pyarrow.Table (zero-copy
|
|
229
|
+
- "arrow": Return pyarrow.Table (no conversion; zero-copy on an uncompressed
|
|
230
|
+
File-backend hit on Python 3.13+)
|
|
206
231
|
enable_integrity_checking: Retained for API compatibility. The 8-byte xxHash3-64
|
|
207
232
|
checksum is now ALWAYS written and validated (silently returning corrupted
|
|
208
233
|
DataFrames is unacceptable, and 8 bytes is negligible), so this flag no longer
|
|
@@ -210,10 +235,10 @@ class ArrowSerializer:
|
|
|
210
235
|
compression: Arrow IPC compression codec.
|
|
211
236
|
- "auto" (default): use the CACHEKIT_ARROW_COMPRESSION setting (itself "zstd" by default)
|
|
212
237
|
- "zstd" / "lz4": compress the payload (smaller wire/L1; must be decompressed on read)
|
|
213
|
-
- None or "none": store uncompressed Arrow IPC
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
238
|
+
- None or "none": store uncompressed Arrow IPC, at the cost of a larger stored payload.
|
|
239
|
+
Lets plaintext File-backend hits stay zero-copy (see the memory profile above);
|
|
240
|
+
the checksum check still reads every page once. On wire backends it only skips
|
|
241
|
+
the codec.
|
|
217
242
|
|
|
218
243
|
Raises:
|
|
219
244
|
ValueError: If return_format or compression is not a valid option
|
|
@@ -324,8 +349,7 @@ class ArrowSerializer:
|
|
|
324
349
|
# batch makes the codec allocate a full-size working buffer — measured ~3.6x the
|
|
325
350
|
# payload). Batches are sized by actual bytes (<= 8 MiB), not a uniform row-count
|
|
326
351
|
# estimate, so the bound holds for skewed/wide frames too (#161). compression=None
|
|
327
|
-
# writes uncompressed IPC
|
|
328
|
-
# plaintext, pandas return only).
|
|
352
|
+
# writes uncompressed IPC.
|
|
329
353
|
sink = pa.BufferOutputStream()
|
|
330
354
|
write_options = pa.ipc.IpcWriteOptions(compression=self.compression) if self.compression else None
|
|
331
355
|
with pa.ipc.new_file(sink, table.schema, options=write_options) as writer:
|
|
@@ -452,11 +476,20 @@ class ArrowSerializer:
|
|
|
452
476
|
"""
|
|
453
477
|
try:
|
|
454
478
|
# pa.py_buffer over the memoryview is zero-copy; open_file decompresses transparently.
|
|
455
|
-
|
|
479
|
+
source = pa.py_buffer(body)
|
|
480
|
+
reader = pa.ipc.open_file(source)
|
|
456
481
|
table = reader.read_all()
|
|
457
482
|
|
|
458
483
|
# Convert to requested format
|
|
459
484
|
if self.return_format == "pandas":
|
|
485
|
+
if reader.num_record_batches == 1:
|
|
486
|
+
# pyarrow converts a single-chunk column (and categorical codes) zero-copy, as a
|
|
487
|
+
# READ-ONLY numpy view of the IPC buffers, which alias the backend's bytes or the
|
|
488
|
+
# File backend's mmap. A second, empty chunk makes it concatenate into fresh
|
|
489
|
+
# arrays instead, as it already does for a multi-batch frame, so the caller gets
|
|
490
|
+
# a frame it can edit in place and that owns its memory, like an uncached call's
|
|
491
|
+
# (cachekit-py#157). One batch is at most _TARGET_BATCH_BYTES, so the copy is small.
|
|
492
|
+
table = pa.concat_tables([table.slice(0, 0), table])
|
|
460
493
|
# self_destruct frees each Arrow column as it is converted (the table is a
|
|
461
494
|
# throwaway local here, so the experimental-invalidation caveat does not apply);
|
|
462
495
|
# split_blocks avoids the transient 2x of consolidated-block construction.
|
|
@@ -466,11 +499,16 @@ class ArrowSerializer:
|
|
|
466
499
|
try:
|
|
467
500
|
import polars as pl # type: ignore[import-not-found]
|
|
468
501
|
|
|
469
|
-
|
|
502
|
+
# Uncompressed IPC: the table's buffers are views of ``body`` (the mapped cache
|
|
503
|
+
# file on a File-backend hit). rechunk=False keeps them, one chunk per record
|
|
504
|
+
# batch, so aligned numeric, bool and temporal columns import zero-copy; the
|
|
505
|
+
# default concatenates, which copies every column. A decompressed table is
|
|
506
|
+
# already a heap copy, so it keeps the contiguous default.
|
|
507
|
+
return pl.from_arrow(table, rechunk=not _views_of(table, source))
|
|
470
508
|
except ImportError as import_err:
|
|
471
509
|
raise SerializationError("polars not installed. Install with: pip install polars") from import_err
|
|
472
510
|
else: # return_format == "arrow"
|
|
473
|
-
return table #
|
|
511
|
+
return table # no conversion; aliases ``body`` when the IPC is uncompressed
|
|
474
512
|
|
|
475
513
|
except (pa.ArrowInvalid, pa.ArrowSerializationError, OSError) as e:
|
|
476
514
|
raise SerializationError(f"Failed to deserialize Arrow IPC data: {e}") from e
|
|
@@ -7,7 +7,8 @@ Backend-agnostic: works with Redis, CachekitIO, Memcached, File, L1.
|
|
|
7
7
|
Wire format (v3 binary frame)
|
|
8
8
|
-----------------------------
|
|
9
9
|
MAGIC b"CK" | VERSION u8 | HDR_LEN u32-BE | HEADER(json utf-8) | PAYLOAD(raw bytes)
|
|
10
|
-
HEADER = {"s": serializer_name, "m": metadata, "v": envelope_version}
|
|
10
|
+
HEADER = {"s": serializer_name, "m": metadata, "v": envelope_version}, optionally followed by
|
|
11
|
+
ASCII spaces (counted in HDR_LEN) that align the payload; see ``wrap_prefix``
|
|
11
12
|
|
|
12
13
|
The payload (serializer output: MessagePack/Arrow IPC/ciphertext) is stored
|
|
13
14
|
**raw** — no base64, no JSON-embedding. This matters because the previous
|
|
@@ -169,12 +170,28 @@ class SerializationWrapper:
|
|
|
169
170
|
"""
|
|
170
171
|
|
|
171
172
|
@staticmethod
|
|
172
|
-
def wrap_prefix(
|
|
173
|
+
def wrap_prefix(
|
|
174
|
+
metadata: dict[str, Any], serializer_name: str, version: str = "2.0", *, align: int = 1, offset: int = 0
|
|
175
|
+
) -> bytes:
|
|
173
176
|
"""Build the v3 frame prefix (everything before the payload): MAGIC | VERSION | HDR_LEN | HEADER.
|
|
174
177
|
|
|
175
178
|
The prefix depends only on the metadata/serializer name, never on the payload bytes, so
|
|
176
179
|
the streaming write path (LAB-766) can emit it before a single payload byte exists and
|
|
177
180
|
the stored frame stays byte-identical to a buffered ``wrap`` of the same payload.
|
|
181
|
+
|
|
182
|
+
``align`` pads HEADER with trailing ASCII spaces, counted in HDR_LEN, until
|
|
183
|
+
``offset + len(prefix)`` is a multiple of ``align``; ``offset`` counts the bytes outside the
|
|
184
|
+
prefix that precede the position to align. JSON allows trailing whitespace, so every reader
|
|
185
|
+
parses a padded header unchanged. The default (``align=1``) never pads. Raises ``ValueError``
|
|
186
|
+
for an ``align`` below 1, or for a negative ``offset`` when ``align`` is above 1; with
|
|
187
|
+
``align=1`` nothing is padded, so ``offset`` is ignored.
|
|
188
|
+
|
|
189
|
+
>>> padded = SerializationWrapper.wrap_prefix({}, "arrow", align=64, offset=22)
|
|
190
|
+
>>> (22 + len(padded)) % 64
|
|
191
|
+
0
|
|
192
|
+
>>> payload, _, name = SerializationWrapper.unwrap(padded + b"payload")
|
|
193
|
+
>>> bytes(payload), name
|
|
194
|
+
(b'payload', 'arrow')
|
|
178
195
|
"""
|
|
179
196
|
items = tuple(metadata.items())
|
|
180
197
|
if (
|
|
@@ -182,8 +199,18 @@ class SerializationWrapper:
|
|
|
182
199
|
and type(version) is str
|
|
183
200
|
and all(type(k) is str and type(v) in _MEMO_VALUE_TYPES for k, v in items)
|
|
184
201
|
):
|
|
185
|
-
|
|
186
|
-
|
|
202
|
+
prefix = _encode_prefix_memo(serializer_name, items, version)
|
|
203
|
+
else:
|
|
204
|
+
prefix = _encode_prefix(serializer_name, metadata, version)
|
|
205
|
+
if align == 1:
|
|
206
|
+
return prefix
|
|
207
|
+
if align < 1 or offset < 0:
|
|
208
|
+
raise ValueError(f"wrap_prefix needs align >= 1 and offset >= 0, got align={align}, offset={offset}")
|
|
209
|
+
pad = -(offset + len(prefix)) % align
|
|
210
|
+
if not pad:
|
|
211
|
+
return prefix
|
|
212
|
+
header = prefix[_PREFIX_LEN:] + b" " * pad
|
|
213
|
+
return b"".join((prefix[: _PREFIX_LEN - _HEADER_LEN_BYTES], len(header).to_bytes(_HEADER_LEN_BYTES, "big"), header))
|
|
187
214
|
|
|
188
215
|
@staticmethod
|
|
189
216
|
def wrap(data: bytes, metadata: dict[str, Any], serializer_name: str, version: str = "2.0") -> bytes:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|