cachekit 0.16.0__tar.gz → 0.17.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.16.0 → cachekit-0.17.0}/Cargo.lock +3 -3
- {cachekit-0.16.0 → cachekit-0.17.0}/PKG-INFO +1 -1
- {cachekit-0.16.0 → cachekit-0.17.0}/pyproject.toml +1 -1
- {cachekit-0.16.0 → cachekit-0.17.0}/rust/Cargo.toml +2 -2
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/__init__.py +1 -1
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/backends/base.py +51 -3
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/backends/file/backend.py +150 -43
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/cache_handler.py +169 -5
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/config/settings.py +3 -1
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/serializers/arrow_serializer.py +177 -49
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/serializers/wrapper.py +22 -13
- {cachekit-0.16.0 → cachekit-0.17.0}/Cargo.toml +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/LICENSE +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/README.md +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/rust/Makefile +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/rust/README.md +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/rust/TEST_EXPANSION_SUMMARY.md +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/rust/src/lib.rs +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/rust/src/python_bindings.rs +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/rust/supply-chain/audits.toml +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/rust/supply-chain/config.toml +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/rust/supply-chain/imports.lock +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/rust/tsan_suppressions.txt +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/backends/__init__.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/backends/base_config.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/backends/cachekitio/__init__.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/backends/cachekitio/backend.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/backends/cachekitio/client.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/backends/cachekitio/config.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/backends/cachekitio/error_handler.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/backends/cachekitio/session.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/backends/errors.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/backends/file/__init__.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/backends/file/config.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/backends/memcached/__init__.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/backends/memcached/backend.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/backends/memcached/config.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/backends/memcached/error_handler.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/backends/provider.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/backends/redis/__init__.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/backends/redis/backend.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/backends/redis/client.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/backends/redis/config.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/backends/redis/error_handler.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/backends/redis/provider.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/config/__init__.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/config/decorator.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/config/nested.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/config/singleton.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/config/validation.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/decorators/__init__.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/decorators/intent.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/decorators/local_wrapper.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/decorators/main.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/decorators/orchestrator.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/decorators/session.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/decorators/stats_context.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/decorators/tenant_context.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/decorators/utils/__init__.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/decorators/wrapper.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/di.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/hash_utils.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/health.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/hiredis_compat.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/imports.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/interop.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/key_generator.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/l1_cache.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/logging.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/monitoring/__init__.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/monitoring/correlation_tracking.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/monitoring/pool_monitor.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/monitoring/protocols.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/object_cache.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/py.typed +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/reliability/__init__.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/reliability/async_metrics.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/reliability/circuit_breaker.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/reliability/error_classification.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/reliability/load_control.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/reliability/metrics_collection.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/reliability/profiles.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/serializers/__init__.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/serializers/auto_serializer.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/serializers/base.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/serializers/encryption_wrapper.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/serializers/interop_serializer.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/serializers/orjson_serializer.py +0 -0
- {cachekit-0.16.0 → cachekit-0.17.0}/src/cachekit/serializers/standard_serializer.py +0 -0
|
@@ -245,9 +245,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
|
|
245
245
|
|
|
246
246
|
[[package]]
|
|
247
247
|
name = "cachekit-core"
|
|
248
|
-
version = "0.
|
|
248
|
+
version = "0.4.0"
|
|
249
249
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
250
|
-
checksum = "
|
|
250
|
+
checksum = "6aba1513135a7b92a124ad6983f7e80e5f5c78c4f9c74384079efa3fbf491eab"
|
|
251
251
|
dependencies = [
|
|
252
252
|
"aes",
|
|
253
253
|
"aes-gcm",
|
|
@@ -271,7 +271,7 @@ dependencies = [
|
|
|
271
271
|
|
|
272
272
|
[[package]]
|
|
273
273
|
name = "cachekit-rs"
|
|
274
|
-
version = "0.
|
|
274
|
+
version = "0.17.0"
|
|
275
275
|
dependencies = [
|
|
276
276
|
"cachekit-core",
|
|
277
277
|
"criterion",
|
|
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cachekit"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.17.0"
|
|
8
8
|
description = "Production-ready Redis caching for Python with intelligent reliability features and Rust-powered performance"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "cachekit-rs"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.17.0"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
authors = ["cachekit Contributors"]
|
|
6
6
|
description = "High-performance storage engine for caching with compression and encryption"
|
|
@@ -20,7 +20,7 @@ crate-type = ["cdylib", "rlib"]
|
|
|
20
20
|
|
|
21
21
|
[dependencies]
|
|
22
22
|
# Compression, checksums, encryption (https://crates.io/crates/cachekit-core)
|
|
23
|
-
cachekit-core = { version = "0.
|
|
23
|
+
cachekit-core = { version = "0.4.0", features = ["compression", "checksum", "messagepack", "encryption"] }
|
|
24
24
|
|
|
25
25
|
# Python integration - optional for Rust-only builds
|
|
26
26
|
pyo3 = { workspace = true, optional = true }
|
|
@@ -4,13 +4,14 @@ This module defines the storage backend contract using PEP 544 protocol-based ab
|
|
|
4
4
|
All L2 backends (Redis, HTTP, etc.) must implement BaseBackend protocol.
|
|
5
5
|
|
|
6
6
|
Optional capability protocols (TTLInspectableBackend, LockableBackend,
|
|
7
|
-
TimeoutConfigurableBackend
|
|
7
|
+
TimeoutConfigurableBackend, BufferReadableBackend, BufferWritableBackend)
|
|
8
|
+
enable advanced features with graceful degradation.
|
|
8
9
|
"""
|
|
9
10
|
|
|
10
11
|
from __future__ import annotations
|
|
11
12
|
|
|
12
|
-
from collections.abc import AsyncIterator
|
|
13
|
-
from typing import Any, Optional, Protocol, runtime_checkable
|
|
13
|
+
from collections.abc import AsyncIterator, Callable
|
|
14
|
+
from typing import Any, BinaryIO, Optional, Protocol, runtime_checkable
|
|
14
15
|
|
|
15
16
|
# Re-export BackendError for convenience (public API)
|
|
16
17
|
from cachekit.backends.errors import BackendError # noqa: F401
|
|
@@ -212,6 +213,53 @@ class BufferReadableBackend(Protocol):
|
|
|
212
213
|
...
|
|
213
214
|
|
|
214
215
|
|
|
216
|
+
@runtime_checkable
|
|
217
|
+
class BufferWritableBackend(Protocol):
|
|
218
|
+
"""Optional protocol for backends that can accept a value written incrementally through a
|
|
219
|
+
seekable sink, instead of one materialized ``bytes`` blob (LAB-766, write-side twin of
|
|
220
|
+
``BufferReadableBackend``).
|
|
221
|
+
|
|
222
|
+
Lets large values (e.g. multi-GB Arrow IPC) reach storage without the whole serialized
|
|
223
|
+
payload ever existing in memory. Only the File backend implements this today; backends that
|
|
224
|
+
don't are simply written via ``set`` as usual (the caller falls back transparently).
|
|
225
|
+
|
|
226
|
+
Why a writer callback and not an ``Iterable[bytes]``: the Arrow envelope stores an
|
|
227
|
+
xxHash3-64 checksum BEFORE the payload it covers, so the producer must patch bytes it has
|
|
228
|
+
already emitted once the stream is complete. A forward-only chunk iterator cannot express
|
|
229
|
+
that without serializing twice; a seekable sink can. This deliberately scopes the protocol
|
|
230
|
+
to random-access backends (File) — a forward-only transport (chunked HTTP upload) needs a
|
|
231
|
+
different contract and is out of scope until a backend demands it.
|
|
232
|
+
|
|
233
|
+
Contract:
|
|
234
|
+
- ``write_payload`` receives a writable, seekable binary sink positioned at the START of
|
|
235
|
+
the payload region. Producers MUST anchor seeks with ``tell()`` (never absolute offset 0):
|
|
236
|
+
backends may keep private framing before the payload.
|
|
237
|
+
- The value becomes visible under ``key`` only if ``write_payload`` returns successfully;
|
|
238
|
+
on any exception the backend discards the partial write, leaves any previous value for
|
|
239
|
+
the key intact, and re-raises the producer's exception unwrapped (so serialization errors
|
|
240
|
+
keep their type for the caller's error handling).
|
|
241
|
+
- ``ttl`` semantics are identical to ``set``.
|
|
242
|
+
- A successful ``set_streaming(key, w, ttl)`` is observably identical to
|
|
243
|
+
``set(key, full_bytes, ttl)`` for the same payload bytes — same readback via
|
|
244
|
+
``get``/``get_buffer``, same expiry, same size-limit enforcement (violations raise
|
|
245
|
+
``BackendError`` and nothing is stored).
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
def set_streaming(self, key: str, write_payload: Callable[[BinaryIO], None], ttl: Optional[int] = None) -> None:
|
|
249
|
+
"""Store the payload produced by ``write_payload`` under ``key``, never holding it whole.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
key: Cache key to store
|
|
253
|
+
write_payload: Callable that writes the complete payload to the provided sink
|
|
254
|
+
ttl: Time-to-live in seconds (None = no expiry), same semantics as ``set``
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
BackendError: If the backend operation fails (I/O, size limits, TTL bounds)
|
|
258
|
+
Exception: Whatever ``write_payload`` raises, unwrapped (partial write discarded)
|
|
259
|
+
"""
|
|
260
|
+
...
|
|
261
|
+
|
|
262
|
+
|
|
215
263
|
@runtime_checkable
|
|
216
264
|
class LockableBackend(Protocol):
|
|
217
265
|
"""Optional protocol for backends supporting distributed locking.
|
|
@@ -20,8 +20,9 @@ import platform
|
|
|
20
20
|
import struct
|
|
21
21
|
import threading
|
|
22
22
|
import time
|
|
23
|
+
from collections.abc import Callable
|
|
23
24
|
from pathlib import Path
|
|
24
|
-
from typing import Any
|
|
25
|
+
from typing import Any, BinaryIO
|
|
25
26
|
|
|
26
27
|
from cachekit.backends.errors import BackendError, BackendErrorType
|
|
27
28
|
|
|
@@ -330,29 +331,8 @@ class FileBackend:
|
|
|
330
331
|
|
|
331
332
|
file_path = self._key_to_path(key)
|
|
332
333
|
|
|
333
|
-
#
|
|
334
|
-
|
|
335
|
-
expiry_timestamp = 0
|
|
336
|
-
else:
|
|
337
|
-
# Validate TTL bounds (security: prevent integer overflow/underflow)
|
|
338
|
-
if ttl < 0 or ttl > MAX_TTL_SECONDS:
|
|
339
|
-
raise BackendError(
|
|
340
|
-
f"TTL {ttl} out of range [0, {MAX_TTL_SECONDS}] (max 10 years)",
|
|
341
|
-
BackendErrorType.PERMANENT,
|
|
342
|
-
)
|
|
343
|
-
expiry_timestamp = int(time.time() + ttl)
|
|
344
|
-
|
|
345
|
-
# Build header (14 bytes)
|
|
346
|
-
header = (
|
|
347
|
-
MAGIC # [0:2] Magic bytes
|
|
348
|
-
+ bytes([FORMAT_VERSION]) # [2:3] Version
|
|
349
|
-
+ bytes([RESERVED]) # [3:4] Reserved
|
|
350
|
-
+ struct.pack(">H", 0) # [4:6] Flags (no compression/encryption yet)
|
|
351
|
-
+ struct.pack(">Q", expiry_timestamp) # [6:14] Expiry timestamp
|
|
352
|
-
)
|
|
353
|
-
|
|
354
|
-
# Combine header + payload
|
|
355
|
-
file_data = header + value
|
|
334
|
+
# Combine header (14 bytes) + payload
|
|
335
|
+
file_data = self._build_header(self._expiry_from_ttl(ttl)) + value
|
|
356
336
|
|
|
357
337
|
# Generate temp file name
|
|
358
338
|
temp_path = self._generate_temp_path(file_path)
|
|
@@ -360,15 +340,7 @@ class FileBackend:
|
|
|
360
340
|
with self._lock:
|
|
361
341
|
try:
|
|
362
342
|
# Check entry count BEFORE write (security: prevent file persisting on error)
|
|
363
|
-
|
|
364
|
-
if self.config.max_entry_count > 0:
|
|
365
|
-
_, entry_count = self._calculate_cache_size()
|
|
366
|
-
# Only check if this is a NEW entry (not overwriting existing)
|
|
367
|
-
if not os.path.exists(file_path) and entry_count >= self.config.max_entry_count:
|
|
368
|
-
raise BackendError(
|
|
369
|
-
f"Entry count {entry_count} would exceed max_entry_count ({self.config.max_entry_count})",
|
|
370
|
-
BackendErrorType.PERMANENT,
|
|
371
|
-
)
|
|
343
|
+
self._check_entry_capacity(file_path)
|
|
372
344
|
|
|
373
345
|
# Write to temp file with O_NOFOLLOW for security
|
|
374
346
|
fd = os.open(
|
|
@@ -410,6 +382,105 @@ class FileBackend:
|
|
|
410
382
|
key=key,
|
|
411
383
|
) from exc
|
|
412
384
|
|
|
385
|
+
def set_streaming(self, key: str, write_payload: Callable[[BinaryIO], None], ttl: int | None = None) -> None:
|
|
386
|
+
"""Store a streamed value with the same atomicity, locking, and limits as ``set`` (LAB-766).
|
|
387
|
+
|
|
388
|
+
``write_payload`` receives a writable, seekable buffered binary file positioned at the
|
|
389
|
+
start of the payload region (just past this backend's 14-byte header) and must write the
|
|
390
|
+
complete payload — record batch by record batch, so the full value never exists in
|
|
391
|
+
memory. The temp-file + atomic-rename pattern from ``set`` is preserved: a failed stream
|
|
392
|
+
never leaves a partial value visible under ``key``, and any previous value for the key
|
|
393
|
+
survives. Producer exceptions propagate unwrapped (after the temp file is discarded) so
|
|
394
|
+
serialization errors keep their type for the caller.
|
|
395
|
+
|
|
396
|
+
``max_value_mb`` is enforced AFTER the stream completes (``fstat`` on the temp file,
|
|
397
|
+
before the rename): a streaming write cannot know its size upfront. The caller-side byte
|
|
398
|
+
budget (CACHEKIT_MAX_VALUE_SIZE, enforced incrementally by the serializer) bounds the
|
|
399
|
+
transient disk usage of an over-limit attempt.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
key: Cache key to store
|
|
403
|
+
write_payload: Callable that writes the complete payload to the provided file
|
|
404
|
+
ttl: Time-to-live in seconds (None or 0 = never expire), same semantics as ``set``
|
|
405
|
+
|
|
406
|
+
Raises:
|
|
407
|
+
BackendError: If the write fails (disk full, permissions, size/TTL limits, etc.)
|
|
408
|
+
"""
|
|
409
|
+
header = self._build_header(self._expiry_from_ttl(ttl))
|
|
410
|
+
file_path = self._key_to_path(key)
|
|
411
|
+
temp_path = self._generate_temp_path(file_path)
|
|
412
|
+
|
|
413
|
+
committed = False
|
|
414
|
+
try:
|
|
415
|
+
# Check entry count BEFORE write (security: prevent file persisting on error)
|
|
416
|
+
with self._lock:
|
|
417
|
+
self._check_entry_capacity(file_path)
|
|
418
|
+
|
|
419
|
+
# Stream OUTSIDE self._lock: unlike set()'s bounded os.write, the producer runs
|
|
420
|
+
# the whole serialization here (minutes for multi-GB frames), and holding the
|
|
421
|
+
# process-wide lock would block every other FileBackend op for that window. The
|
|
422
|
+
# in-process lock isn't what makes this safe anyway — the temp path is unique
|
|
423
|
+
# per pid+ns, the flock gives cross-process exclusion, and the rename commit
|
|
424
|
+
# below is atomic. Write to temp file with O_NOFOLLOW for security.
|
|
425
|
+
fd = os.open(
|
|
426
|
+
temp_path,
|
|
427
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
428
|
+
self.config.permissions,
|
|
429
|
+
)
|
|
430
|
+
f: BinaryIO | None = None
|
|
431
|
+
try:
|
|
432
|
+
# Acquire exclusive write lock
|
|
433
|
+
self._acquire_file_lock(fd, exclusive=True)
|
|
434
|
+
try:
|
|
435
|
+
f = os.fdopen(fd, "wb") # buffered writer; takes ownership of fd
|
|
436
|
+
f.write(header)
|
|
437
|
+
write_payload(f)
|
|
438
|
+
f.flush()
|
|
439
|
+
|
|
440
|
+
# Enforce max_value_mb on the real on-disk size (fstat, not tell():
|
|
441
|
+
# the producer may have seeked back to patch bytes it already wrote).
|
|
442
|
+
payload_size = os.fstat(fd).st_size - HEADER_SIZE
|
|
443
|
+
max_bytes = self.config.max_value_mb * 1024 * 1024
|
|
444
|
+
if payload_size > max_bytes:
|
|
445
|
+
raise BackendError(
|
|
446
|
+
f"Value size {payload_size} exceeds max_value_mb ({self.config.max_value_mb}MB)",
|
|
447
|
+
BackendErrorType.PERMANENT,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
# fsync to ensure data is on disk
|
|
451
|
+
os.fsync(fd)
|
|
452
|
+
finally:
|
|
453
|
+
self._release_file_lock(fd)
|
|
454
|
+
finally:
|
|
455
|
+
if f is not None:
|
|
456
|
+
f.close()
|
|
457
|
+
else:
|
|
458
|
+
os.close(fd)
|
|
459
|
+
|
|
460
|
+
# Commit under the process lock: atomic rename + eviction bookkeeping only.
|
|
461
|
+
with self._lock:
|
|
462
|
+
os.rename(temp_path, file_path)
|
|
463
|
+
committed = True
|
|
464
|
+
|
|
465
|
+
# Trigger eviction if over threshold
|
|
466
|
+
self._maybe_evict()
|
|
467
|
+
|
|
468
|
+
except OSError as exc:
|
|
469
|
+
raise BackendError(
|
|
470
|
+
f"Failed to write cache file: {exc}",
|
|
471
|
+
error_type=self._classify_os_error(exc, is_directory=False),
|
|
472
|
+
original_exception=exc,
|
|
473
|
+
operation="set_streaming",
|
|
474
|
+
key=key,
|
|
475
|
+
) from exc
|
|
476
|
+
finally:
|
|
477
|
+
# Any failure — backend I/O, size limit, or a producer exception (which
|
|
478
|
+
# propagates unwrapped per the BufferWritableBackend contract, so callers keep
|
|
479
|
+
# its type and log it once) — discards the partial write. finally (not a
|
|
480
|
+
# BaseException catch) so even KeyboardInterrupt can't leak a temp file.
|
|
481
|
+
if not committed:
|
|
482
|
+
self._safe_unlink(temp_path)
|
|
483
|
+
|
|
413
484
|
def delete(self, key: str) -> bool:
|
|
414
485
|
"""Delete key from file storage.
|
|
415
486
|
|
|
@@ -640,16 +711,8 @@ class FileBackend:
|
|
|
640
711
|
only bytes [6:14] are rewritten, so the payload and all other header fields are
|
|
641
712
|
untouched (and cross-SDK File readers stay compatible).
|
|
642
713
|
"""
|
|
643
|
-
# Same TTL bounds as set() (security: prevent integer overflow/underflow).
|
|
644
|
-
|
|
645
|
-
new_expiry = 0
|
|
646
|
-
elif ttl < 0 or ttl > MAX_TTL_SECONDS:
|
|
647
|
-
raise BackendError(
|
|
648
|
-
f"TTL {ttl} out of range [0, {MAX_TTL_SECONDS}] (max 10 years)",
|
|
649
|
-
BackendErrorType.PERMANENT,
|
|
650
|
-
)
|
|
651
|
-
else:
|
|
652
|
-
new_expiry = int(time.time() + ttl)
|
|
714
|
+
# Same TTL bounds as set()/set_streaming (security: prevent integer overflow/underflow).
|
|
715
|
+
new_expiry = self._expiry_from_ttl(ttl)
|
|
653
716
|
|
|
654
717
|
file_path = self._key_to_path(key)
|
|
655
718
|
|
|
@@ -704,6 +767,50 @@ class FileBackend:
|
|
|
704
767
|
|
|
705
768
|
# Private helper methods
|
|
706
769
|
|
|
770
|
+
@staticmethod
|
|
771
|
+
def _expiry_from_ttl(ttl: int | None) -> int:
|
|
772
|
+
"""Expiry timestamp for a TTL (0 = never expire), with TTL bounds validation.
|
|
773
|
+
|
|
774
|
+
Raises:
|
|
775
|
+
BackendError: If TTL is negative or exceeds MAX_TTL_SECONDS (security:
|
|
776
|
+
prevent integer overflow/underflow in the packed uint64 timestamp)
|
|
777
|
+
"""
|
|
778
|
+
if ttl is None or ttl == 0:
|
|
779
|
+
return 0
|
|
780
|
+
if ttl < 0 or ttl > MAX_TTL_SECONDS:
|
|
781
|
+
raise BackendError(
|
|
782
|
+
f"TTL {ttl} out of range [0, {MAX_TTL_SECONDS}] (max 10 years)",
|
|
783
|
+
BackendErrorType.PERMANENT,
|
|
784
|
+
)
|
|
785
|
+
return int(time.time() + ttl)
|
|
786
|
+
|
|
787
|
+
@staticmethod
|
|
788
|
+
def _build_header(expiry_timestamp: int) -> bytes:
|
|
789
|
+
"""Build the 14-byte on-disk entry header."""
|
|
790
|
+
return (
|
|
791
|
+
MAGIC # [0:2] Magic bytes
|
|
792
|
+
+ bytes([FORMAT_VERSION]) # [2:3] Version
|
|
793
|
+
+ bytes([RESERVED]) # [3:4] Reserved
|
|
794
|
+
+ struct.pack(">H", 0) # [4:6] Flags (no compression/encryption yet)
|
|
795
|
+
+ struct.pack(">Q", expiry_timestamp) # [6:14] Expiry timestamp
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
def _check_entry_capacity(self, file_path: str) -> None:
|
|
799
|
+
"""Reject a NEW entry when max_entry_count is reached (overwrites always pass).
|
|
800
|
+
|
|
801
|
+
Raises:
|
|
802
|
+
BackendError: If storing a new entry would exceed max_entry_count
|
|
803
|
+
"""
|
|
804
|
+
if self.config.max_entry_count <= 0:
|
|
805
|
+
return
|
|
806
|
+
_, entry_count = self._calculate_cache_size()
|
|
807
|
+
# Only check if this is a NEW entry (not overwriting existing)
|
|
808
|
+
if not os.path.exists(file_path) and entry_count >= self.config.max_entry_count:
|
|
809
|
+
raise BackendError(
|
|
810
|
+
f"Entry count {entry_count} would exceed max_entry_count ({self.config.max_entry_count})",
|
|
811
|
+
BackendErrorType.PERMANENT,
|
|
812
|
+
)
|
|
813
|
+
|
|
707
814
|
def _key_to_path(self, key: str) -> str:
|
|
708
815
|
"""Convert cache key to file path using blake2b hash.
|
|
709
816
|
|
|
@@ -11,9 +11,16 @@ import hashlib
|
|
|
11
11
|
import threading
|
|
12
12
|
import warnings
|
|
13
13
|
from collections.abc import Callable
|
|
14
|
-
from typing import TYPE_CHECKING, Any, Optional, Protocol, TypeGuard, Union, runtime_checkable
|
|
15
|
-
|
|
16
|
-
from cachekit.backends.base import
|
|
14
|
+
from typing import TYPE_CHECKING, Any, BinaryIO, Optional, Protocol, TypeGuard, Union, runtime_checkable
|
|
15
|
+
|
|
16
|
+
from cachekit.backends.base import (
|
|
17
|
+
BackendError,
|
|
18
|
+
BaseBackend,
|
|
19
|
+
BufferHandle,
|
|
20
|
+
BufferReadableBackend,
|
|
21
|
+
BufferWritableBackend,
|
|
22
|
+
TTLInspectableBackend,
|
|
23
|
+
)
|
|
17
24
|
from cachekit.backends.provider import (
|
|
18
25
|
BackendProviderInterface,
|
|
19
26
|
DefaultBackendProvider,
|
|
@@ -221,6 +228,15 @@ def supports_buffer_read(backend: BaseBackend) -> TypeGuard[BufferReadableBacken
|
|
|
221
228
|
return hasattr(backend, "get_buffer")
|
|
222
229
|
|
|
223
230
|
|
|
231
|
+
def supports_streaming_write(backend: BaseBackend) -> TypeGuard[BufferWritableBackend]:
|
|
232
|
+
"""Type guard: backend can accept an incrementally-streamed value via set_streaming (LAB-766, File only).
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
True if backend implements BufferWritableBackend (used for the streaming Arrow write path).
|
|
236
|
+
"""
|
|
237
|
+
return hasattr(backend, "set_streaming")
|
|
238
|
+
|
|
239
|
+
|
|
224
240
|
class SWRCapableBackend(Protocol):
|
|
225
241
|
"""Backend with server-signaled stale-while-revalidate reads (LAB-381).
|
|
226
242
|
|
|
@@ -932,6 +948,54 @@ class CacheSerializationHandler:
|
|
|
932
948
|
and getattr(self._base_serializer, "return_format", None) == "pandas"
|
|
933
949
|
)
|
|
934
950
|
|
|
951
|
+
def supports_streaming_write(self) -> bool:
|
|
952
|
+
"""True iff writes can stream the envelope to a capable backend (LAB-766).
|
|
953
|
+
|
|
954
|
+
Eligible only for PLAINTEXT Arrow:
|
|
955
|
+
- encrypted values can never stream (AES-256-GCM emits its auth tag only after the
|
|
956
|
+
whole ciphertext exists, so the secure path stays on buffered ``set(bytes)``);
|
|
957
|
+
- interop/v1 stores raw documents with no CK envelope (different serializer, no benefit);
|
|
958
|
+
- non-Arrow serializers materialize their output in one shot anyway.
|
|
959
|
+
|
|
960
|
+
The backend must also support streaming writes (File only today); that is checked
|
|
961
|
+
separately at the cache-handler layer, so True here on a non-streaming backend simply
|
|
962
|
+
means the write falls back to the buffered path.
|
|
963
|
+
"""
|
|
964
|
+
return not self.encryption and not self.interop_mode and self._serializer_string_name == "arrow"
|
|
965
|
+
|
|
966
|
+
def write_serialized_to(self, sink: BinaryIO, data: Any) -> None:
|
|
967
|
+
"""Streaming twin of :meth:`serialize_data` (LAB-766): write the SAME envelope bytes into
|
|
968
|
+
``sink`` without ever materializing the payload in memory.
|
|
969
|
+
|
|
970
|
+
Only valid when :meth:`supports_streaming_write` is True (plaintext Arrow — no
|
|
971
|
+
encryption wrapper, so no tenant extraction / AAD / cache_key binding applies). The
|
|
972
|
+
envelope is byte-identical to a buffered ``serialize_data`` of the same value: frame
|
|
973
|
+
prefix first (metadata is deterministic per serializer config), then the Arrow
|
|
974
|
+
serializer streams ``[checksum][IPC record batches]`` directly into the sink.
|
|
975
|
+
|
|
976
|
+
The L2 oversized-entry ceiling (``max_value_size``, issue #163) is enforced
|
|
977
|
+
incrementally mid-stream instead of on the finished blob — same limit, same ValueError,
|
|
978
|
+
but an over-limit stream aborts early instead of materializing first.
|
|
979
|
+
|
|
980
|
+
Raises:
|
|
981
|
+
TypeError: If data is not a DataFrame or dict of arrays
|
|
982
|
+
ValueError: If the envelope would exceed max_value_size
|
|
983
|
+
SerializationError: If Arrow serialization fails
|
|
984
|
+
"""
|
|
985
|
+
from cachekit.serializers.arrow_serializer import ArrowSerializer # already loaded on the arrow path
|
|
986
|
+
|
|
987
|
+
serializer = self._base_serializer
|
|
988
|
+
if not isinstance(serializer, ArrowSerializer):
|
|
989
|
+
# supports_streaming_write() gates callers to the arrow serializer; anything else
|
|
990
|
+
# here is a caller bug — fail loud rather than stream a wrong envelope.
|
|
991
|
+
raise SerializationError(f"write_serialized_to requires the arrow serializer, got {type(serializer).__name__}")
|
|
992
|
+
|
|
993
|
+
metadata = serializer.serialization_metadata()
|
|
994
|
+
prefix = SerializationWrapper.wrap_prefix(metadata.to_dict(), self._serializer_string_name)
|
|
995
|
+
sink.write(prefix)
|
|
996
|
+
budget = get_settings().max_value_size - len(prefix)
|
|
997
|
+
serializer.serialize_to_sink(data, sink, max_bytes=budget)
|
|
998
|
+
|
|
935
999
|
def deserialize_data(self, data: str | bytes | memoryview, cache_key: str = "") -> Any:
|
|
936
1000
|
"""Deserialize data from cache storage with cache_key verification.
|
|
937
1001
|
|
|
@@ -1413,7 +1477,11 @@ class CacheOperationHandler:
|
|
|
1413
1477
|
kwargs: Function kwargs (for tenant extraction in encryption)
|
|
1414
1478
|
|
|
1415
1479
|
Returns:
|
|
1416
|
-
Serialized bytes
|
|
1480
|
+
Serialized bytes when the buffered path stored the value (eligible for L1
|
|
1481
|
+
backfill), or None when there is nothing for L1: the value was STREAMED to the
|
|
1482
|
+
backend (success — L1 intentionally skipped, LAB-766), the streaming attempt
|
|
1483
|
+
failed (logged, never retried buffered), or serialization failed. None is NOT
|
|
1484
|
+
a failure signal.
|
|
1417
1485
|
|
|
1418
1486
|
Note:
|
|
1419
1487
|
Requires cache_handler to be set via set_cache_handler() before calling.
|
|
@@ -1423,6 +1491,28 @@ class CacheOperationHandler:
|
|
|
1423
1491
|
if self._cache_handler is None:
|
|
1424
1492
|
raise RuntimeError("Cache handler must be set before calling store_result")
|
|
1425
1493
|
|
|
1494
|
+
# Streaming write fast path (LAB-766): plaintext Arrow to a streaming-writable
|
|
1495
|
+
# backend (File) writes the envelope without ever materializing it. SWR
|
|
1496
|
+
# (stale_ttl) stays buffered — no streaming backend signals freshness today.
|
|
1497
|
+
# getattr guards custom CacheHandlerStrategy impls that predate set_streaming.
|
|
1498
|
+
if stale_ttl is None and self.serialization_handler.supports_streaming_write():
|
|
1499
|
+
set_streaming = getattr(self._cache_handler, "set_streaming", None)
|
|
1500
|
+
if set_streaming is not None:
|
|
1501
|
+
streamed = set_streaming(
|
|
1502
|
+
cache_key,
|
|
1503
|
+
lambda sink: self.serialization_handler.write_serialized_to(sink, result),
|
|
1504
|
+
ttl,
|
|
1505
|
+
)
|
|
1506
|
+
if streamed is not None:
|
|
1507
|
+
# Streamed (True) or attempt failed and was logged (False — do NOT
|
|
1508
|
+
# retry buffered: that re-materializes the payload this path exists
|
|
1509
|
+
# to avoid). Either way return None: a multi-GB envelope must never
|
|
1510
|
+
# be copied into L1 (mirrors the mmap read path's L1 exclusion, #171).
|
|
1511
|
+
if streamed:
|
|
1512
|
+
get_logger().cache_stored(cache_key, ttl)
|
|
1513
|
+
return None
|
|
1514
|
+
# None: backend can't stream — fall through to the buffered path.
|
|
1515
|
+
|
|
1426
1516
|
# Pass cache_key for AAD binding (required for encrypted data)
|
|
1427
1517
|
serialized_data = self.serialization_handler.serialize_data(result, args, kwargs, cache_key)
|
|
1428
1518
|
# Only thread the SWR kwarg when set: strategy implementations without
|
|
@@ -1461,7 +1551,11 @@ class CacheOperationHandler:
|
|
|
1461
1551
|
kwargs: Function kwargs (for tenant extraction in encryption)
|
|
1462
1552
|
|
|
1463
1553
|
Returns:
|
|
1464
|
-
Serialized bytes
|
|
1554
|
+
Serialized bytes when the buffered path stored the value (eligible for L1
|
|
1555
|
+
backfill), or None when there is nothing for L1: the value was STREAMED to the
|
|
1556
|
+
backend (success — L1 intentionally skipped, LAB-766), the streaming attempt
|
|
1557
|
+
failed (logged, never retried buffered), or serialization failed. None is NOT
|
|
1558
|
+
a failure signal.
|
|
1465
1559
|
|
|
1466
1560
|
Note:
|
|
1467
1561
|
Requires cache_handler to be set via set_cache_handler() before calling.
|
|
@@ -1471,6 +1565,25 @@ class CacheOperationHandler:
|
|
|
1471
1565
|
if self._cache_handler is None:
|
|
1472
1566
|
raise RuntimeError("Cache handler must be set before calling store_result_async")
|
|
1473
1567
|
|
|
1568
|
+
# Streaming write fast path (LAB-766) — async twin of store_result's gate. The
|
|
1569
|
+
# whole stream (pyarrow serialization included) runs in the handler's thread
|
|
1570
|
+
# pool, which also moves the blocking serialize OFF the event loop (the buffered
|
|
1571
|
+
# path below serializes on the loop thread).
|
|
1572
|
+
if self.serialization_handler.supports_streaming_write():
|
|
1573
|
+
set_streaming_async = getattr(self._cache_handler, "set_streaming_async", None)
|
|
1574
|
+
if set_streaming_async is not None:
|
|
1575
|
+
streamed = await set_streaming_async(
|
|
1576
|
+
cache_key,
|
|
1577
|
+
lambda sink: self.serialization_handler.write_serialized_to(sink, result),
|
|
1578
|
+
ttl,
|
|
1579
|
+
)
|
|
1580
|
+
if streamed is not None:
|
|
1581
|
+
# See store_result: never retry buffered on failure, never hand the
|
|
1582
|
+
# envelope to L1 on success.
|
|
1583
|
+
if streamed:
|
|
1584
|
+
get_logger().cache_stored(cache_key, ttl)
|
|
1585
|
+
return None
|
|
1586
|
+
|
|
1474
1587
|
# Pass cache_key for AAD binding (required for encrypted data)
|
|
1475
1588
|
serialized_data = self.serialization_handler.serialize_data(result, args, kwargs, cache_key)
|
|
1476
1589
|
await self._cache_handler.set_async(cache_key, serialized_data, ttl)
|
|
@@ -1607,6 +1720,16 @@ class CacheHandlerStrategy(Protocol):
|
|
|
1607
1720
|
"""Set value in cache with TTL and optional metadata."""
|
|
1608
1721
|
...
|
|
1609
1722
|
|
|
1723
|
+
def set_streaming(self, key: str, write_payload: Callable[[BinaryIO], None], ttl: Optional[int] = None) -> Optional[bool]:
|
|
1724
|
+
"""Stream a value to a streaming-writable backend (LAB-766); None when unsupported."""
|
|
1725
|
+
...
|
|
1726
|
+
|
|
1727
|
+
async def set_streaming_async(
|
|
1728
|
+
self, key: str, write_payload: Callable[[BinaryIO], None], ttl: Optional[int] = None
|
|
1729
|
+
) -> Optional[bool]:
|
|
1730
|
+
"""Async variant of set_streaming."""
|
|
1731
|
+
...
|
|
1732
|
+
|
|
1610
1733
|
def delete(self, key: str) -> bool:
|
|
1611
1734
|
"""Delete key from cache."""
|
|
1612
1735
|
...
|
|
@@ -1860,6 +1983,47 @@ class StandardCacheHandler:
|
|
|
1860
1983
|
get_logger().error(f"Unexpected error setting key {key}: {e}")
|
|
1861
1984
|
return False
|
|
1862
1985
|
|
|
1986
|
+
def set_streaming(self, key: str, write_payload: Callable[[BinaryIO], None], ttl: Optional[int] = None) -> Optional[bool]:
|
|
1987
|
+
"""Stream a value to a streaming-writable backend (LAB-766).
|
|
1988
|
+
|
|
1989
|
+
Returns:
|
|
1990
|
+
None when the backend doesn't implement BufferWritableBackend — the caller falls
|
|
1991
|
+
back to the buffered ``set``. True on success. False when the streaming attempt
|
|
1992
|
+
failed (logged, mirroring ``set``'s degradation contract); the caller must NOT
|
|
1993
|
+
retry with the buffered path — that would re-materialize the very payload this
|
|
1994
|
+
path exists to keep out of memory.
|
|
1995
|
+
"""
|
|
1996
|
+
if not supports_streaming_write(self.backend):
|
|
1997
|
+
return None
|
|
1998
|
+
try:
|
|
1999
|
+
self._with_backpressure_and_timeout(self.backend.set_streaming, key, write_payload, ttl)
|
|
2000
|
+
return True
|
|
2001
|
+
except BackendError as e:
|
|
2002
|
+
get_logger().error(f"Backend error streaming key {redact_cache_key(key)}: {e}")
|
|
2003
|
+
return False
|
|
2004
|
+
except Exception as e:
|
|
2005
|
+
# Producer-side failure (serialization error, max_value_size budget): the backend
|
|
2006
|
+
# already discarded its partial write; surface the real cause, not a backend error.
|
|
2007
|
+
get_logger().error(f"Streaming serialization failed for key {redact_cache_key(key)}: {e}")
|
|
2008
|
+
return False
|
|
2009
|
+
|
|
2010
|
+
async def set_streaming_async(
|
|
2011
|
+
self, key: str, write_payload: Callable[[BinaryIO], None], ttl: Optional[int] = None
|
|
2012
|
+
) -> Optional[bool]:
|
|
2013
|
+
"""Async variant of :meth:`set_streaming`: the whole stream — pyarrow serialization
|
|
2014
|
+
included — runs in the thread pool, keeping blocking IPC writes off the event loop."""
|
|
2015
|
+
if not supports_streaming_write(self.backend):
|
|
2016
|
+
return None
|
|
2017
|
+
try:
|
|
2018
|
+
await self._with_backpressure_and_timeout_async(self.backend.set_streaming, key, write_payload, ttl)
|
|
2019
|
+
return True
|
|
2020
|
+
except BackendError as e:
|
|
2021
|
+
get_logger().error(f"Backend error streaming key {redact_cache_key(key)}: {e}")
|
|
2022
|
+
return False
|
|
2023
|
+
except Exception as e:
|
|
2024
|
+
get_logger().error(f"Streaming serialization failed for key {redact_cache_key(key)}: {e}")
|
|
2025
|
+
return False
|
|
2026
|
+
|
|
1863
2027
|
def delete(self, key: str) -> bool:
|
|
1864
2028
|
"""Delete key from cache using backend.
|
|
1865
2029
|
|
|
@@ -105,7 +105,9 @@ class CachekitConfig(BaseSettings):
|
|
|
105
105
|
"'zstd'/'lz4' shrink the stored payload but must be decompressed into the heap on read. "
|
|
106
106
|
"'none' stores uncompressed Arrow IPC, which lets the File backend serve plaintext "
|
|
107
107
|
"DataFrame reads via a zero-copy mmap (low steady-state read RSS; peak transiently "
|
|
108
|
-
"higher) at the cost of a larger payload.
|
|
108
|
+
"higher) at the cost of a larger payload. Writes are codec-independent: plaintext "
|
|
109
|
+
"Arrow streams to the File backend without materializing the payload (encrypted "
|
|
110
|
+
"values stay buffered). Env: CACHEKIT_ARROW_COMPRESSION."
|
|
109
111
|
),
|
|
110
112
|
)
|
|
111
113
|
retry_on_timeout: bool = Field(
|