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.
Files changed (40) hide show
  1. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/PKG-INFO +42 -2
  2. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/README.md +41 -1
  3. fastapi_cachex-0.3.4/fastapi_cachex/backends/base.py +137 -0
  4. fastapi_cachex-0.3.4/fastapi_cachex/backends/codec.py +69 -0
  5. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/backends/memcached.py +81 -45
  6. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/backends/memory.py +98 -59
  7. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/backends/redis.py +92 -165
  8. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/cache.py +72 -70
  9. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/manager.py +6 -12
  10. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/routes.py +82 -120
  11. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/session/manager.py +54 -54
  12. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/state/manager.py +47 -82
  13. fastapi_cachex-0.3.4/fastapi_cachex/types.py +62 -0
  14. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/pyproject.toml +3 -1
  15. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/pyproject.toml.orig +3 -1
  16. fastapi_cachex-0.3.2/fastapi_cachex/backends/base.py +0 -70
  17. fastapi_cachex-0.3.2/fastapi_cachex/types.py +0 -34
  18. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/__init__.py +0 -0
  19. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/backends/__init__.py +0 -0
  20. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/backends/config.py +0 -0
  21. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/dependencies.py +0 -0
  22. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/directives.py +0 -0
  23. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/exceptions.py +0 -0
  24. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/manager_proxy.py +0 -0
  25. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/proxy.py +0 -0
  26. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/py.typed +0 -0
  27. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/session/__init__.py +0 -0
  28. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/session/config.py +0 -0
  29. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/session/dependencies.py +0 -0
  30. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/session/exceptions.py +0 -0
  31. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/session/middleware.py +0 -0
  32. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/session/models.py +0 -0
  33. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/session/proxy.py +0 -0
  34. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/session/security.py +0 -0
  35. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/session/token_serializers.py +0 -0
  36. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/state/__init__.py +0 -0
  37. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/state/dependencies.py +0 -0
  38. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/state/exceptions.py +0 -0
  39. {fastapi_cachex-0.3.2 → fastapi_cachex-0.3.4}/fastapi_cachex/state/models.py +0 -0
  40. {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.2
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()`. Since Memcached doesn't support key enumeration (see
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()`. Since Memcached doesn't support key enumeration (see
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 but wraps it
28
- in async methods. For blocking concerns, consider using aiomcache for
29
- true async Memcached operations. Keys are namespaced with 'fastapi_cachex:'
30
- by default to avoid conflicts with other applications.
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
- self.client = HashClient(servers, connect_timeout=5, timeout=5)
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
- prefixed_key = self._make_key(key)
76
- value = await asyncio.to_thread(self.client.get, prefixed_key)
77
- if value is None:
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
- return None
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
- return None
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
- prefixed_key = self._make_key(key)
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
- # Use latin-1 to round-trip arbitrary bytes through JSON storage
105
- content = value.content.decode("latin-1")
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
- serialized_data: str | bytes = json.dumps(
108
- {
109
- "fingerprint": value.fingerprint,
110
- "content": content,
111
- "media_type": value.media_type,
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
- # orjson returns bytes, stdlib json returns str
116
- serialized_bytes = (
117
- serialized_data
118
- if isinstance(serialized_data, bytes)
119
- else serialized_data.encode("utf-8")
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
- expire = ttl if ttl is not None else 0
123
- await asyncio.to_thread(self.client.set, prefixed_key, serialized_bytes, expire)
124
- logger.debug("Memcached SET; key=%s ttl=%s", key, ttl)
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.