cachekit 0.23.0__tar.gz → 0.24.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 (88) hide show
  1. {cachekit-0.23.0 → cachekit-0.24.0}/Cargo.lock +1 -1
  2. {cachekit-0.23.0 → cachekit-0.24.0}/PKG-INFO +1 -1
  3. {cachekit-0.23.0 → cachekit-0.24.0}/pyproject.toml +5 -1
  4. {cachekit-0.23.0 → cachekit-0.24.0}/rust/Cargo.toml +1 -1
  5. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/__init__.py +1 -1
  6. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/base.py +6 -3
  7. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/file/backend.py +28 -6
  8. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/cache_handler.py +78 -22
  9. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/config/decorator.py +12 -3
  10. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/config/settings.py +2 -3
  11. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/decorators/wrapper.py +10 -2
  12. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/reliability/metrics_collection.py +4 -4
  13. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/serializers/arrow_serializer.py +50 -12
  14. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/serializers/wrapper.py +31 -4
  15. {cachekit-0.23.0 → cachekit-0.24.0}/Cargo.toml +0 -0
  16. {cachekit-0.23.0 → cachekit-0.24.0}/LICENSE +0 -0
  17. {cachekit-0.23.0 → cachekit-0.24.0}/README.md +0 -0
  18. {cachekit-0.23.0 → cachekit-0.24.0}/rust/Makefile +0 -0
  19. {cachekit-0.23.0 → cachekit-0.24.0}/rust/README.md +0 -0
  20. {cachekit-0.23.0 → cachekit-0.24.0}/rust/TEST_EXPANSION_SUMMARY.md +0 -0
  21. {cachekit-0.23.0 → cachekit-0.24.0}/rust/src/lib.rs +0 -0
  22. {cachekit-0.23.0 → cachekit-0.24.0}/rust/src/msgpack_bounds.rs +0 -0
  23. {cachekit-0.23.0 → cachekit-0.24.0}/rust/src/python_bindings.rs +0 -0
  24. {cachekit-0.23.0 → cachekit-0.24.0}/rust/tsan_suppressions.txt +0 -0
  25. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/_fork.py +0 -0
  26. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/__init__.py +0 -0
  27. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/_uninterrupted.py +0 -0
  28. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/base_config.py +0 -0
  29. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/cachekitio/__init__.py +0 -0
  30. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/cachekitio/backend.py +0 -0
  31. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/cachekitio/client.py +0 -0
  32. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/cachekitio/config.py +0 -0
  33. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/cachekitio/error_handler.py +0 -0
  34. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/cachekitio/session.py +0 -0
  35. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/errors.py +0 -0
  36. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/file/__init__.py +0 -0
  37. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/file/config.py +0 -0
  38. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/memcached/__init__.py +0 -0
  39. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/memcached/backend.py +0 -0
  40. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/memcached/config.py +0 -0
  41. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/memcached/error_handler.py +0 -0
  42. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/provider.py +0 -0
  43. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/redis/__init__.py +0 -0
  44. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/redis/backend.py +0 -0
  45. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/redis/client.py +0 -0
  46. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/redis/config.py +0 -0
  47. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/redis/error_handler.py +0 -0
  48. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/backends/redis/provider.py +0 -0
  49. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/config/__init__.py +0 -0
  50. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/config/nested.py +0 -0
  51. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/config/singleton.py +0 -0
  52. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/config/validation.py +0 -0
  53. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/decorators/__init__.py +0 -0
  54. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/decorators/intent.py +0 -0
  55. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/decorators/local_wrapper.py +0 -0
  56. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/decorators/main.py +0 -0
  57. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/decorators/orchestrator.py +0 -0
  58. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/decorators/session.py +0 -0
  59. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/decorators/single_flight.py +0 -0
  60. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/decorators/stats_context.py +0 -0
  61. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/decorators/tenant_context.py +0 -0
  62. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/decorators/utils/__init__.py +0 -0
  63. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/di.py +0 -0
  64. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/hash_utils.py +0 -0
  65. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/health.py +0 -0
  66. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/hiredis_compat.py +0 -0
  67. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/imports.py +0 -0
  68. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/interop.py +0 -0
  69. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/invalidation.py +0 -0
  70. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/key_generator.py +0 -0
  71. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/l1_cache.py +0 -0
  72. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/logging.py +0 -0
  73. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/monitoring/__init__.py +0 -0
  74. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/monitoring/protocols.py +0 -0
  75. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/object_cache.py +0 -0
  76. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/py.typed +0 -0
  77. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/reliability/__init__.py +0 -0
  78. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/reliability/async_metrics.py +0 -0
  79. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/reliability/circuit_breaker.py +0 -0
  80. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/reliability/error_classification.py +0 -0
  81. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/reliability/load_control.py +0 -0
  82. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/serializers/__init__.py +0 -0
  83. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/serializers/auto_serializer.py +0 -0
  84. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/serializers/base.py +0 -0
  85. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/serializers/encryption_wrapper.py +0 -0
  86. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/serializers/interop_serializer.py +0 -0
  87. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/serializers/orjson_serializer.py +0 -0
  88. {cachekit-0.23.0 → cachekit-0.24.0}/src/cachekit/serializers/standard_serializer.py +0 -0
