fastapi-cachex 0.5.2__tar.gz → 0.5.3__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.5.2 → fastapi_cachex-0.5.3}/PKG-INFO +2 -2
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/_stored_response.py +3 -1
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/backends/base.py +27 -4
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/backends/codec.py +25 -7
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/backends/memcached.py +114 -76
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/backends/memory.py +9 -2
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/backends/redis.py +71 -77
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/cache.py +557 -495
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/cache_key.py +5 -3
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/lock.py +14 -3
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/manager.py +17 -9
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/pyproject.toml +4 -16
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/pyproject.toml.orig +6 -14
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/LICENSE +0 -0
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/README.md +0 -0
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/__init__.py +0 -0
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/_cache_control.py +0 -0
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/_callables.py +0 -0
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/_coalesce.py +0 -0
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/_key_builders.py +0 -0
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/_rendering.py +0 -0
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/_vary.py +0 -0
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/_warnings.py +0 -0
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/backends/__init__.py +0 -0
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/backends/_unavailable.py +0 -0
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/backends/config.py +0 -0
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/cached.py +0 -0
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/dependencies.py +0 -0
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/directives.py +0 -0
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/exceptions.py +0 -0
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/headers.py +0 -0
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/manager_proxy.py +0 -0
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/proxy.py +0 -0
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/py.typed +0 -0
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/routes.py +0 -0
- {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/types.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-cachex
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.3
|
|
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
|
|
7
7
|
Author-email: allen0099 <s96016641@gmail.com>
|
|
8
8
|
License-Expression: Apache-2.0
|
|
9
9
|
License-File: LICENSE
|
|
10
|
-
Classifier: Development Status ::
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
11
|
Classifier: Intended Audience :: Developers
|
|
12
12
|
Classifier: Programming Language :: Python
|
|
13
13
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -121,7 +121,9 @@ def _get_response_body(response: Response) -> bytes | None:
|
|
|
121
121
|
|
|
122
122
|
|
|
123
123
|
def _etag_for(body: bytes) -> str:
|
|
124
|
-
|
|
124
|
+
# A validator, not a security control: say so, or a FIPS-mode OpenSSL
|
|
125
|
+
# refuses MD5 and every render fails (#496).
|
|
126
|
+
return f'W/"{hashlib.md5(body, usedforsecurity=False).hexdigest()}"'
|
|
125
127
|
|
|
126
128
|
|
|
127
129
|
def _weak_etag(etag: str) -> str:
|
|
@@ -7,7 +7,6 @@ from collections.abc import Iterable
|
|
|
7
7
|
from datetime import timedelta
|
|
8
8
|
from types import TracebackType
|
|
9
9
|
from typing import TYPE_CHECKING
|
|
10
|
-
from typing import Any
|
|
11
10
|
from typing import overload
|
|
12
11
|
|
|
13
12
|
from fastapi_cachex.exceptions import CacheXError
|
|
@@ -102,6 +101,24 @@ def validate_ttl(ttl: int | timedelta | None) -> int | None:
|
|
|
102
101
|
return ttl
|
|
103
102
|
|
|
104
103
|
|
|
104
|
+
def require_ttl(ttl: int | timedelta) -> int:
|
|
105
|
+
"""``validate_ttl`` for a TTL that must be given, such as a lock lease.
|
|
106
|
+
|
|
107
|
+
``expire_if_equals`` renews a lease; "no expiry" there would make a lock
|
|
108
|
+
permanent, and each backend used to read ``None`` its own way (#499).
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
TTLTypeError: If ``ttl`` is ``None``, or anything ``validate_ttl``
|
|
112
|
+
refuses with it
|
|
113
|
+
TTLValueError: As ``validate_ttl``
|
|
114
|
+
"""
|
|
115
|
+
if ttl is None:
|
|
116
|
+
# Unreachable for type checkers; guards untyped callers.
|
|
117
|
+
msg = "ttl must be an int number of seconds or a timedelta, got None" # type: ignore[unreachable]
|
|
118
|
+
raise TTLTypeError(msg)
|
|
119
|
+
return validate_ttl(ttl)
|
|
120
|
+
|
|
121
|
+
|
|
105
122
|
def _whole_seconds(value: timedelta, name: str) -> int:
|
|
106
123
|
"""The whole number of seconds in ``value``.
|
|
107
124
|
|
|
@@ -305,12 +322,18 @@ class BaseCacheBackend(ABC):
|
|
|
305
322
|
Args:
|
|
306
323
|
key: Cache key to update expiry for
|
|
307
324
|
expected: The entry the caller stored (compared with ``==``)
|
|
308
|
-
ttl: Time to live in seconds
|
|
325
|
+
ttl: Time to live in seconds or as a ``timedelta``; required
|
|
309
326
|
|
|
310
327
|
Returns:
|
|
311
328
|
Whether the expiry was updated
|
|
329
|
+
|
|
330
|
+
Raises:
|
|
331
|
+
TTLTypeError: If ``ttl`` is ``None`` or not an ``int`` or a
|
|
332
|
+
``timedelta``
|
|
333
|
+
TTLValueError: If ``ttl`` is zero, negative or larger than
|
|
334
|
+
``MAX_TTL``
|
|
312
335
|
"""
|
|
313
|
-
ttl =
|
|
336
|
+
ttl = require_ttl(ttl)
|
|
314
337
|
if await self.get(key) != expected:
|
|
315
338
|
return False
|
|
316
339
|
await self.set(key, expected, ttl=ttl)
|
|
@@ -442,7 +465,7 @@ class BaseCacheBackend(ABC):
|
|
|
442
465
|
"""
|
|
443
466
|
|
|
444
467
|
@abstractmethod
|
|
445
|
-
async def get_cache_data(self) -> dict[str, tuple[
|
|
468
|
+
async def get_cache_data(self) -> dict[str, tuple[CacheEntry, float | None]]:
|
|
446
469
|
"""Get all cache data with expiry information.
|
|
447
470
|
|
|
448
471
|
This method is primarily used for cache monitoring and statistics.
|
|
@@ -22,8 +22,10 @@ except ImportError: # pragma: no cover - import-time, environment dependent
|
|
|
22
22
|
|
|
23
23
|
# ``json.loads`` (either implementation) raises ``ValueError`` subclasses for bad
|
|
24
24
|
# JSON; ``KeyError``/``TypeError``/``AttributeError`` cover documents whose shape
|
|
25
|
-
# is not the one ``encode_entry`` writes (missing fields, non-string content)
|
|
26
|
-
|
|
25
|
+
# is not the one ``encode_entry`` writes (missing fields, non-string content);
|
|
26
|
+
# fields of another type are checked in ``decode_entry``.
|
|
27
|
+
# The standard library parser raises ``RecursionError`` on deep nesting.
|
|
28
|
+
_DECODE_ERRORS = (ValueError, KeyError, TypeError, AttributeError, RecursionError)
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
def encode_entry(entry: CacheEntry) -> bytes:
|
|
@@ -54,6 +56,11 @@ def encode_entry(entry: CacheEntry) -> bytes:
|
|
|
54
56
|
return serialized if isinstance(serialized, bytes) else serialized.encode("utf-8")
|
|
55
57
|
|
|
56
58
|
|
|
59
|
+
# The status codes HTTP defines; anything else cannot be replayed.
|
|
60
|
+
_MIN_STATUS = 100
|
|
61
|
+
_MAX_STATUS = 599
|
|
62
|
+
|
|
63
|
+
|
|
57
64
|
def _stored_at(value: object) -> float | None:
|
|
58
65
|
"""``stored_at`` from a document; anything but a finite number is unknown."""
|
|
59
66
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
@@ -66,8 +73,8 @@ def decode_entry(raw: str | bytes | None) -> CacheEntry | None:
|
|
|
66
73
|
|
|
67
74
|
A bare integer (what the server-side ``INCR`` family leaves behind for
|
|
68
75
|
``increment``) becomes a counter entry. Anything else that is not a document
|
|
69
|
-
written by ``encode_entry`` (corrupt JSON, missing fields,
|
|
70
|
-
|
|
76
|
+
written by ``encode_entry`` (corrupt JSON, missing fields, a field of the
|
|
77
|
+
wrong type, a status code outside 100-599) yields ``None``, so callers can treat every malformed value as a
|
|
71
78
|
cache miss.
|
|
72
79
|
|
|
73
80
|
Documents written before entries carried a status code and headers simply
|
|
@@ -83,11 +90,22 @@ def decode_entry(raw: str | bytes | None) -> CacheEntry | None:
|
|
|
83
90
|
return counter_entry(counter)
|
|
84
91
|
try:
|
|
85
92
|
data = json.loads(raw)
|
|
93
|
+
fingerprint = data["fingerprint"]
|
|
94
|
+
media_type = data.get("media_type")
|
|
95
|
+
status_code = data.get("status_code", DEFAULT_STATUS_CODE)
|
|
96
|
+
if (
|
|
97
|
+
not isinstance(fingerprint, str)
|
|
98
|
+
or not isinstance(media_type, (str, type(None)))
|
|
99
|
+
or isinstance(status_code, bool)
|
|
100
|
+
or not isinstance(status_code, int)
|
|
101
|
+
or not _MIN_STATUS <= status_code <= _MAX_STATUS
|
|
102
|
+
):
|
|
103
|
+
return None
|
|
86
104
|
return CacheEntry(
|
|
87
|
-
fingerprint=
|
|
105
|
+
fingerprint=fingerprint,
|
|
88
106
|
content=data["content"].encode("latin-1"),
|
|
89
|
-
media_type=
|
|
90
|
-
status_code=
|
|
107
|
+
media_type=media_type,
|
|
108
|
+
status_code=status_code,
|
|
91
109
|
headers=data.get("headers"),
|
|
92
110
|
stored_at=_stored_at(data.get("stored_at")),
|
|
93
111
|
)
|
|
@@ -5,6 +5,7 @@ import hashlib
|
|
|
5
5
|
import logging
|
|
6
6
|
import time
|
|
7
7
|
import warnings
|
|
8
|
+
from collections.abc import Callable
|
|
8
9
|
from collections.abc import Iterable
|
|
9
10
|
from datetime import timedelta
|
|
10
11
|
|
|
@@ -14,9 +15,11 @@ from fastapi_cachex.backends.codec import encode_entry
|
|
|
14
15
|
from fastapi_cachex.exceptions import CacheXError
|
|
15
16
|
from fastapi_cachex.exceptions import TTLValueError
|
|
16
17
|
from fastapi_cachex.types import CacheEntry
|
|
18
|
+
from fastapi_cachex.types import log_ref
|
|
17
19
|
|
|
18
20
|
from ._unavailable import translate_unavailable
|
|
19
21
|
from .base import BaseCacheBackend
|
|
22
|
+
from .base import require_ttl
|
|
20
23
|
from .base import validate_delta
|
|
21
24
|
from .base import validate_ttl
|
|
22
25
|
|
|
@@ -57,6 +60,32 @@ _LEGAL_KEY_BYTES = frozenset(range(0x21, 0x7F))
|
|
|
57
60
|
_MAX_PREFIX_BYTES = _MAX_KEY_BYTES - 64
|
|
58
61
|
|
|
59
62
|
|
|
63
|
+
# What `connect_timeout` and `timeout` are while they are not passed. 0.6.0
|
|
64
|
+
# makes it 1 second, as on the Redis backend (#516).
|
|
65
|
+
_DEFAULT_TIMEOUT = 5.0
|
|
66
|
+
|
|
67
|
+
_TIMEOUT_DEFAULT_NOTICE = (
|
|
68
|
+
"MemcachedBackend timeouts will default to 1 second in fastapi-cachex "
|
|
69
|
+
"0.6.0; they default to 5 seconds now. Pass connect_timeout and timeout "
|
|
70
|
+
"to choose the values and silence this warning; see "
|
|
71
|
+
"https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_6/#memcached-timeouts"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _validate_timeout(name: str, value: float) -> None:
|
|
76
|
+
"""Reject a timeout that is not a positive, finite number of seconds.
|
|
77
|
+
|
|
78
|
+
Zero would make the sockets non-blocking, and pymemcache reports a
|
|
79
|
+
negative one only when the first command connects.
|
|
80
|
+
"""
|
|
81
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
82
|
+
msg = f"{name} must be a number of seconds, got {type(value).__name__}"
|
|
83
|
+
raise TypeError(msg)
|
|
84
|
+
if not 0 < value < float("inf"):
|
|
85
|
+
msg = f"{name} must be greater than zero and finite, got {value!r}"
|
|
86
|
+
raise ValueError(msg)
|
|
87
|
+
|
|
88
|
+
|
|
60
89
|
def _validate_key_prefix(key_prefix: str) -> None:
|
|
61
90
|
"""Reject a ``key_prefix`` that would make keys Memcached refuses (#239).
|
|
62
91
|
|
|
@@ -153,19 +182,48 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
153
182
|
self,
|
|
154
183
|
servers: list[str],
|
|
155
184
|
key_prefix: str = DEFAULT_MEMCACHE_PREFIX,
|
|
185
|
+
*,
|
|
186
|
+
connect_timeout: float | None = None,
|
|
187
|
+
timeout: float | None = None,
|
|
156
188
|
) -> None:
|
|
157
189
|
"""Initialize the Memcached backend.
|
|
158
190
|
|
|
159
191
|
Args:
|
|
160
192
|
servers: List of Memcached servers in format ["host:port", ...]
|
|
161
193
|
key_prefix: Prefix for all cache keys (default: 'fastapi_cachex:')
|
|
194
|
+
connect_timeout: Seconds to wait for a connection to a server.
|
|
195
|
+
``None`` (not passed) is 5 seconds, and 1 second from 0.6.0;
|
|
196
|
+
it does not disable the timeout.
|
|
197
|
+
timeout: Seconds to wait for each reply from a server. ``None``
|
|
198
|
+
(not passed) is 5 seconds, and 1 second from 0.6.0; it does
|
|
199
|
+
not disable the timeout. Every
|
|
200
|
+
call runs in a worker thread of the default executor and
|
|
201
|
+
holds it until it is answered or times out, so while a
|
|
202
|
+
server is unreachable a lower value frees those threads
|
|
203
|
+
sooner.
|
|
204
|
+
|
|
205
|
+
Warns:
|
|
206
|
+
FutureWarning: If ``connect_timeout`` or ``timeout`` is not
|
|
207
|
+
passed, since their default changes in 0.6.0 (#516).
|
|
162
208
|
|
|
163
209
|
Raises:
|
|
164
210
|
CacheXError: If pymemcache is not installed
|
|
211
|
+
TypeError: If ``connect_timeout`` or ``timeout`` is not a number
|
|
165
212
|
ValueError: If ``key_prefix`` holds whitespace, control
|
|
166
|
-
characters or non-ASCII, or is 250 bytes or longer
|
|
213
|
+
characters or non-ASCII, or is 250 bytes or longer, or if
|
|
214
|
+
``connect_timeout`` or ``timeout`` is not greater than zero
|
|
167
215
|
"""
|
|
168
216
|
_validate_key_prefix(key_prefix)
|
|
217
|
+
if connect_timeout is None or timeout is None:
|
|
218
|
+
warnings.warn(
|
|
219
|
+
_TIMEOUT_DEFAULT_NOTICE, FutureWarning, stacklevel=caller_stacklevel()
|
|
220
|
+
)
|
|
221
|
+
connect_timeout = (
|
|
222
|
+
_DEFAULT_TIMEOUT if connect_timeout is None else connect_timeout
|
|
223
|
+
)
|
|
224
|
+
timeout = _DEFAULT_TIMEOUT if timeout is None else timeout
|
|
225
|
+
_validate_timeout("connect_timeout", connect_timeout)
|
|
226
|
+
_validate_timeout("timeout", timeout)
|
|
169
227
|
try:
|
|
170
228
|
from pymemcache import HashClient
|
|
171
229
|
except ImportError as exc:
|
|
@@ -185,8 +243,8 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
185
243
|
# ``_DEAD_TIMEOUT`` seconds.
|
|
186
244
|
self.client = HashClient(
|
|
187
245
|
servers,
|
|
188
|
-
connect_timeout=
|
|
189
|
-
timeout=
|
|
246
|
+
connect_timeout=connect_timeout,
|
|
247
|
+
timeout=timeout,
|
|
190
248
|
use_pooling=True,
|
|
191
249
|
default_noreply=False,
|
|
192
250
|
retry_attempts=0,
|
|
@@ -298,7 +356,15 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
298
356
|
return None
|
|
299
357
|
logger.debug("Memcached GET_AND_DELETE RETRY; key=%s", key)
|
|
300
358
|
|
|
301
|
-
|
|
359
|
+
# The message reaches error logs and trackers: a digest, not the key.
|
|
360
|
+
key_ref = log_ref(key)
|
|
361
|
+
logger.debug(
|
|
362
|
+
"Memcached GET_AND_DELETE GAVE UP; key_ref=%s key=%s", key_ref, key
|
|
363
|
+
)
|
|
364
|
+
msg = (
|
|
365
|
+
f"Memcached get_and_delete exceeded {_CAS_MAX_RETRIES} attempts "
|
|
366
|
+
f"(key_ref={key_ref})"
|
|
367
|
+
)
|
|
302
368
|
raise CacheXError(msg)
|
|
303
369
|
|
|
304
370
|
@_unavailable
|
|
@@ -325,31 +391,45 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
325
391
|
)
|
|
326
392
|
return bool(stored)
|
|
327
393
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
394
|
+
def _cas_if_equals(
|
|
395
|
+
self,
|
|
396
|
+
operation: str,
|
|
397
|
+
key: str,
|
|
398
|
+
expected: CacheEntry,
|
|
399
|
+
new: Callable[[bytes], bytes],
|
|
400
|
+
exptime: int,
|
|
401
|
+
) -> bool:
|
|
402
|
+
"""GETS, compare and CAS in one worker thread.
|
|
331
403
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
404
|
+
Writes ``new(raw)``, given the bytes just read, with ``exptime`` only
|
|
405
|
+
if the key holds ``expected`` and nothing wrote or deleted it since
|
|
406
|
+
``GETS`` read the value that was compared. ``new`` is called only once
|
|
407
|
+
the comparison has passed.
|
|
336
408
|
"""
|
|
337
|
-
return await asyncio.to_thread(self._delete_if_equals, key, expected)
|
|
338
|
-
|
|
339
|
-
def _delete_if_equals(self, key: str, expected: CacheEntry) -> bool:
|
|
340
|
-
"""Run ``delete_if_equals``'s GETS, compare and CAS in one worker thread."""
|
|
341
409
|
prefixed_key = self._make_key(key)
|
|
342
410
|
raw, cas_token = self.client.gets(prefixed_key)
|
|
343
411
|
if raw is None or decode_entry(raw) != expected:
|
|
344
|
-
logger.debug("Memcached
|
|
412
|
+
logger.debug("Memcached %s MISMATCH; key=%s", operation, key)
|
|
345
413
|
return False
|
|
346
|
-
|
|
414
|
+
done = self.client.cas(
|
|
415
|
+
prefixed_key, new(raw), cas_token, exptime, noreply=False
|
|
416
|
+
)
|
|
347
417
|
logger.debug(
|
|
348
|
-
"Memcached
|
|
349
|
-
|
|
350
|
-
|
|
418
|
+
"Memcached %s %s; key=%s", operation, "HIT" if done else "LOST RACE", key
|
|
419
|
+
)
|
|
420
|
+
return bool(done)
|
|
421
|
+
|
|
422
|
+
@_unavailable
|
|
423
|
+
async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool:
|
|
424
|
+
"""Atomically remove ``key`` while it holds ``expected`` (see base class).
|
|
425
|
+
|
|
426
|
+
The classic protocol's DELETE takes no CAS token, so the release is a
|
|
427
|
+
CAS write with a negative exptime, which Memcached treats as "expired
|
|
428
|
+
immediately".
|
|
429
|
+
"""
|
|
430
|
+
return await asyncio.to_thread(
|
|
431
|
+
self._cas_if_equals, "DELETE_IF_EQUALS", key, expected, lambda _: b"", -1
|
|
351
432
|
)
|
|
352
|
-
return bool(deleted)
|
|
353
433
|
|
|
354
434
|
@_unavailable
|
|
355
435
|
async def expire_if_equals(
|
|
@@ -360,30 +440,16 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
360
440
|
TOUCH in the Memcached protocol takes no CAS token, so the renewal is a
|
|
361
441
|
CAS write with the same bytes read by GETS and the new exptime.
|
|
362
442
|
"""
|
|
363
|
-
ttl = validate_ttl(ttl)
|
|
364
443
|
# Converted up front so a ttl Memcached cannot store fails before I/O.
|
|
365
|
-
exptime = _expiry(ttl)
|
|
444
|
+
exptime = _expiry(require_ttl(ttl))
|
|
366
445
|
return await asyncio.to_thread(
|
|
367
|
-
self.
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
def _expire_if_equals(
|
|
371
|
-
self, key: str, expected: CacheEntry, ttl: int, exptime: int
|
|
372
|
-
) -> bool:
|
|
373
|
-
"""Run ``expire_if_equals``'s GETS, compare and CAS in one worker thread."""
|
|
374
|
-
prefixed_key = self._make_key(key)
|
|
375
|
-
raw, cas_token = self.client.gets(prefixed_key)
|
|
376
|
-
if raw is None or decode_entry(raw) != expected:
|
|
377
|
-
logger.debug("Memcached EXPIRE_IF_EQUALS MISMATCH; key=%s", key)
|
|
378
|
-
return False
|
|
379
|
-
updated = self.client.cas(prefixed_key, raw, cas_token, exptime, noreply=False)
|
|
380
|
-
logger.debug(
|
|
381
|
-
"Memcached EXPIRE_IF_EQUALS %s; key=%s ttl=%s",
|
|
382
|
-
"HIT" if updated else "LOST RACE",
|
|
446
|
+
self._cas_if_equals,
|
|
447
|
+
"EXPIRE_IF_EQUALS",
|
|
383
448
|
key,
|
|
384
|
-
|
|
449
|
+
expected,
|
|
450
|
+
lambda raw: raw,
|
|
451
|
+
exptime,
|
|
385
452
|
)
|
|
386
|
-
return bool(updated)
|
|
387
453
|
|
|
388
454
|
@_unavailable
|
|
389
455
|
async def set_if_equals(
|
|
@@ -393,42 +459,17 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
393
459
|
value: CacheEntry,
|
|
394
460
|
ttl: int | timedelta | None = None,
|
|
395
461
|
) -> bool:
|
|
396
|
-
"""Atomically store ``value`` while ``key`` holds ``expected`` (see base class).
|
|
397
|
-
|
|
398
|
-
GETS reads the value to compare along with its CAS token, and the CAS
|
|
399
|
-
write succeeds only if nothing wrote or deleted the key since.
|
|
400
|
-
"""
|
|
401
|
-
ttl = validate_ttl(ttl)
|
|
462
|
+
"""Atomically store ``value`` while ``key`` holds ``expected`` (see base class)."""
|
|
402
463
|
# Converted up front so a ttl Memcached cannot store fails before I/O.
|
|
403
|
-
exptime = _expiry(ttl)
|
|
464
|
+
exptime = _expiry(validate_ttl(ttl))
|
|
404
465
|
return await asyncio.to_thread(
|
|
405
|
-
self.
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
def _set_if_equals(
|
|
409
|
-
self,
|
|
410
|
-
key: str,
|
|
411
|
-
expected: CacheEntry,
|
|
412
|
-
value: CacheEntry,
|
|
413
|
-
ttl: int | None,
|
|
414
|
-
exptime: int,
|
|
415
|
-
) -> bool:
|
|
416
|
-
"""Run ``set_if_equals``'s GETS, compare and CAS in one worker thread."""
|
|
417
|
-
prefixed_key = self._make_key(key)
|
|
418
|
-
raw, cas_token = self.client.gets(prefixed_key)
|
|
419
|
-
if raw is None or decode_entry(raw) != expected:
|
|
420
|
-
logger.debug("Memcached SET_IF_EQUALS MISMATCH; key=%s", key)
|
|
421
|
-
return False
|
|
422
|
-
stored = self.client.cas(
|
|
423
|
-
prefixed_key, encode_entry(value), cas_token, exptime, noreply=False
|
|
424
|
-
)
|
|
425
|
-
logger.debug(
|
|
426
|
-
"Memcached SET_IF_EQUALS %s; key=%s ttl=%s",
|
|
427
|
-
"HIT" if stored else "LOST RACE",
|
|
466
|
+
self._cas_if_equals,
|
|
467
|
+
"SET_IF_EQUALS",
|
|
428
468
|
key,
|
|
429
|
-
|
|
469
|
+
expected,
|
|
470
|
+
lambda _: encode_entry(value),
|
|
471
|
+
exptime,
|
|
430
472
|
)
|
|
431
|
-
return bool(stored)
|
|
432
473
|
|
|
433
474
|
def _add_delta(self, prefixed_key: str, delta: int) -> int | None:
|
|
434
475
|
"""Apply ``delta`` with INCR/DECR; ``None`` when the key does not exist.
|
|
@@ -623,7 +664,6 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
623
664
|
)
|
|
624
665
|
return removed
|
|
625
666
|
|
|
626
|
-
@_unavailable
|
|
627
667
|
async def clear_pattern(self, pattern: str) -> int:
|
|
628
668
|
"""Clear cached responses matching a pattern.
|
|
629
669
|
|
|
@@ -647,7 +687,6 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
647
687
|
logger.debug("Memcached CLEAR_PATTERN unsupported; pattern=%s", pattern)
|
|
648
688
|
return 0
|
|
649
689
|
|
|
650
|
-
@_unavailable
|
|
651
690
|
async def get_all_keys(self) -> list[str]:
|
|
652
691
|
"""Get all cache keys in the backend.
|
|
653
692
|
|
|
@@ -670,7 +709,6 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
670
709
|
logger.debug("Memcached GET_ALL_KEYS unsupported; returning empty list")
|
|
671
710
|
return []
|
|
672
711
|
|
|
673
|
-
@_unavailable
|
|
674
712
|
async def get_cache_data(self) -> dict[str, tuple[CacheEntry, float | None]]:
|
|
675
713
|
"""Get all cache data with expiry information.
|
|
676
714
|
|
|
@@ -18,6 +18,7 @@ from fastapi_cachex.types import counter_value
|
|
|
18
18
|
|
|
19
19
|
from .base import BaseCacheBackend
|
|
20
20
|
from .base import check_counter_range
|
|
21
|
+
from .base import require_ttl
|
|
21
22
|
from .base import validate_delta
|
|
22
23
|
from .base import validate_ttl
|
|
23
24
|
from .base import warn_if_path_shaped
|
|
@@ -235,7 +236,13 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
235
236
|
async def delete_many(self, keys: Iterable[str]) -> int:
|
|
236
237
|
"""Remove every key in ``keys`` under one lock; returns how many existed."""
|
|
237
238
|
doomed = set(keys)
|
|
238
|
-
|
|
239
|
+
# Popped key by key: `_evict` would walk the whole cache (#498).
|
|
240
|
+
async with self.lock:
|
|
241
|
+
now = time.time()
|
|
242
|
+
removed = 0
|
|
243
|
+
for key in doomed:
|
|
244
|
+
item = self.cache.pop(key, None)
|
|
245
|
+
removed += item is not None and _is_live(item, now)
|
|
239
246
|
logger.debug(
|
|
240
247
|
"Memory cache DELETE_MANY; requested=%s removed=%s", len(doomed), removed
|
|
241
248
|
)
|
|
@@ -289,7 +296,7 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
289
296
|
self, key: str, expected: CacheEntry, ttl: int | timedelta
|
|
290
297
|
) -> bool:
|
|
291
298
|
"""Atomically update expiry on ``key`` while it holds ``expected`` (see base class)."""
|
|
292
|
-
ttl =
|
|
299
|
+
ttl = require_ttl(ttl)
|
|
293
300
|
async with self.lock:
|
|
294
301
|
item = self.cache.get(key)
|
|
295
302
|
if item is None or not _is_live(item, time.time()):
|