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.
Files changed (36) hide show
  1. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/PKG-INFO +2 -2
  2. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/_stored_response.py +3 -1
  3. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/backends/base.py +27 -4
  4. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/backends/codec.py +25 -7
  5. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/backends/memcached.py +114 -76
  6. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/backends/memory.py +9 -2
  7. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/backends/redis.py +71 -77
  8. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/cache.py +557 -495
  9. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/cache_key.py +5 -3
  10. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/lock.py +14 -3
  11. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/manager.py +17 -9
  12. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/pyproject.toml +4 -16
  13. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/pyproject.toml.orig +6 -14
  14. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/LICENSE +0 -0
  15. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/README.md +0 -0
  16. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/__init__.py +0 -0
  17. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/_cache_control.py +0 -0
  18. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/_callables.py +0 -0
  19. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/_coalesce.py +0 -0
  20. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/_key_builders.py +0 -0
  21. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/_rendering.py +0 -0
  22. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/_vary.py +0 -0
  23. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/_warnings.py +0 -0
  24. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/backends/__init__.py +0 -0
  25. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/backends/_unavailable.py +0 -0
  26. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/backends/config.py +0 -0
  27. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/cached.py +0 -0
  28. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/dependencies.py +0 -0
  29. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/directives.py +0 -0
  30. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/exceptions.py +0 -0
  31. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/headers.py +0 -0
  32. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/manager_proxy.py +0 -0
  33. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/proxy.py +0 -0
  34. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/py.typed +0 -0
  35. {fastapi_cachex-0.5.2 → fastapi_cachex-0.5.3}/fastapi_cachex/routes.py +0 -0
  36. {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.2
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 :: 3 - Alpha
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
- return f'W/"{hashlib.md5(body).hexdigest()}"' # noqa: S324
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 = validate_ttl(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[Any, float | None]]:
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
- _DECODE_ERRORS = (ValueError, KeyError, TypeError, AttributeError)
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, non-string
70
- content) yields ``None``, so callers can treat every malformed value as a
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=data["fingerprint"],
105
+ fingerprint=fingerprint,
88
106
  content=data["content"].encode("latin-1"),
89
- media_type=data.get("media_type"),
90
- status_code=data.get("status_code", DEFAULT_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=5,
189
- timeout=5,
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
- msg = f"Memcached get_and_delete exceeded {_CAS_MAX_RETRIES} attempts on key {key!r}"
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
- @_unavailable
329
- async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool:
330
- """Atomically remove ``key`` while it holds ``expected`` (see base class).
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
- The classic protocol's DELETE takes no CAS token, so the release is a
333
- CAS write with a negative exptime, which Memcached treats as "expired
334
- immediately": it succeeds only if nothing wrote the key since ``GETS``
335
- read the value that was compared.
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 DELETE_IF_EQUALS MISMATCH; key=%s", key)
412
+ logger.debug("Memcached %s MISMATCH; key=%s", operation, key)
345
413
  return False
346
- deleted = self.client.cas(prefixed_key, b"", cas_token, -1, noreply=False)
414
+ done = self.client.cas(
415
+ prefixed_key, new(raw), cas_token, exptime, noreply=False
416
+ )
347
417
  logger.debug(
348
- "Memcached DELETE_IF_EQUALS %s; key=%s",
349
- "HIT" if deleted else "LOST RACE",
350
- key,
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._expire_if_equals, key, expected, ttl, exptime
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
- ttl,
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._set_if_equals, key, expected, value, ttl, exptime
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
- ttl,
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
- removed = await self._evict(doomed.__contains__)
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 = validate_ttl(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()):