@@ -280,7 +280,7 @@ dependencies = [
280
280
 
281
281
  [[package]]
282
282
  name = "cachekit-rs"
283
- version = "0.23.0"
283
+ version = "0.24.0"
284
284
  dependencies = [
285
285
  "cachekit-core",
286
286
  "criterion 0.8.2",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cachekit
3
- Version: 0.23.0
3
+ Version: 0.24.0
4
4
  Classifier: Development Status :: 4 - Beta
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.23.0"
7
+ version = "0.24.0"
8
8
  description = "Backend-agnostic caching for Python — intent-based decorators with circuit breaker, distributed locking, Prometheus metrics, and optional zero-knowledge AES-256-GCM encryption, on a Rust-powered core. Zero-config L1 in-memory; scales to Redis, Memcached, File, or CachekitIO."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -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
@@ -252,6 +253,9 @@ dev = [
252
253
  # OrjsonSerializer support — now the [json] optional extra; kept here so the
253
254
  # orjson tests, doctests, and markdown-docs still resolve it in dev/CI.
254
255
  "orjson>=3.9.0",
256
+ # ArrowSerializer return_format="polars" tests (zero-copy column aliasing). Not in `test`:
257
+ # polars ships abi3 wheels only, which a free-threaded interpreter cannot install.
258
+ "polars>=2.0.0",
255
259
  ]
256
260
  # Linux CI only - Atheris requires libFuzzer (not available on macOS without building LLVM)
257
261
  fuzz = [
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "cachekit-rs"
3
- version = "0.23.0"
3
+ version = "0.24.0"
4
4
  edition = "2021"
5
5
  authors = ["cachekit Contributors"]
6
6
  description = "High-performance storage engine for caching with compression and encryption"
@@ -68,7 +68,7 @@ Example Usage:
68
68
  ```
69
69
  """
70
70
 
71
- __version__ = "0.23.0"
71
+ __version__ = "0.24.0"
72
72
 
73
73
  from collections.abc import Callable
74
74
  from typing import Any, TypeVar
@@ -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 — so the consumer must finish reading and ``close()``
190
- before the view is touched again. The view DANGLES after close (touching it can segfault), so
191
- it must never be stored (e.g. in L1) nor returned past the read call frame (#171).
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 the consumer is done (after Arrow deserialize has copied the
138
- data out via to_pandas). The view DANGLES after close — touching it segfaults — so the handle
139
- must never escape the deserialize call frame and must never be stored in L1 (#171 blocker C).
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 (ValueError, BufferError): # pragma: no cover - defensive (already released / lingering sub-export)
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 (ValueError, BufferError): # pragma: no cover - defensive (already closed)
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
@@ -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" (DataFrame zero-copy), "orjson" (JSON)
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
- wrapped = SerializationWrapper.wrap(serialized_data, metadata_dict, self._serializer_string_name)
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 zero-copy mmap fast path (#171).
1210
+ """True iff reads can use the mmap fast path (#171).
1167
1211
 
1168
- Eligible only for PLAINTEXT Arrow that returns pandas:
1169
- - encrypted values can never mmap (AES-GCM decrypt owns its buffer);
1170
- - non-Arrow serializers gain nothing (they copy at the Rust/C boundary, rebuild objects);
1171
- - the "arrow" return_format yields a table that ALIASES the mapped pages, so closing the
1172
- handle would be a use-after-free — pandas (which copies out via to_pandas) only.
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
- return (
1178
- not self.encryption
1179
- and self._serializer_string_name == "arrow"
1180
- and getattr(self._base_serializer, "return_format", None) == "pandas"
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 not self.encryption and not self.interop_mode and self._serializer_string_name == "arrow"
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 = SerializationWrapper.wrap_prefix(metadata.to_dict(), self._serializer_string_name)
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, where the
1494
- mapped view is confined to its frame and must never reach L1 (#171
1495
- blocker C). size_bytes is the envelope's byte length on every hit, mmap
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 -> pandas on a buffer-readable backend (File,
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). A None
1740
- # from get_buffer (ineligible file, or a non-buffer backend) falls through to bytes.
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" (DataFrame zero-copy)
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, or cross-language cache sharing.
145
- Example: @cache(key=lambda arr: hashlib.blake2b(arr.tobytes()).hexdigest())
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, which lets the File backend serve plaintext "
156
- "DataFrame reads via a zero-copy mmap (low steady-state read RSS; peak transiently "
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
  ),
@@ -634,7 +634,7 @@ def create_cache_wrapper(
634
634
  ttl: Cache time-to-live in seconds (None = no expiration)
635
635
  namespace: Cache key namespace prefix
636
636
  serializer: Serializer instance or name. Accepts either:
637
- - String name: "default" (MessagePack), "arrow" (DataFrame zero-copy)
637
+ - String name: "default" (MessagePack), "arrow" (DataFrames as Arrow IPC)
638
638
  - SerializerProtocol instance: Custom serializer implementing the protocol
639
639
  An EncryptionWrapper instance or the "encrypted" name raises ConfigurationError:
640
640
  encrypt with encryption=True (@cache.secure) and pass the inner serializer here.
@@ -816,6 +816,8 @@ def create_cache_wrapper(
816
816
  _interop_sig = inspect.signature(func)
817
817
 
818
818
  # Key registry id: names this function's server-side tracking set on a KeyTrackableBackend.
819
+ # _registry_hash is also the identity every custom key= key starts with (_resolve_cache_key):
820
+ # changing it re-keys every key= entry, and the golden-vector test pins it.
819
821
  # 64-bit hash, not func_hash's 32: a registry collision makes one function's invalidation
820
822
  # drain another's keys. namespace=None and namespace="default" write different auto-mode
821
823
  # keys, so they get different sets (None -> empty segment). The "ck" namespace is reserved:
@@ -1501,7 +1503,13 @@ def create_cache_wrapper(
1501
1503
  custom_key = custom_key_func(*call_args, **call_kwargs)
1502
1504
  if not isinstance(custom_key, str):
1503
1505
  raise TypeError(f"key function must return str, got {type(custom_key).__name__}")
1504
- return f"{namespace or 'default'}:{custom_key}"
1506
+ # The function's 16-hex identity goes first, at a fixed width no namespace or key can shift, so
1507
+ # two functions whose key functions return the same string never share an entry. First also keeps
1508
+ # every key apart from the pre-fix "{namespace}:{key}" keys an earlier release writes, which start
1509
+ # with a configured namespace: a crafted argument cannot plant one (unless a plain RedisBackend
1510
+ # sharing the database uses namespace t or t:*, which can spell a tenant-scoped key). Generated
1511
+ # keys start ns: or func:, so this key never equals one. CachekitIO files it in its default namespace.
1512
+ return f"{_registry_hash}-{namespace or 'default'}:{custom_key}"
1505
1513
  # fast_mode: the only mode _generated_key_mode leaves once interop and key= are out.
1506
1514
  # Minimal key generation - no string formatting overhead (10-50μs savings)
1507
1515
  from ..hash_utils import cache_key_hash
@@ -35,7 +35,7 @@ class MetricsCollector:
35
35
  Examples:
36
36
  Create and use a counter:
37
37
 
38
- >>> counter = MetricsCollector("test_counter")
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("labeled_counter")
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("test_gauge")
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("chained")
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 zero-copy DataFrame caching.
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, no conversion)
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. Lets the File backend serve plaintext
214
- DataFrame reads (returned as pandas) via a zero-copy mmap — low steady-state read
215
- RSS (~0.32x), though peak is transiently higher from checksum verification + pandas
216
- materialization — at the cost of a larger stored payload. No effect on wire backends.
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, which the File backend reads zero-copy via mmap (#171,
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
- reader = pa.ipc.open_file(pa.py_buffer(body))
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
- return pl.from_arrow(table)
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 # zero-copy, no conversion
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(metadata: dict[str, Any], serializer_name: str, version: str = "2.0") -> bytes:
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
- return _encode_prefix_memo(serializer_name, items, version)
186
- return _encode_prefix(serializer_name, metadata, version)
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