cachekit 0.20.0__cp314-cp314-win_amd64.whl
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.
- LICENSE +21 -0
- README.md +539 -0
- cachekit/__init__.py +136 -0
- cachekit/_rust_serializer.cp314-win_amd64.pyd +0 -0
- cachekit/backends/__init__.py +114 -0
- cachekit/backends/base.py +450 -0
- cachekit/backends/base_config.py +104 -0
- cachekit/backends/cachekitio/__init__.py +12 -0
- cachekit/backends/cachekitio/backend.py +911 -0
- cachekit/backends/cachekitio/client.py +187 -0
- cachekit/backends/cachekitio/config.py +225 -0
- cachekit/backends/cachekitio/error_handler.py +134 -0
- cachekit/backends/cachekitio/session.py +59 -0
- cachekit/backends/errors.py +198 -0
- cachekit/backends/file/__init__.py +30 -0
- cachekit/backends/file/backend.py +1159 -0
- cachekit/backends/file/config.py +191 -0
- cachekit/backends/memcached/__init__.py +25 -0
- cachekit/backends/memcached/backend.py +255 -0
- cachekit/backends/memcached/config.py +137 -0
- cachekit/backends/memcached/error_handler.py +98 -0
- cachekit/backends/provider.py +255 -0
- cachekit/backends/redis/__init__.py +12 -0
- cachekit/backends/redis/backend.py +285 -0
- cachekit/backends/redis/client.py +263 -0
- cachekit/backends/redis/config.py +117 -0
- cachekit/backends/redis/error_handler.py +185 -0
- cachekit/backends/redis/provider.py +804 -0
- cachekit/cache_handler.py +2301 -0
- cachekit/config/__init__.py +48 -0
- cachekit/config/decorator.py +603 -0
- cachekit/config/nested.py +393 -0
- cachekit/config/settings.py +382 -0
- cachekit/config/singleton.py +100 -0
- cachekit/config/validation.py +243 -0
- cachekit/decorators/__init__.py +15 -0
- cachekit/decorators/intent.py +283 -0
- cachekit/decorators/local_wrapper.py +145 -0
- cachekit/decorators/main.py +7 -0
- cachekit/decorators/orchestrator.py +419 -0
- cachekit/decorators/session.py +141 -0
- cachekit/decorators/stats_context.py +64 -0
- cachekit/decorators/tenant_context.py +256 -0
- cachekit/decorators/utils/__init__.py +3 -0
- cachekit/decorators/wrapper.py +2490 -0
- cachekit/di.py +55 -0
- cachekit/hash_utils.py +145 -0
- cachekit/health.py +647 -0
- cachekit/hiredis_compat.py +98 -0
- cachekit/imports.py +48 -0
- cachekit/interop.py +518 -0
- cachekit/key_generator.py +450 -0
- cachekit/l1_cache.py +642 -0
- cachekit/logging.py +386 -0
- cachekit/monitoring/__init__.py +8 -0
- cachekit/monitoring/pool_monitor.py +346 -0
- cachekit/monitoring/protocols.py +50 -0
- cachekit/object_cache.py +471 -0
- cachekit/py.typed +0 -0
- cachekit/reliability/__init__.py +36 -0
- cachekit/reliability/async_metrics.py +804 -0
- cachekit/reliability/circuit_breaker.py +459 -0
- cachekit/reliability/error_classification.py +91 -0
- cachekit/reliability/load_control.py +214 -0
- cachekit/reliability/metrics_collection.py +407 -0
- cachekit/reliability/profiles.py +236 -0
- cachekit/serializers/__init__.py +260 -0
- cachekit/serializers/arrow_serializer.py +450 -0
- cachekit/serializers/auto_serializer.py +1262 -0
- cachekit/serializers/base.py +480 -0
- cachekit/serializers/encryption_wrapper.py +885 -0
- cachekit/serializers/interop_serializer.py +64 -0
- cachekit/serializers/orjson_serializer.py +212 -0
- cachekit/serializers/standard_serializer.py +411 -0
- cachekit/serializers/wrapper.py +169 -0
- cachekit-0.20.0.dist-info/METADATA +596 -0
- cachekit-0.20.0.dist-info/RECORD +80 -0
- cachekit-0.20.0.dist-info/WHEEL +4 -0
- cachekit-0.20.0.dist-info/licenses/LICENSE +21 -0
- cachekit-0.20.0.dist-info/sboms/cachekit-rs.cyclonedx.json +3546 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Backend storage abstraction for cachekit.
|
|
2
|
+
|
|
3
|
+
This module provides protocol-based abstraction for L2 backend storage with
|
|
4
|
+
dependency injection pattern. Backends can be Redis, HTTP, DynamoDB, Memcached,
|
|
5
|
+
or any key-value store.
|
|
6
|
+
|
|
7
|
+
Public API:
|
|
8
|
+
- BaseBackend: Core protocol (5 methods: get, set, delete, exists, health_check)
|
|
9
|
+
- TTLInspectableBackend: Optional protocol for TTL inspection/refresh
|
|
10
|
+
- LockableBackend: Optional protocol for distributed locking
|
|
11
|
+
- KeyTrackableBackend: Optional protocol for server-side key tracking (whole-function invalidation)
|
|
12
|
+
- TimeoutConfigurableBackend: Optional protocol for per-operation timeouts
|
|
13
|
+
- BackendProvider: Dependency injection protocol
|
|
14
|
+
- BackendError: Exception raised by backend operations
|
|
15
|
+
- BackendErrorType: Error classification enum
|
|
16
|
+
- CapabilityNotAvailableError: Exception for missing optional capabilities
|
|
17
|
+
- RedisBackend: Redis implementation (default)
|
|
18
|
+
- MemcachedBackend: Memcached implementation (requires pymemcache)
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
>>> from cachekit.backends import BaseBackend, RedisBackend, BackendError
|
|
22
|
+
>>> # RedisBackend usage requires Redis connection
|
|
23
|
+
>>> # See RedisBackend documentation for connection details
|
|
24
|
+
|
|
25
|
+
Dependency injection pattern:
|
|
26
|
+
>>> from cachekit.backends import BackendProvider
|
|
27
|
+
>>> # Implement BackendProvider protocol in your application
|
|
28
|
+
>>> # See BackendProvider documentation for implementation examples
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from typing import Protocol
|
|
34
|
+
|
|
35
|
+
from cachekit.backends.base import (
|
|
36
|
+
BaseBackend,
|
|
37
|
+
KeyTrackableBackend,
|
|
38
|
+
LockableBackend,
|
|
39
|
+
TimeoutConfigurableBackend,
|
|
40
|
+
TTLInspectableBackend,
|
|
41
|
+
)
|
|
42
|
+
from cachekit.backends.base_config import BaseBackendConfig, inherit_config
|
|
43
|
+
from cachekit.backends.errors import (
|
|
44
|
+
BackendError,
|
|
45
|
+
BackendErrorType,
|
|
46
|
+
CapabilityNotAvailableError,
|
|
47
|
+
)
|
|
48
|
+
from cachekit.backends.redis import RedisBackend
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"BaseBackend",
|
|
52
|
+
"BaseBackendConfig",
|
|
53
|
+
"inherit_config",
|
|
54
|
+
"TTLInspectableBackend",
|
|
55
|
+
"LockableBackend",
|
|
56
|
+
"KeyTrackableBackend",
|
|
57
|
+
"TimeoutConfigurableBackend",
|
|
58
|
+
"BackendProvider",
|
|
59
|
+
"BackendError",
|
|
60
|
+
"BackendErrorType",
|
|
61
|
+
"CapabilityNotAvailableError",
|
|
62
|
+
"RedisBackend",
|
|
63
|
+
"MemcachedBackend",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class BackendProvider(Protocol):
|
|
68
|
+
"""Protocol for dependency injection of backend instances.
|
|
69
|
+
|
|
70
|
+
Enables testability and pluggable backends without hardcoding concrete
|
|
71
|
+
implementations. The provider manages backend lifecycle (singleton,
|
|
72
|
+
pooling, etc.). A decorator calls ``get_backend()`` once and keeps the
|
|
73
|
+
result for the life of the process, so the backend returned must be safe to
|
|
74
|
+
share across requests — anything request-scoped (e.g. the tenant) has to be
|
|
75
|
+
resolved per operation, not captured when the backend is built.
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
>>> from cachekit.backends import BackendProvider, BaseBackend
|
|
79
|
+
>>> # Implement BackendProvider protocol:
|
|
80
|
+
>>> # class MyProvider:
|
|
81
|
+
>>> # def get_backend(self) -> BaseBackend:
|
|
82
|
+
>>> # return RedisBackend()
|
|
83
|
+
>>> # provider = MyProvider()
|
|
84
|
+
>>> # backend = provider.get_backend()
|
|
85
|
+
|
|
86
|
+
Note:
|
|
87
|
+
This is a structural protocol (PEP 544) - any class with a get_backend()
|
|
88
|
+
method satisfies this protocol without explicit inheritance.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def get_backend(self) -> BaseBackend:
|
|
92
|
+
"""Return a BaseBackend instance.
|
|
93
|
+
|
|
94
|
+
Implementation can manage singleton or pooling depending on backend
|
|
95
|
+
requirements; the result must be safe to share across requests.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
BaseBackend instance ready for cache operations
|
|
99
|
+
|
|
100
|
+
Example:
|
|
101
|
+
>>> provider = MyProvider() # doctest: +SKIP
|
|
102
|
+
>>> backend = provider.get_backend() # doctest: +SKIP
|
|
103
|
+
>>> backend.set("key", b"value", ttl=60) # doctest: +SKIP
|
|
104
|
+
"""
|
|
105
|
+
...
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def __getattr__(name: str):
|
|
109
|
+
"""Lazy import for optional backends (pymemcache may not be installed)."""
|
|
110
|
+
if name == "MemcachedBackend":
|
|
111
|
+
from cachekit.backends.memcached import MemcachedBackend
|
|
112
|
+
|
|
113
|
+
return MemcachedBackend
|
|
114
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""Base backend protocol definitions.
|
|
2
|
+
|
|
3
|
+
This module defines the storage backend contract using PEP 544 protocol-based abstraction.
|
|
4
|
+
All L2 backends (Redis, HTTP, etc.) must implement BaseBackend protocol.
|
|
5
|
+
|
|
6
|
+
Optional capability protocols (TTLInspectableBackend, LockableBackend, KeyTrackableBackend,
|
|
7
|
+
TimeoutConfigurableBackend, BufferReadableBackend, BufferWritableBackend)
|
|
8
|
+
enable advanced features with graceful degradation.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import AsyncIterator, Callable, Iterable
|
|
14
|
+
from contextlib import AbstractAsyncContextManager
|
|
15
|
+
from typing import Any, BinaryIO, Optional, Protocol, runtime_checkable
|
|
16
|
+
|
|
17
|
+
# Re-export BackendError for convenience (public API)
|
|
18
|
+
from cachekit.backends.errors import BackendError # noqa: F401
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class BaseBackend(Protocol):
|
|
23
|
+
"""Protocol defining the L2 backend storage contract.
|
|
24
|
+
|
|
25
|
+
All backend implementations must support these four operations on bytes.
|
|
26
|
+
This protocol uses structural subtyping (PEP 544) - any class implementing
|
|
27
|
+
these methods is considered a valid backend.
|
|
28
|
+
|
|
29
|
+
Design principles:
|
|
30
|
+
- Stateless operations (no connection management in protocol)
|
|
31
|
+
- Bytes-only interface (language-agnostic, no Python-specific types)
|
|
32
|
+
- No Redis-specific concepts (works for any backend: Redis, HTTP, DynamoDB, etc.)
|
|
33
|
+
- Simple and focused (KISS principle)
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
>>> from cachekit.backends import BaseBackend, RedisBackend
|
|
37
|
+
>>> issubclass(RedisBackend, BaseBackend) # structural (runtime-checkable) protocol
|
|
38
|
+
True
|
|
39
|
+
>>> backend = RedisBackend() # doctest: +SKIP
|
|
40
|
+
>>> backend.set("key", b"value", ttl=60) # doctest: +SKIP
|
|
41
|
+
>>> data = backend.get("key") # doctest: +SKIP
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def get(self, key: str) -> Optional[bytes]:
|
|
45
|
+
"""Retrieve value from backend storage.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
key: Cache key to retrieve
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Bytes value if found, None if key doesn't exist
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
BackendError: If backend operation fails (network, timeout, etc.)
|
|
55
|
+
"""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
def set(self, key: str, value: bytes, ttl: Optional[int] = None) -> None:
|
|
59
|
+
"""Store value in backend storage.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
key: Cache key to store
|
|
63
|
+
value: Bytes value to store (encrypted or plaintext msgpack)
|
|
64
|
+
ttl: Time-to-live in seconds (None = no expiry)
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
BackendError: If backend operation fails (network, timeout, etc.)
|
|
68
|
+
"""
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
def delete(self, key: str) -> bool:
|
|
72
|
+
"""Delete key from backend storage.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
key: Cache key to delete
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
True if key was deleted, False if key didn't exist
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
BackendError: If backend operation fails (network, timeout, etc.)
|
|
82
|
+
"""
|
|
83
|
+
...
|
|
84
|
+
|
|
85
|
+
def exists(self, key: str) -> bool:
|
|
86
|
+
"""Check if key exists in backend storage.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
key: Cache key to check
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
True if key exists, False otherwise
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
BackendError: If backend operation fails (network, timeout, etc.)
|
|
96
|
+
"""
|
|
97
|
+
...
|
|
98
|
+
|
|
99
|
+
def health_check(self) -> tuple[bool, dict[str, Any]]:
|
|
100
|
+
"""Check backend health status.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Tuple of (is_healthy, details_dict)
|
|
104
|
+
Details must include 'latency_ms' and 'backend_type'
|
|
105
|
+
|
|
106
|
+
Example:
|
|
107
|
+
>>> backend = RedisBackend() # doctest: +SKIP
|
|
108
|
+
>>> is_healthy, details = backend.health_check() # doctest: +SKIP
|
|
109
|
+
>>> assert 'latency_ms' in details # doctest: +SKIP
|
|
110
|
+
>>> assert 'backend_type' in details # doctest: +SKIP
|
|
111
|
+
"""
|
|
112
|
+
...
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@runtime_checkable
|
|
116
|
+
class TTLInspectableBackend(Protocol):
|
|
117
|
+
"""Optional protocol for backends that support TTL inspection and refresh.
|
|
118
|
+
|
|
119
|
+
Backends implementing this protocol can report remaining TTL on keys
|
|
120
|
+
and refresh TTLs on existing keys. This enables features like automatic
|
|
121
|
+
TTL refresh for frequently-accessed keys.
|
|
122
|
+
|
|
123
|
+
Not all backends support this capability:
|
|
124
|
+
- Supported: Redis, CachekitIO, File, PostgreSQL, DynamoDB, SQLite
|
|
125
|
+
- Not supported: HTTP (stateless), S3 (limited)
|
|
126
|
+
- Memcached: partial — implements ``refresh_ttl`` (via ``touch``) but NOT ``get_ttl``
|
|
127
|
+
(no protocol command to read remaining TTL), so it is not a full TTLInspectableBackend.
|
|
128
|
+
|
|
129
|
+
Example:
|
|
130
|
+
>>> # TTL inspection pattern (async context):
|
|
131
|
+
>>> # if hasattr(backend, 'get_ttl'):
|
|
132
|
+
>>> # ttl = await backend.get_ttl("user:123")
|
|
133
|
+
>>> # if ttl and ttl < 60:
|
|
134
|
+
>>> # await backend.refresh_ttl("user:123", 3600)
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
async def get_ttl(self, key: str) -> Optional[int]:
|
|
138
|
+
"""Get remaining TTL on key (in seconds).
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
key: Cache key to inspect
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Remaining TTL in seconds, or None if:
|
|
145
|
+
- Key doesn't exist
|
|
146
|
+
- Key has no expiration (permanent)
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
BackendError: If backend operation fails
|
|
150
|
+
|
|
151
|
+
Example:
|
|
152
|
+
>>> ttl = await backend.get_ttl("user:123") # doctest: +SKIP
|
|
153
|
+
>>> if ttl and ttl < 60: # doctest: +SKIP
|
|
154
|
+
... # Key expiring soon
|
|
155
|
+
... pass # doctest: +SKIP
|
|
156
|
+
"""
|
|
157
|
+
...
|
|
158
|
+
|
|
159
|
+
async def refresh_ttl(self, key: str, ttl: int) -> bool:
|
|
160
|
+
"""Refresh (update) TTL on existing key.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
key: Cache key to refresh
|
|
164
|
+
ttl: New TTL in seconds
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
True if key existed and TTL was refreshed
|
|
168
|
+
False if key doesn't exist (no-op)
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
BackendError: If backend operation fails
|
|
172
|
+
|
|
173
|
+
Example:
|
|
174
|
+
>>> refreshed = await backend.refresh_ttl("user:123", 3600) # doctest: +SKIP
|
|
175
|
+
>>> if refreshed: # doctest: +SKIP
|
|
176
|
+
... # TTL successfully updated
|
|
177
|
+
... pass # doctest: +SKIP
|
|
178
|
+
"""
|
|
179
|
+
...
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@runtime_checkable
|
|
183
|
+
class BufferHandle(Protocol):
|
|
184
|
+
"""A borrowed, zero-copy view of a cached value plus the resource backing it.
|
|
185
|
+
|
|
186
|
+
Returned by ``BufferReadableBackend.get_buffer``. ``view`` aliases backend-owned memory (e.g.
|
|
187
|
+
mmap'd file pages), not a heap copy — so the consumer must finish reading and ``close()``
|
|
188
|
+
before the view is touched again. The view DANGLES after close (touching it can segfault), so
|
|
189
|
+
it must never be stored (e.g. in L1) nor returned past the read call frame (#171).
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
view: memoryview
|
|
193
|
+
"""Zero-copy view of the payload (valid only until close())."""
|
|
194
|
+
|
|
195
|
+
def close(self) -> None:
|
|
196
|
+
"""Release the view and its backing resource. Idempotent."""
|
|
197
|
+
...
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@runtime_checkable
|
|
201
|
+
class BufferReadableBackend(Protocol):
|
|
202
|
+
"""Optional protocol for backends that can return a zero-copy buffer instead of materializing
|
|
203
|
+
the whole value on the heap.
|
|
204
|
+
|
|
205
|
+
Lets large plaintext values (e.g. uncompressed Arrow IPC) be read without copying the payload.
|
|
206
|
+
Only the File backend implements this today (mmap, POSIX). Backends that don't implement it
|
|
207
|
+
are simply read via ``get`` as usual.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
def get_buffer(self, key: str) -> Optional[BufferHandle]:
|
|
211
|
+
"""Return a borrowed zero-copy handle for ``key``, or None when the value is not mappable
|
|
212
|
+
(missing/expired/too large/non-POSIX) — the caller then falls back to ``get``. The caller
|
|
213
|
+
MUST ``close()`` the handle when done reading."""
|
|
214
|
+
...
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@runtime_checkable
|
|
218
|
+
class BufferWritableBackend(Protocol):
|
|
219
|
+
"""Optional protocol for backends that can accept a value written incrementally through a
|
|
220
|
+
seekable sink, instead of one materialized ``bytes`` blob (LAB-766, write-side twin of
|
|
221
|
+
``BufferReadableBackend``).
|
|
222
|
+
|
|
223
|
+
Lets large values (e.g. multi-GB Arrow IPC) reach storage without the whole serialized
|
|
224
|
+
payload ever existing in memory. Only the File backend implements this today; backends that
|
|
225
|
+
don't are simply written via ``set`` as usual (the caller falls back transparently).
|
|
226
|
+
|
|
227
|
+
Why a writer callback and not an ``Iterable[bytes]``: the Arrow envelope stores an
|
|
228
|
+
xxHash3-64 checksum BEFORE the payload it covers, so the producer must patch bytes it has
|
|
229
|
+
already emitted once the stream is complete. A forward-only chunk iterator cannot express
|
|
230
|
+
that without serializing twice; a seekable sink can. This deliberately scopes the protocol
|
|
231
|
+
to random-access backends (File) — a forward-only transport (chunked HTTP upload) needs a
|
|
232
|
+
different contract and is out of scope until a backend demands it.
|
|
233
|
+
|
|
234
|
+
Contract:
|
|
235
|
+
- ``write_payload`` receives a writable, seekable binary sink positioned at the START of
|
|
236
|
+
the payload region. Producers MUST anchor seeks with ``tell()`` (never absolute offset 0):
|
|
237
|
+
backends may keep private framing before the payload.
|
|
238
|
+
- The value becomes visible under ``key`` only if ``write_payload`` returns successfully;
|
|
239
|
+
on any exception the backend discards the partial write, leaves any previous value for
|
|
240
|
+
the key intact, and re-raises the producer's exception unwrapped (so serialization errors
|
|
241
|
+
keep their type for the caller's error handling).
|
|
242
|
+
- ``ttl`` semantics are identical to ``set``.
|
|
243
|
+
- A successful ``set_streaming(key, w, ttl)`` is observably identical to
|
|
244
|
+
``set(key, full_bytes, ttl)`` for the same payload bytes — same readback via
|
|
245
|
+
``get``/``get_buffer``, same expiry, same size-limit enforcement (violations raise
|
|
246
|
+
``BackendError`` and nothing is stored).
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
def set_streaming(self, key: str, write_payload: Callable[[BinaryIO], None], ttl: Optional[int] = None) -> None:
|
|
250
|
+
"""Store the payload produced by ``write_payload`` under ``key``, never holding it whole.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
key: Cache key to store
|
|
254
|
+
write_payload: Callable that writes the complete payload to the provided sink
|
|
255
|
+
ttl: Time-to-live in seconds (None = no expiry), same semantics as ``set``
|
|
256
|
+
|
|
257
|
+
Raises:
|
|
258
|
+
BackendError: If the backend operation fails (I/O, size limits, TTL bounds)
|
|
259
|
+
Exception: Whatever ``write_payload`` raises, unwrapped (partial write discarded)
|
|
260
|
+
"""
|
|
261
|
+
...
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@runtime_checkable
|
|
265
|
+
class LockableBackend(Protocol):
|
|
266
|
+
"""Optional protocol for backends supporting distributed locking.
|
|
267
|
+
|
|
268
|
+
Backends implementing this protocol can provide distributed lock semantics
|
|
269
|
+
for coordinating access across multiple processes/servers. This enables
|
|
270
|
+
features like cache stampede prevention and critical sections.
|
|
271
|
+
|
|
272
|
+
Not all backends support this capability:
|
|
273
|
+
- Supported: ``PerRequestRedisBackend`` — the tenant-scoped Redis backend
|
|
274
|
+
``RedisBackendProvider`` hands out and the env-resolved Redis path uses — and
|
|
275
|
+
``CachekitIOBackend`` (SaaS ``POST /v1/cache/{key}/lock``).
|
|
276
|
+
- Not supported: ``RedisBackend`` constructed directly and passed as
|
|
277
|
+
``backend=``, ``FileBackend``, L1-only (in-memory).
|
|
278
|
+
|
|
279
|
+
Contract — bare cache key:
|
|
280
|
+
``acquire_lock`` receives the **bare cache key**, identical to what
|
|
281
|
+
``get``/``set``/``delete`` receive. Callers MUST NOT append suffixes
|
|
282
|
+
like ``:lock`` before passing the key. Each backend owns its own
|
|
283
|
+
lock-namespace derivation:
|
|
284
|
+
|
|
285
|
+
- Redis: derives ``<key>:lock`` internally for the on-wire lock name.
|
|
286
|
+
- CachekitIO (SaaS): the lock endpoint is ``POST /v1/cache/{key}/lock`` —
|
|
287
|
+
no key derivation needed.
|
|
288
|
+
|
|
289
|
+
This convergence keeps the Python SDK byte-for-byte compatible with
|
|
290
|
+
the cachekit-rs and cachekit-ts SDKs at the protocol boundary.
|
|
291
|
+
|
|
292
|
+
Example:
|
|
293
|
+
>>> # Distributed locking pattern (async context):
|
|
294
|
+
>>> # if hasattr(backend, 'acquire_lock'):
|
|
295
|
+
>>> # async with backend.acquire_lock("user:123:profile", timeout=30) as acquired:
|
|
296
|
+
>>> # if acquired:
|
|
297
|
+
>>> # result = expensive_computation()
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
def acquire_lock(
|
|
301
|
+
self,
|
|
302
|
+
key: str,
|
|
303
|
+
timeout: float,
|
|
304
|
+
blocking_timeout: Optional[float] = None,
|
|
305
|
+
) -> AbstractAsyncContextManager[bool]:
|
|
306
|
+
"""Acquire a distributed lock on key.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
key: Bare cache key (e.g., ``"user:123"``) — the SAME shape passed
|
|
310
|
+
to ``get``/``set``/``delete``. Implementations are responsible
|
|
311
|
+
for any internal lock-namespace derivation; callers MUST NOT
|
|
312
|
+
pre-suffix the key.
|
|
313
|
+
timeout: How long to hold the lock (seconds) before auto-release
|
|
314
|
+
blocking_timeout: Max time to wait for lock acquisition (None = non-blocking)
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
An async context manager yielding True if the lock was acquired,
|
|
318
|
+
False if the wait timed out. Implementations are ``async``
|
|
319
|
+
generators wrapped in ``@asynccontextmanager``, so this protocol
|
|
320
|
+
declares the *decorated* shape.
|
|
321
|
+
|
|
322
|
+
Raises:
|
|
323
|
+
BackendError: If backend operation fails
|
|
324
|
+
|
|
325
|
+
Example:
|
|
326
|
+
>>> async with backend.acquire_lock("user:123:profile", timeout=30, blocking_timeout=5) as acquired: # doctest: +SKIP
|
|
327
|
+
... if acquired: # doctest: +SKIP
|
|
328
|
+
... # Lock held, safe to proceed
|
|
329
|
+
... pass # doctest: +SKIP
|
|
330
|
+
... else: # doctest: +SKIP
|
|
331
|
+
... # Timeout waiting for lock
|
|
332
|
+
... pass # doctest: +SKIP
|
|
333
|
+
|
|
334
|
+
Note:
|
|
335
|
+
Lock is automatically released on context exit, even if exception occurs.
|
|
336
|
+
"""
|
|
337
|
+
...
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@runtime_checkable
|
|
341
|
+
class KeyTrackableBackend(Protocol):
|
|
342
|
+
"""Optional protocol for backends supporting server-side key tracking.
|
|
343
|
+
|
|
344
|
+
A decorated function's no-argument ``invalidate_cache()`` must delete every key
|
|
345
|
+
ANY process wrote for it, not only the keys the calling process remembers. A
|
|
346
|
+
tracking backend keeps one server-side set of written keys per decorated
|
|
347
|
+
function (the registry id) — per tenant on a tenant-scoped backend — and drains
|
|
348
|
+
the calling tenant's set on whole-function invalidation.
|
|
349
|
+
|
|
350
|
+
Not all backends support this capability:
|
|
351
|
+
- Supported: ``PerRequestRedisBackend`` — what ``RedisBackendProvider`` and
|
|
352
|
+
therefore the env-resolved Redis path hand out.
|
|
353
|
+
- Not supported: ``RedisBackend`` constructed directly and passed as
|
|
354
|
+
``backend=``, ``FileBackend``, ``MemcachedBackend``, ``CachekitIOBackend``,
|
|
355
|
+
L1-only. These keep process-local invalidation: the calling process deletes
|
|
356
|
+
the keys it wrote or read itself.
|
|
357
|
+
|
|
358
|
+
Both methods are sync, like ``BaseBackend.get``/``set``; async callers run
|
|
359
|
+
them through ``asyncio.to_thread``.
|
|
360
|
+
|
|
361
|
+
Example:
|
|
362
|
+
>>> from cachekit.backends.base import KeyTrackableBackend
|
|
363
|
+
>>> from cachekit.backends.redis.provider import PerRequestRedisBackend
|
|
364
|
+
>>> from cachekit.backends.file import FileBackend
|
|
365
|
+
>>> issubclass(PerRequestRedisBackend, KeyTrackableBackend)
|
|
366
|
+
True
|
|
367
|
+
>>> issubclass(FileBackend, KeyTrackableBackend)
|
|
368
|
+
False
|
|
369
|
+
"""
|
|
370
|
+
|
|
371
|
+
def track_key(self, registry_id: str, key: str) -> None:
|
|
372
|
+
"""Add a cache key to the server-side tracking set for ``registry_id``.
|
|
373
|
+
|
|
374
|
+
``key`` is the raw cache key as the caller wrote it and is stored raw.
|
|
375
|
+
Implementations scope ``registry_id`` (tenant prefix) and apply the same
|
|
376
|
+
scoping to members only at drain time, so a member can never name a key
|
|
377
|
+
outside the implementation's own scope. Refreshes the set's TTL on every call.
|
|
378
|
+
|
|
379
|
+
Raises:
|
|
380
|
+
Exception: On any backend failure. Tracking is advisory: callers
|
|
381
|
+
swallow the error, and a key whose tracking failed is still
|
|
382
|
+
removed by the caller's next drain through ``local_keys``.
|
|
383
|
+
"""
|
|
384
|
+
...
|
|
385
|
+
|
|
386
|
+
def drain_tracked(self, registry_id: str, local_keys: Iterable[str]) -> set[str]:
|
|
387
|
+
"""Drain the tracking set in bounded atomic steps, deleting every member's key,
|
|
388
|
+
then delete every key in ``local_keys`` that no step popped.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
registry_id: Unscoped registry id of the decorated function.
|
|
392
|
+
local_keys: The caller's snapshot of the keys it knows it wrote or read.
|
|
393
|
+
Required: these are the keys the tracking set may be missing (a failed
|
|
394
|
+
``track_key``, a key written before tracking existed, a lapsed set),
|
|
395
|
+
and deleting them is what makes the drain complete.
|
|
396
|
+
|
|
397
|
+
Returns:
|
|
398
|
+
The raw keys deleted — decoded popped members plus all of ``local_keys`` —
|
|
399
|
+
for L1 eviction.
|
|
400
|
+
|
|
401
|
+
Raises:
|
|
402
|
+
Exception: If any command fails. The caller then falls back to deleting
|
|
403
|
+
its local keys one by one.
|
|
404
|
+
"""
|
|
405
|
+
...
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@runtime_checkable
|
|
409
|
+
class TimeoutConfigurableBackend(Protocol):
|
|
410
|
+
"""Optional protocol for per-operation timeout configuration.
|
|
411
|
+
|
|
412
|
+
Backends implementing this protocol allow fine-grained timeout control
|
|
413
|
+
per operation, so a caller can set an explicit timeout for a single
|
|
414
|
+
operation where the backend supports it.
|
|
415
|
+
|
|
416
|
+
All backends support some timeout mechanism, but granularity varies:
|
|
417
|
+
- Per-operation: HTTP, DynamoDB, PostgreSQL
|
|
418
|
+
- Per-socket/transaction: Redis, Memcached, SQLite
|
|
419
|
+
- Global: KV, S3
|
|
420
|
+
|
|
421
|
+
Example:
|
|
422
|
+
>>> # Per-operation timeout pattern (async context):
|
|
423
|
+
>>> # if hasattr(backend, 'with_timeout'):
|
|
424
|
+
>>> # async with backend.with_timeout("get", 100):
|
|
425
|
+
>>> # value = await backend.get("key")
|
|
426
|
+
"""
|
|
427
|
+
|
|
428
|
+
async def with_timeout(
|
|
429
|
+
self,
|
|
430
|
+
operation: str,
|
|
431
|
+
timeout_ms: int,
|
|
432
|
+
) -> AsyncIterator[None]:
|
|
433
|
+
"""Set timeout for operations within context.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
operation: Operation name (e.g., "get", "set", "delete")
|
|
437
|
+
timeout_ms: Timeout in milliseconds
|
|
438
|
+
|
|
439
|
+
Raises:
|
|
440
|
+
BackendError: With error_type=TIMEOUT if timeout exceeded
|
|
441
|
+
|
|
442
|
+
Example:
|
|
443
|
+
>>> async with backend.with_timeout("get", 100): # doctest: +SKIP
|
|
444
|
+
... value = await backend.get("key") # doctest: +SKIP
|
|
445
|
+
|
|
446
|
+
Note:
|
|
447
|
+
Backends without per-operation timeout may apply timeout at
|
|
448
|
+
socket or global level (coarser-grained fallback).
|
|
449
|
+
"""
|
|
450
|
+
...
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Base configuration for all backend implementations.
|
|
2
|
+
|
|
3
|
+
All backend configs MUST inherit from BaseBackendConfig to ensure
|
|
4
|
+
consistent behavior, validation, and environment variable handling.
|
|
5
|
+
|
|
6
|
+
Design rationale:
|
|
7
|
+
- Pydantic v2 model_config does NOT merge on inheritance (replaces)
|
|
8
|
+
- Child classes must explicitly spread parent config via inherit_config()
|
|
9
|
+
- This enables isinstance() checks and enforces patterns
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
>>> from cachekit.backends.base_config import BaseBackendConfig, inherit_config
|
|
13
|
+
>>> from pydantic_settings import SettingsConfigDict
|
|
14
|
+
>>>
|
|
15
|
+
>>> class MyBackendConfig(BaseBackendConfig):
|
|
16
|
+
... model_config = SettingsConfigDict(
|
|
17
|
+
... **inherit_config(BaseBackendConfig),
|
|
18
|
+
... env_prefix="CACHEKIT_MY_",
|
|
19
|
+
... )
|
|
20
|
+
... my_setting: str = "default"
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
28
|
+
|
|
29
|
+
from cachekit.config.validation import RedactingSettings
|
|
30
|
+
|
|
31
|
+
# Keys that child classes MUST override (not inherited from base)
|
|
32
|
+
_CHILD_OVERRIDE_KEYS = frozenset({"env_prefix"})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def inherit_config(base_cls: type[BaseSettings]) -> dict[str, Any]:
|
|
36
|
+
"""Extract inheritable model_config settings from a base class.
|
|
37
|
+
|
|
38
|
+
Filters out keys that child classes must explicitly set (like env_prefix).
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
base_cls: The base class to inherit config from
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Dict of model_config settings safe to spread into child class
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
>>> class ChildConfig(BaseBackendConfig):
|
|
48
|
+
... model_config = SettingsConfigDict(
|
|
49
|
+
... **inherit_config(BaseBackendConfig),
|
|
50
|
+
... env_prefix="MY_PREFIX_",
|
|
51
|
+
... )
|
|
52
|
+
"""
|
|
53
|
+
return {k: v for k, v in base_cls.model_config.items() if k not in _CHILD_OVERRIDE_KEYS}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class BaseBackendConfig(RedactingSettings):
|
|
57
|
+
"""Base class for all backend configurations.
|
|
58
|
+
|
|
59
|
+
Provides consistent settings for environment variable parsing,
|
|
60
|
+
validation strictness, and configuration patterns.
|
|
61
|
+
|
|
62
|
+
All backend configs MUST:
|
|
63
|
+
1. Inherit from this class
|
|
64
|
+
2. Spread model_config via inherit_config(): `**inherit_config(BaseBackendConfig)`
|
|
65
|
+
3. Add their own env_prefix
|
|
66
|
+
4. Implement from_env() classmethod
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
model_config: Standard pydantic-settings configuration with:
|
|
70
|
+
- env_nested_delimiter="__" for nested config support
|
|
71
|
+
- case_sensitive=False for env var flexibility
|
|
72
|
+
- extra="forbid" for strict validation (catch typos)
|
|
73
|
+
- populate_by_name=True for alias support
|
|
74
|
+
- hide_input_in_errors=True so no error text echoes a credential
|
|
75
|
+
|
|
76
|
+
Example:
|
|
77
|
+
>>> class MyBackendConfig(BaseBackendConfig):
|
|
78
|
+
... model_config = SettingsConfigDict(
|
|
79
|
+
... **inherit_config(BaseBackendConfig),
|
|
80
|
+
... env_prefix="CACHEKIT_MY_",
|
|
81
|
+
... )
|
|
82
|
+
... my_url: str = "http://localhost:8080"
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
model_config = SettingsConfigDict(
|
|
86
|
+
env_nested_delimiter="__",
|
|
87
|
+
case_sensitive=False,
|
|
88
|
+
extra="forbid",
|
|
89
|
+
populate_by_name=True,
|
|
90
|
+
# Backend configs hold credentials (an API key, a password in a Redis URL). RedactingSettings
|
|
91
|
+
# redacts errors()/json() on construction; this keeps str()/repr() input-free on every path.
|
|
92
|
+
hide_input_in_errors=True,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def from_env(cls) -> BaseBackendConfig:
|
|
97
|
+
"""Create configuration from environment variables.
|
|
98
|
+
|
|
99
|
+
Subclasses should override type hint but can reuse implementation.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Configuration instance loaded from environment
|
|
103
|
+
"""
|
|
104
|
+
return cls()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""cachekit.io backend implementation.
|
|
2
|
+
|
|
3
|
+
Provides cachekit.io storage backend implementing BaseBackend protocol.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from cachekit.backends.cachekitio.backend import CachekitIOBackend
|
|
7
|
+
from cachekit.backends.cachekitio.config import CachekitIOBackendConfig
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"CachekitIOBackend",
|
|
11
|
+
"CachekitIOBackendConfig",
|
|
12
|
+
]
|