fastapi-cachex 0.3.2__tar.gz → 0.3.4__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.
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/PKG-INFO +42 -2
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/README.md +41 -1
- fastapi_cachex-0.3.4/fastapi_cachex/backends/base.py +137 -0
- fastapi_cachex-0.3.4/fastapi_cachex/backends/codec.py +69 -0
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/backends/memcached.py +81 -45
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/backends/memory.py +98 -59
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/backends/redis.py +92 -165
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/cache.py +72 -70
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/manager.py +6 -12
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/routes.py +82 -120
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/session/manager.py +54 -54
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/state/manager.py +47 -82
- fastapi_cachex-0.3.4/fastapi_cachex/types.py +62 -0
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/pyproject.toml +3 -1
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/pyproject.toml.orig +3 -1
- fastapi_cachex-0.3.2/fastapi_cachex/backends/base.py +0 -70
- fastapi_cachex-0.3.2/fastapi_cachex/types.py +0 -34
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/__init__.py +0 -0
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/backends/__init__.py +0 -0
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/backends/config.py +0 -0
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/dependencies.py +0 -0
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/directives.py +0 -0
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/exceptions.py +0 -0
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/manager_proxy.py +0 -0
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/proxy.py +0 -0
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/py.typed +0 -0
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/session/__init__.py +0 -0
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/session/config.py +0 -0
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/session/dependencies.py +0 -0
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/session/exceptions.py +0 -0
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/session/middleware.py +0 -0
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/session/models.py +0 -0
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/session/proxy.py +0 -0
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/session/security.py +0 -0
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/session/token_serializers.py +0 -0
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/state/__init__.py +0 -0
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/state/dependencies.py +0 -0
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/state/exceptions.py +0 -0
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/state/models.py +0 -0
- {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/state/proxy.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-cachex
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: A caching library for FastAPI with support for Cache-Control, ETag, and multiple backends.
|
|
5
5
|
Keywords: fastapi,cache,etag,cache-control,redis,memcached,in-memory
|
|
6
6
|
Author: allen0099
|
|
@@ -181,7 +181,7 @@ cache and OAuth state, so `clear()`/`clear_prefix()` never touch unrelated cache
|
|
|
181
181
|
entries.
|
|
182
182
|
|
|
183
183
|
**Note**: `clear()`/`clear_prefix()` are implemented via the backend's
|
|
184
|
-
`get_all_keys()
|
|
184
|
+
`get_all_keys()` and `delete_many()` (one batched `DEL` on Redis). Since Memcached doesn't support key enumeration (see
|
|
185
185
|
[Memcached limitations](#memcached)), these two methods are no-ops on a
|
|
186
186
|
Memcached backend — `get()`/`set()`/`delete()`/`has()` work normally. Use
|
|
187
187
|
Redis or the in-memory backend if you need bulk clearing.
|
|
@@ -217,6 +217,41 @@ When a cached entry is valid (within TTL):
|
|
|
217
217
|
|
|
218
218
|
This means **cached hits are extremely fast** - the endpoint handler function is never executed.
|
|
219
219
|
|
|
220
|
+
### Atomic backend primitives
|
|
221
|
+
|
|
222
|
+
Every backend exposes two atomic operations on top of `get`/`set`/`delete`, for
|
|
223
|
+
values that are read and written by many concurrent requests:
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
from fastapi_cachex import BackendProxy
|
|
227
|
+
|
|
228
|
+
backend = BackendProxy.get()
|
|
229
|
+
|
|
230
|
+
# Fixed-window counter: created on first use, `ttl` applies only then.
|
|
231
|
+
hits = await backend.increment(f"resend:{user_id}", ttl=86400)
|
|
232
|
+
if hits > 3:
|
|
233
|
+
raise TooManyRequests()
|
|
234
|
+
|
|
235
|
+
# One-shot value: of several concurrent callers exactly one gets the entry.
|
|
236
|
+
grant = await backend.get_and_delete(f"grant:{token}")
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
- `increment(key, delta=1, ttl=None) -> int` — Memory does the read-modify-write
|
|
240
|
+
under its lock, Redis runs a Lua script (`EXISTS` + `INCRBY` + `EXPIRE`) and
|
|
241
|
+
Memcached uses `ADD` + `INCR`/`DECR` (Memcached counters stop at 0). The
|
|
242
|
+
counter is visible through `get()` as a `CacheEntry` with fingerprint
|
|
243
|
+
`COUNTER_FINGERPRINT` and the decimal value as content, so `delete`/`clear*`
|
|
244
|
+
and the monitoring routes treat it like any other entry. Incrementing a key
|
|
245
|
+
that holds a cached response raises `CacheXError`.
|
|
246
|
+
- `get_and_delete(key) -> CacheEntry | None` — Memory pops under its lock, Redis
|
|
247
|
+
uses `GETDEL` (server 6.2+) and Memcached returns the value only when its own
|
|
248
|
+
`DELETE` won. `StateManager.consume_state`, `CacheManager.delete` and
|
|
249
|
+
`invalidate()` are built on it.
|
|
250
|
+
|
|
251
|
+
Both have a non-atomic fallback on `BaseCacheBackend`, so a third-party backend
|
|
252
|
+
that only implements the abstract methods keeps working; override them to get
|
|
253
|
+
real atomicity.
|
|
254
|
+
|
|
220
255
|
### In-Memory Cache (default)
|
|
221
256
|
|
|
222
257
|
If you don't specify a backend, FastAPI-CacheX will use the in-memory cache by default.
|
|
@@ -249,6 +284,11 @@ BackendProxy.set(backend)
|
|
|
249
284
|
- Keys are namespaced with `fastapi_cachex:` prefix to avoid conflicts
|
|
250
285
|
- Consider using Redis backend if you need pattern-based cache clearing
|
|
251
286
|
|
|
287
|
+
The synchronous pymemcache client runs in worker threads and is connection-pooled,
|
|
288
|
+
so concurrent requests never share a socket. Writes wait for the server's
|
|
289
|
+
acknowledgement (`default_noreply=False`), which keeps a value readable from
|
|
290
|
+
any pooled connection as soon as `set()` returns.
|
|
291
|
+
|
|
252
292
|
### Redis
|
|
253
293
|
|
|
254
294
|
```python
|
|
@@ -143,7 +143,7 @@ cache and OAuth state, so `clear()`/`clear_prefix()` never touch unrelated cache
|
|
|
143
143
|
entries.
|
|
144
144
|
|
|
145
145
|
**Note**: `clear()`/`clear_prefix()` are implemented via the backend's
|
|
146
|
-
`get_all_keys()
|
|
146
|
+
`get_all_keys()` and `delete_many()` (one batched `DEL` on Redis). Since Memcached doesn't support key enumeration (see
|
|
147
147
|
[Memcached limitations](#memcached)), these two methods are no-ops on a
|
|
148
148
|
Memcached backend — `get()`/`set()`/`delete()`/`has()` work normally. Use
|
|
149
149
|
Redis or the in-memory backend if you need bulk clearing.
|
|
@@ -179,6 +179,41 @@ When a cached entry is valid (within TTL):
|
|
|
179
179
|
|
|
180
180
|
This means **cached hits are extremely fast** - the endpoint handler function is never executed.
|
|
181
181
|
|
|
182
|
+
### Atomic backend primitives
|
|
183
|
+
|
|
184
|
+
Every backend exposes two atomic operations on top of `get`/`set`/`delete`, for
|
|
185
|
+
values that are read and written by many concurrent requests:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from fastapi_cachex import BackendProxy
|
|
189
|
+
|
|
190
|
+
backend = BackendProxy.get()
|
|
191
|
+
|
|
192
|
+
# Fixed-window counter: created on first use, `ttl` applies only then.
|
|
193
|
+
hits = await backend.increment(f"resend:{user_id}", ttl=86400)
|
|
194
|
+
if hits > 3:
|
|
195
|
+
raise TooManyRequests()
|
|
196
|
+
|
|
197
|
+
# One-shot value: of several concurrent callers exactly one gets the entry.
|
|
198
|
+
grant = await backend.get_and_delete(f"grant:{token}")
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
- `increment(key, delta=1, ttl=None) -> int` — Memory does the read-modify-write
|
|
202
|
+
under its lock, Redis runs a Lua script (`EXISTS` + `INCRBY` + `EXPIRE`) and
|
|
203
|
+
Memcached uses `ADD` + `INCR`/`DECR` (Memcached counters stop at 0). The
|
|
204
|
+
counter is visible through `get()` as a `CacheEntry` with fingerprint
|
|
205
|
+
`COUNTER_FINGERPRINT` and the decimal value as content, so `delete`/`clear*`
|
|
206
|
+
and the monitoring routes treat it like any other entry. Incrementing a key
|
|
207
|
+
that holds a cached response raises `CacheXError`.
|
|
208
|
+
- `get_and_delete(key) -> CacheEntry | None` — Memory pops under its lock, Redis
|
|
209
|
+
uses `GETDEL` (server 6.2+) and Memcached returns the value only when its own
|
|
210
|
+
`DELETE` won. `StateManager.consume_state`, `CacheManager.delete` and
|
|
211
|
+
`invalidate()` are built on it.
|
|
212
|
+
|
|
213
|
+
Both have a non-atomic fallback on `BaseCacheBackend`, so a third-party backend
|
|
214
|
+
that only implements the abstract methods keeps working; override them to get
|
|
215
|
+
real atomicity.
|
|
216
|
+
|
|
182
217
|
### In-Memory Cache (default)
|
|
183
218
|
|
|
184
219
|
If you don't specify a backend, FastAPI-CacheX will use the in-memory cache by default.
|
|
@@ -211,6 +246,11 @@ BackendProxy.set(backend)
|
|
|
211
246
|
- Keys are namespaced with `fastapi_cachex:` prefix to avoid conflicts
|
|
212
247
|
- Consider using Redis backend if you need pattern-based cache clearing
|
|
213
248
|
|
|
249
|
+
The synchronous pymemcache client runs in worker threads and is connection-pooled,
|
|
250
|
+
so concurrent requests never share a socket. Writes wait for the server's
|
|
251
|
+
acknowledgement (`default_noreply=False`), which keeps a value readable from
|
|
252
|
+
any pooled connection as soon as `set()` returns.
|
|
253
|
+
|
|
214
254
|
### Redis
|
|
215
255
|
|
|
216
256
|
```python
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Base cache backend interface and abstract implementation."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC
|
|
4
|
+
from abc import abstractmethod
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi_cachex.types import CacheEntry
|
|
9
|
+
from fastapi_cachex.types import counter_entry
|
|
10
|
+
from fastapi_cachex.types import counter_value
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BaseCacheBackend(ABC):
|
|
14
|
+
"""Base class for all cache backends."""
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
async def get(self, key: str) -> CacheEntry | None:
|
|
18
|
+
"""Retrieve a cached response."""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None:
|
|
22
|
+
"""Store a response in the cache."""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
async def delete(self, key: str) -> None:
|
|
26
|
+
"""Remove a response from the cache."""
|
|
27
|
+
|
|
28
|
+
async def delete_many(self, keys: Iterable[str]) -> int:
|
|
29
|
+
"""Remove every key in ``keys``; returns how many were removed.
|
|
30
|
+
|
|
31
|
+
The base implementation deletes one key at a time and reports how
|
|
32
|
+
many were attempted, since ``delete`` does not say whether the key
|
|
33
|
+
existed. The built-in backends override it with a single batched
|
|
34
|
+
operation that counts what was actually removed.
|
|
35
|
+
"""
|
|
36
|
+
count = 0
|
|
37
|
+
for key in keys:
|
|
38
|
+
await self.delete(key)
|
|
39
|
+
count += 1
|
|
40
|
+
return count
|
|
41
|
+
|
|
42
|
+
async def get_and_delete(self, key: str) -> CacheEntry | None:
|
|
43
|
+
"""Atomically retrieve and remove a cached entry.
|
|
44
|
+
|
|
45
|
+
Use this for one-shot values (OAuth states, grants, invalidation) where
|
|
46
|
+
exactly one of several concurrent callers may win: every other caller
|
|
47
|
+
sees ``None``.
|
|
48
|
+
|
|
49
|
+
The base implementation is a best-effort, NON-atomic get-then-delete
|
|
50
|
+
fallback for third-party backends; the built-in backends override it
|
|
51
|
+
with an atomic implementation.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The entry that was stored under ``key``, or ``None`` if there was none
|
|
55
|
+
"""
|
|
56
|
+
value = await self.get(key)
|
|
57
|
+
if value is not None:
|
|
58
|
+
await self.delete(key)
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
async def increment(self, key: str, delta: int = 1, ttl: int | None = None) -> int:
|
|
62
|
+
"""Atomically add ``delta`` to the integer counter stored at ``key``.
|
|
63
|
+
|
|
64
|
+
A missing key counts as 0: the first call creates the counter with the
|
|
65
|
+
value ``delta`` and applies ``ttl`` (seconds; ``None`` = never expires).
|
|
66
|
+
Later calls keep the existing expiry, so the counter lives in a fixed
|
|
67
|
+
window that starts when it is created - the shape rate limiters need.
|
|
68
|
+
The counter is readable through ``get()`` as a ``CacheEntry`` whose
|
|
69
|
+
fingerprint is ``COUNTER_FINGERPRINT`` and whose content is the decimal
|
|
70
|
+
value; ``delete``/``clear*`` treat it like any other entry.
|
|
71
|
+
|
|
72
|
+
The base implementation is a best-effort, NON-atomic read-modify-write
|
|
73
|
+
fallback for third-party backends and re-applies ``ttl`` on every call.
|
|
74
|
+
The built-in backends override it with a single server-side operation.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
key: Cache key of the counter
|
|
78
|
+
delta: Amount to add (may be negative)
|
|
79
|
+
ttl: Time to live in seconds, applied when the counter is created
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The counter value after the increment
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
CacheXError: If ``key`` holds a cached response instead of a counter
|
|
86
|
+
"""
|
|
87
|
+
current = await self.get(key)
|
|
88
|
+
value = delta if current is None else counter_value(current) + delta
|
|
89
|
+
await self.set(key, counter_entry(value), ttl=ttl)
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
async def clear(self) -> None:
|
|
94
|
+
"""Clear all cached responses."""
|
|
95
|
+
|
|
96
|
+
@abstractmethod
|
|
97
|
+
async def clear_path(self, path: str, include_params: bool = False) -> int:
|
|
98
|
+
"""Clear cached responses for a specific path.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
path: The path to clear cache for
|
|
102
|
+
include_params: Whether to clear all parameter variations of the path
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Number of cache entries cleared
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
@abstractmethod
|
|
109
|
+
async def clear_pattern(self, pattern: str) -> int:
|
|
110
|
+
"""Clear cached responses matching a pattern.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
pattern: A glob pattern to match cache keys against (e.g., "/users/*")
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Number of cache entries cleared
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
@abstractmethod
|
|
120
|
+
async def get_all_keys(self) -> list[str]:
|
|
121
|
+
"""Get all cache keys in the backend.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
List of all cache keys currently stored in the backend
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
@abstractmethod
|
|
128
|
+
async def get_cache_data(self) -> dict[str, tuple[Any, float | None]]:
|
|
129
|
+
"""Get all cache data with expiry information.
|
|
130
|
+
|
|
131
|
+
This method is primarily used for cache monitoring and statistics.
|
|
132
|
+
Returns cache keys mapped to tuples of (value, expiry_time).
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Dictionary mapping cache keys to (value, expiry) tuples.
|
|
136
|
+
Expiry is None if the item never expires.
|
|
137
|
+
"""
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Serialization shared by the network backends (Redis, Memcached).
|
|
2
|
+
|
|
3
|
+
Both backends store a ``CacheEntry`` as a JSON document; ``orjson`` is used when
|
|
4
|
+
it is installed and the standard library ``json`` module otherwise.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from fastapi_cachex.types import CacheEntry
|
|
8
|
+
from fastapi_cachex.types import counter_entry
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import orjson as json
|
|
12
|
+
|
|
13
|
+
except ImportError: # pragma: no cover
|
|
14
|
+
import json # type: ignore[no-redef] # pragma: no cover
|
|
15
|
+
|
|
16
|
+
# ``json.loads`` (either implementation) raises ``ValueError`` subclasses for bad
|
|
17
|
+
# JSON; ``KeyError``/``TypeError``/``AttributeError`` cover documents whose shape
|
|
18
|
+
# is not the one ``encode_entry`` writes (missing fields, non-string content).
|
|
19
|
+
_DECODE_ERRORS = (ValueError, KeyError, TypeError, AttributeError)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def encode_entry(entry: CacheEntry) -> bytes:
|
|
23
|
+
"""Serialize a ``CacheEntry`` to a UTF-8 JSON document.
|
|
24
|
+
|
|
25
|
+
The raw content bytes are passed through ``latin-1`` so that arbitrary
|
|
26
|
+
bytes round-trip through JSON text.
|
|
27
|
+
"""
|
|
28
|
+
serialized: str | bytes = json.dumps(
|
|
29
|
+
{
|
|
30
|
+
"fingerprint": entry.fingerprint,
|
|
31
|
+
"content": entry.content.decode("latin-1"),
|
|
32
|
+
"media_type": entry.media_type,
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
# orjson returns bytes, stdlib json returns str
|
|
36
|
+
return serialized if isinstance(serialized, bytes) else serialized.encode("utf-8")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _as_counter(raw: str | bytes) -> int | None:
|
|
40
|
+
"""The integer a bare counter value holds, or ``None`` for anything else."""
|
|
41
|
+
try:
|
|
42
|
+
return int(raw)
|
|
43
|
+
except ValueError:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def decode_entry(raw: str | bytes | None) -> CacheEntry | None:
|
|
48
|
+
"""Rebuild a ``CacheEntry`` from a stored value.
|
|
49
|
+
|
|
50
|
+
A bare integer (what the server-side ``INCR`` family leaves behind for
|
|
51
|
+
``increment``) becomes a counter entry. Anything else that is not a document
|
|
52
|
+
written by ``encode_entry`` (corrupt JSON, missing fields, non-string
|
|
53
|
+
content) yields ``None``, so callers can treat every malformed value as a
|
|
54
|
+
cache miss.
|
|
55
|
+
"""
|
|
56
|
+
if raw is None:
|
|
57
|
+
return None
|
|
58
|
+
counter = _as_counter(raw)
|
|
59
|
+
if counter is not None:
|
|
60
|
+
return counter_entry(counter)
|
|
61
|
+
try:
|
|
62
|
+
data = json.loads(raw)
|
|
63
|
+
return CacheEntry(
|
|
64
|
+
fingerprint=data["fingerprint"],
|
|
65
|
+
content=data["content"].encode("latin-1"),
|
|
66
|
+
media_type=data.get("media_type"),
|
|
67
|
+
)
|
|
68
|
+
except _DECODE_ERRORS:
|
|
69
|
+
return None
|
|
@@ -4,17 +4,13 @@ import asyncio
|
|
|
4
4
|
import logging
|
|
5
5
|
import warnings
|
|
6
6
|
|
|
7
|
+
from fastapi_cachex.backends.codec import decode_entry
|
|
8
|
+
from fastapi_cachex.backends.codec import encode_entry
|
|
7
9
|
from fastapi_cachex.exceptions import CacheXError
|
|
8
10
|
from fastapi_cachex.types import CacheEntry
|
|
9
11
|
|
|
10
12
|
from .base import BaseCacheBackend
|
|
11
13
|
|
|
12
|
-
try:
|
|
13
|
-
import orjson as json
|
|
14
|
-
|
|
15
|
-
except ImportError: # pragma: no cover
|
|
16
|
-
import json # type: ignore[no-redef] # pragma: no cover
|
|
17
|
-
|
|
18
14
|
logger = logging.getLogger(__name__)
|
|
19
15
|
|
|
20
16
|
# Default Memcached key prefix for fastapi-cachex
|
|
@@ -24,10 +20,11 @@ DEFAULT_MEMCACHE_PREFIX = "fastapi_cachex:"
|
|
|
24
20
|
class MemcachedBackend(BaseCacheBackend):
|
|
25
21
|
"""Memcached backend implementation.
|
|
26
22
|
|
|
27
|
-
Note: This implementation uses synchronous pymemcache client
|
|
28
|
-
in
|
|
29
|
-
|
|
30
|
-
by default to avoid
|
|
23
|
+
Note: This implementation uses the synchronous pymemcache client and runs
|
|
24
|
+
each call in a worker thread. The client is connection-pooled so concurrent
|
|
25
|
+
calls never share a socket. For true async Memcached operations consider
|
|
26
|
+
aiomcache. Keys are namespaced with 'fastapi_cachex:' by default to avoid
|
|
27
|
+
conflicts with other applications.
|
|
31
28
|
|
|
32
29
|
Limitations:
|
|
33
30
|
- Pattern-based clearing (clear_pattern) is not supported by Memcached protocol
|
|
@@ -56,7 +53,17 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
56
53
|
msg = "pymemcache is not installed. Please install it with 'pip install pymemcache'"
|
|
57
54
|
raise CacheXError(msg)
|
|
58
55
|
|
|
59
|
-
|
|
56
|
+
# Pooled connections have no ordering guarantee between each other, so
|
|
57
|
+
# every write waits for the server's acknowledgement; otherwise a
|
|
58
|
+
# ``set`` on one socket may still be in flight when a ``get`` on another
|
|
59
|
+
# socket is served, and the caller would miss its own write.
|
|
60
|
+
self.client = HashClient(
|
|
61
|
+
servers,
|
|
62
|
+
connect_timeout=5,
|
|
63
|
+
timeout=5,
|
|
64
|
+
use_pooling=True,
|
|
65
|
+
default_noreply=False,
|
|
66
|
+
)
|
|
60
67
|
self.key_prefix = key_prefix
|
|
61
68
|
|
|
62
69
|
def _make_key(self, key: str) -> str:
|
|
@@ -72,24 +79,15 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
72
79
|
Returns:
|
|
73
80
|
Cached entry if found, None otherwise
|
|
74
81
|
"""
|
|
75
|
-
|
|
76
|
-
value =
|
|
77
|
-
if
|
|
82
|
+
raw = await asyncio.to_thread(self.client.get, self._make_key(key))
|
|
83
|
+
value = decode_entry(raw)
|
|
84
|
+
if raw is None:
|
|
78
85
|
logger.debug("Memcached MISS; key=%s", key)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
# Memcached stores data as bytes; deserialize from JSON
|
|
82
|
-
try:
|
|
83
|
-
data = json.loads(value)
|
|
84
|
-
logger.debug("Memcached HIT; key=%s", key)
|
|
85
|
-
return CacheEntry(
|
|
86
|
-
fingerprint=data["fingerprint"],
|
|
87
|
-
content=data["content"].encode("latin-1"),
|
|
88
|
-
media_type=data.get("media_type"),
|
|
89
|
-
)
|
|
90
|
-
except (json.JSONDecodeError, KeyError, ValueError):
|
|
86
|
+
elif value is None:
|
|
91
87
|
logger.debug("Memcached DESERIALIZE ERROR; key=%s", key)
|
|
92
|
-
|
|
88
|
+
else:
|
|
89
|
+
logger.debug("Memcached HIT; key=%s", key)
|
|
90
|
+
return value
|
|
93
91
|
|
|
94
92
|
async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None:
|
|
95
93
|
"""Set value in cache.
|
|
@@ -99,29 +97,67 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
99
97
|
value: CacheEntry instance to store
|
|
100
98
|
ttl: Time to live in seconds
|
|
101
99
|
"""
|
|
102
|
-
|
|
100
|
+
expire = ttl if ttl is not None else 0
|
|
101
|
+
await asyncio.to_thread(
|
|
102
|
+
self.client.set, self._make_key(key), encode_entry(value), expire
|
|
103
|
+
)
|
|
104
|
+
logger.debug("Memcached SET; key=%s ttl=%s", key, ttl)
|
|
103
105
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
+
async def get_and_delete(self, key: str) -> CacheEntry | None:
|
|
107
|
+
"""Atomically retrieve and remove a cached entry (see base class).
|
|
106
108
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
109
|
+
Memcached has no combined primitive, but DELETE is atomic: the value is
|
|
110
|
+
returned only when this call is the one that removed it, so exactly one
|
|
111
|
+
concurrent caller wins.
|
|
112
|
+
"""
|
|
113
|
+
prefixed_key = self._make_key(key)
|
|
114
|
+
raw = await asyncio.to_thread(self.client.get, prefixed_key)
|
|
115
|
+
if raw is None:
|
|
116
|
+
logger.debug("Memcached GET_AND_DELETE MISS; key=%s", key)
|
|
117
|
+
return None
|
|
118
|
+
deleted = await asyncio.to_thread(
|
|
119
|
+
self.client.delete, prefixed_key, noreply=False
|
|
113
120
|
)
|
|
121
|
+
if not deleted:
|
|
122
|
+
logger.debug("Memcached GET_AND_DELETE LOST RACE; key=%s", key)
|
|
123
|
+
return None
|
|
124
|
+
logger.debug("Memcached GET_AND_DELETE HIT; key=%s", key)
|
|
125
|
+
return decode_entry(raw)
|
|
114
126
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
127
|
+
def _add_delta(self, prefixed_key: str, delta: int) -> int | None:
|
|
128
|
+
"""Apply ``delta`` with INCR/DECR; ``None`` when the key does not exist."""
|
|
129
|
+
if delta < 0:
|
|
130
|
+
result = self.client.decr(prefixed_key, -delta, noreply=False)
|
|
131
|
+
else:
|
|
132
|
+
result = self.client.incr(prefixed_key, delta, noreply=False)
|
|
133
|
+
return None if result is None else int(result)
|
|
121
134
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
135
|
+
async def increment(self, key: str, delta: int = 1, ttl: int | None = None) -> int:
|
|
136
|
+
"""Atomically add ``delta`` to the counter at ``key`` (see base class).
|
|
137
|
+
|
|
138
|
+
Memcached counters are unsigned, so a negative ``delta`` uses DECR,
|
|
139
|
+
which stops at 0 instead of going negative.
|
|
140
|
+
"""
|
|
141
|
+
from pymemcache.exceptions import MemcacheClientError
|
|
142
|
+
|
|
143
|
+
prefixed_key = self._make_key(key)
|
|
144
|
+
try:
|
|
145
|
+
value = await asyncio.to_thread(self._add_delta, prefixed_key, delta)
|
|
146
|
+
if value is None:
|
|
147
|
+
# No counter yet: ADD is atomic and a no-op when a concurrent
|
|
148
|
+
# call created it first, so the retry always finds a counter.
|
|
149
|
+
await asyncio.to_thread(
|
|
150
|
+
self.client.add, prefixed_key, b"0", ttl or 0, noreply=False
|
|
151
|
+
)
|
|
152
|
+
value = await asyncio.to_thread(self._add_delta, prefixed_key, delta)
|
|
153
|
+
except MemcacheClientError as e:
|
|
154
|
+
msg = "Cache key holds a value that is not a counter"
|
|
155
|
+
raise CacheXError(msg) from e
|
|
156
|
+
if value is None: # pragma: no cover - the counter expired mid-call
|
|
157
|
+
msg = "Counter vanished between ADD and INCR"
|
|
158
|
+
raise CacheXError(msg)
|
|
159
|
+
logger.debug("Memcached INCREMENT; key=%s value=%s ttl=%s", key, value, ttl)
|
|
160
|
+
return value
|
|
125
161
|
|
|
126
162
|
async def delete(self, key: str) -> None:
|
|
127
163
|
"""Delete value from cache.
|