cachekit 0.15.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.
Files changed (93) hide show
  1. {cachekit-0.15.0 → cachekit-0.17.0}/Cargo.lock +3 -3
  2. {cachekit-0.15.0 → cachekit-0.17.0}/PKG-INFO +1 -1
  3. {cachekit-0.15.0 → cachekit-0.17.0}/pyproject.toml +1 -1
  4. {cachekit-0.15.0 → cachekit-0.17.0}/rust/Cargo.toml +2 -2
  5. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/__init__.py +1 -1
  6. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/backends/base.py +51 -3
  7. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/backends/file/backend.py +150 -43
  8. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/cache_handler.py +169 -5
  9. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/config/decorator.py +0 -6
  10. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/config/nested.py +0 -2
  11. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/config/settings.py +3 -1
  12. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/serializers/arrow_serializer.py +177 -49
  13. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/serializers/encryption_wrapper.py +32 -4
  14. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/serializers/wrapper.py +22 -13
  15. cachekit-0.15.0/src/cachekit/invalidation/__init__.py +0 -31
  16. cachekit-0.15.0/src/cachekit/invalidation/channel.py +0 -125
  17. cachekit-0.15.0/src/cachekit/invalidation/event.py +0 -232
  18. cachekit-0.15.0/src/cachekit/invalidation/redis_channel.py +0 -403
  19. {cachekit-0.15.0 → cachekit-0.17.0}/Cargo.toml +0 -0
  20. {cachekit-0.15.0 → cachekit-0.17.0}/LICENSE +0 -0
  21. {cachekit-0.15.0 → cachekit-0.17.0}/README.md +0 -0
  22. {cachekit-0.15.0 → cachekit-0.17.0}/rust/Makefile +0 -0
  23. {cachekit-0.15.0 → cachekit-0.17.0}/rust/README.md +0 -0
  24. {cachekit-0.15.0 → cachekit-0.17.0}/rust/TEST_EXPANSION_SUMMARY.md +0 -0
  25. {cachekit-0.15.0 → cachekit-0.17.0}/rust/src/lib.rs +0 -0
  26. {cachekit-0.15.0 → cachekit-0.17.0}/rust/src/python_bindings.rs +0 -0
  27. {cachekit-0.15.0 → cachekit-0.17.0}/rust/supply-chain/audits.toml +0 -0
  28. {cachekit-0.15.0 → cachekit-0.17.0}/rust/supply-chain/config.toml +0 -0
  29. {cachekit-0.15.0 → cachekit-0.17.0}/rust/supply-chain/imports.lock +0 -0
  30. {cachekit-0.15.0 → cachekit-0.17.0}/rust/tsan_suppressions.txt +0 -0
  31. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/backends/__init__.py +0 -0
  32. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/backends/base_config.py +0 -0
  33. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/backends/cachekitio/__init__.py +0 -0
  34. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/backends/cachekitio/backend.py +0 -0
  35. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/backends/cachekitio/client.py +0 -0
  36. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/backends/cachekitio/config.py +0 -0
  37. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/backends/cachekitio/error_handler.py +0 -0
  38. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/backends/cachekitio/session.py +0 -0
  39. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/backends/errors.py +0 -0
  40. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/backends/file/__init__.py +0 -0
  41. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/backends/file/config.py +0 -0
  42. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/backends/memcached/__init__.py +0 -0
  43. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/backends/memcached/backend.py +0 -0
  44. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/backends/memcached/config.py +0 -0
  45. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/backends/memcached/error_handler.py +0 -0
  46. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/backends/provider.py +0 -0
  47. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/backends/redis/__init__.py +0 -0
  48. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/backends/redis/backend.py +0 -0
  49. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/backends/redis/client.py +0 -0
  50. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/backends/redis/config.py +0 -0
  51. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/backends/redis/error_handler.py +0 -0
  52. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/backends/redis/provider.py +0 -0
  53. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/config/__init__.py +0 -0
  54. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/config/singleton.py +0 -0
  55. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/config/validation.py +0 -0
  56. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/decorators/__init__.py +0 -0
  57. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/decorators/intent.py +0 -0
  58. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/decorators/local_wrapper.py +0 -0
  59. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/decorators/main.py +0 -0
  60. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/decorators/orchestrator.py +0 -0
  61. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/decorators/session.py +0 -0
  62. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/decorators/stats_context.py +0 -0
  63. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/decorators/tenant_context.py +0 -0
  64. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/decorators/utils/__init__.py +0 -0
  65. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/decorators/wrapper.py +0 -0
  66. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/di.py +0 -0
  67. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/hash_utils.py +0 -0
  68. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/health.py +0 -0
  69. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/hiredis_compat.py +0 -0
  70. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/imports.py +0 -0
  71. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/interop.py +0 -0
  72. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/key_generator.py +0 -0
  73. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/l1_cache.py +0 -0
  74. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/logging.py +0 -0
  75. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/monitoring/__init__.py +0 -0
  76. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/monitoring/correlation_tracking.py +0 -0
  77. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/monitoring/pool_monitor.py +0 -0
  78. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/monitoring/protocols.py +0 -0
  79. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/object_cache.py +0 -0
  80. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/py.typed +0 -0
  81. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/reliability/__init__.py +0 -0
  82. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/reliability/async_metrics.py +0 -0
  83. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/reliability/circuit_breaker.py +0 -0
  84. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/reliability/error_classification.py +0 -0
  85. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/reliability/load_control.py +0 -0
  86. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/reliability/metrics_collection.py +0 -0
  87. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/reliability/profiles.py +0 -0
  88. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/serializers/__init__.py +0 -0
  89. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/serializers/auto_serializer.py +0 -0
  90. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/serializers/base.py +0 -0
  91. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/serializers/interop_serializer.py +0 -0
  92. {cachekit-0.15.0 → cachekit-0.17.0}/src/cachekit/serializers/orjson_serializer.py +0 -0
  93. {cachekit-0.15.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.3.0"
248
+ version = "0.4.0"
249
249
  source = "registry+https://github.com/rust-lang/crates.io-index"
250
- checksum = "9ee6235f73aefb0dc66b9cd0d333b9da928c8192e4234357a739fe756c2f8f23"
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.15.0"
274
+ version = "0.17.0"
275
275
  dependencies = [
276
276
  "cachekit-core",
277
277
  "criterion",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cachekit
3
- Version: 0.15.0
3
+ Version: 0.17.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "cachekit"
7
- version = "0.15.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.15.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.3.0", features = ["compression", "checksum", "messagepack", "encryption"] }
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 }
@@ -67,7 +67,7 @@ Example Usage:
67
67
  ```
68
68
  """
69
69
 
70
- __version__ = "0.15.0"
70
+ __version__ = "0.17.0"
71
71
 
72
72
  from collections.abc import Callable
73
73
  from typing import Any, TypeVar
@@ -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) enable advanced features with graceful degradation.
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
- # Calculate expiry timestamp (0 = never expire)
334
- if ttl is None or ttl == 0:
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
- # Allow overwrites (existing key doesn't increase count)
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
- if ttl == 0:
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 BackendError, BaseBackend, BufferHandle, BufferReadableBackend, TTLInspectableBackend
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 (for L1 cache storage), or None if serialization failed
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 (for L1 cache storage), or None if serialization failed
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
 
@@ -337,7 +337,6 @@ class DecoratorConfig:
337
337
  l1=L1CacheConfig(
338
338
  enabled=True,
339
339
  swr_enabled=False,
340
- invalidation_enabled=False,
341
340
  namespace_index=False,
342
341
  ),
343
342
  circuit_breaker=CircuitBreakerConfig(enabled=False),
@@ -378,7 +377,6 @@ class DecoratorConfig:
378
377
  l1=L1CacheConfig(
379
378
  enabled=True,
380
379
  swr_enabled=True,
381
- invalidation_enabled=True,
382
380
  namespace_index=True,
383
381
  ),
384
382
  circuit_breaker=CircuitBreakerConfig(enabled=True),
@@ -444,7 +442,6 @@ class DecoratorConfig:
444
442
  l1=L1CacheConfig(
445
443
  enabled=True, # L1 stores encrypted bytes. Enabled: ~50ns hits vs 2-7ms Redis
446
444
  swr_enabled=True,
447
- invalidation_enabled=True,
448
445
  namespace_index=True,
449
446
  ),
450
447
  encryption=EncryptionConfig(
@@ -493,7 +490,6 @@ class DecoratorConfig:
493
490
  l1=L1CacheConfig(
494
491
  enabled=True,
495
492
  swr_enabled=True,
496
- invalidation_enabled=False,
497
493
  namespace_index=False,
498
494
  ),
499
495
  circuit_breaker=CircuitBreakerConfig(enabled=True),
@@ -534,7 +530,6 @@ class DecoratorConfig:
534
530
  l1=L1CacheConfig(
535
531
  enabled=True,
536
532
  swr_enabled=False,
537
- invalidation_enabled=False,
538
533
  namespace_index=False,
539
534
  ),
540
535
  circuit_breaker=CircuitBreakerConfig(enabled=False),
@@ -608,7 +603,6 @@ class DecoratorConfig:
608
603
  l1=L1CacheConfig(
609
604
  enabled=True,
610
605
  swr_enabled=True,
611
- invalidation_enabled=True,
612
606
  namespace_index=True,
613
607
  ),
614
608
  circuit_breaker=CircuitBreakerConfig(enabled=True),
@@ -30,7 +30,6 @@ class L1CacheConfig:
30
30
  task and sync functions via a daemon thread.
31
31
  swr_threshold_ratio: Fraction of TTL after which a hit triggers a background
32
32
  refresh, in (0.0, 1.0] (default: 0.5)
33
- invalidation_enabled: Enable invalidation event broadcasts (default: True)
34
33
  namespace_index: Enable fast namespace-based invalidation (default: True)
35
34
 
36
35
  Examples:
@@ -59,7 +58,6 @@ class L1CacheConfig:
59
58
  max_size_mb: int | None = None
60
59
  swr_enabled: bool = True
61
60
  swr_threshold_ratio: float = 0.5
62
- invalidation_enabled: bool = True
63
61
  namespace_index: bool = True
64
62
 
65
63
  def validate(self) -> None: