Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/APP_CACHE.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,10 @@ Complete runnable example: [`examples/app_cache.py`](https://github.com/allen009

> [!NOTE]
> `clear()`/`clear_prefix()` are implemented via the backend's `get_all_keys()`
> and `delete_many()` (one batched `DEL` on Redis). Since Memcached doesn't
> and `delete_many()` (`DEL` in batches of 100 keys on Redis). Since Memcached doesn't
> support key enumeration (see [Backends](BACKENDS.md#memcached)), these
> methods — and `clear_pattern()` — are no-ops on a Memcached backend;
> methods — and `clear_pattern()` — are no-ops on a Memcached backend that
> return 0 with a `RuntimeWarning`;
> `get()`/`set()`/`add()`/`delete()`/`has()` work normally. Use Redis or the in-memory
> backend if you need bulk clearing.

Expand Down
11 changes: 7 additions & 4 deletions docs/BACKENDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,8 @@ BackendProxy.set(backend)

**Limitations**:

- Pattern-based key clearing (`clear_pattern`) is not supported by the Memcached protocol
- Pattern-based key clearing (`clear_pattern`) is not supported by the Memcached protocol:
it returns 0 with a `RuntimeWarning`
- Keys cannot be enumerated: `get_all_keys()`/`get_cache_data()` return empty
results (with a `RuntimeWarning`), so the monitoring routes show nothing
- `clear_path()` cannot find HTTP cache entries: it deletes only a key named exactly
Expand Down Expand Up @@ -290,9 +291,11 @@ if await backend.set_if_absent(f"stream:{user_id}", owner, ttl=300):
and the monitoring routes treat it like any other entry. Incrementing a key
that holds anything else raises `CacheXError` on every backend, even a cached
response whose body is a number. A counter written with
`set(key, counter_entry(n))` can be incremented on every backend, except that
Memcached counters are unsigned: there `n` must be from 0 to 2**64 - 1, and
incrementing a negative one raises `CacheXError`. `delta`
`set(key, counter_entry(n))` can be incremented on every backend, as long as
`n` fits the server's counters: Redis counters are signed 64-bit, so there `n`
must be from -2**63 to 2**63 - 1, and Memcached counters are unsigned, so there
`n` must be from 0 to 2**64 - 1. Incrementing a counter outside that range
raises `CacheXError`. `delta`
must be an `int` within the signed 64-bit range; anything else raises
`TypeError` or `ValueError` before the backend is touched.
- `get_and_delete(key) -> CacheEntry | None` — Memory pops under its lock, Redis
Expand Down
3 changes: 2 additions & 1 deletion fastapi_cachex/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,8 @@ async def increment(self, key: str, delta: int = 1, ttl: int | None = None) -> i
Raises:
CacheXError: If ``key`` holds a cached response instead of a counter
TypeError: If ``delta`` or ``ttl`` is not an ``int``
ValueError: If ``ttl`` is out of range
ValueError: If ``ttl`` is out of range, or ``delta`` does not fit
in a signed 64-bit integer
"""
validate_delta(delta)
validate_ttl(ttl)
Expand Down
5 changes: 4 additions & 1 deletion fastapi_cachex/backends/memcached.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@ class MemcachedBackend(BaseCacheBackend):
conflicts with other applications.

Limitations:
- Pattern-based clearing (clear_pattern) is not supported by Memcached protocol
- Memcached cannot enumerate keys: clear_pattern clears nothing (returns
0) and get_all_keys/get_cache_data return empty results, each with a
RuntimeWarning; clear_path only deletes a key named exactly as the path
- clear() issues flush_all, which empties the whole server
- Operations are wrapped to appear async but use blocking sync client internally
"""

Expand Down
6 changes: 6 additions & 0 deletions fastapi_cachex/backends/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ def __init__(
broadest compatibility. Use 3 only when hiredis >= 3.0 is installed
and Redis 8.0+ RESP3 features are required.
**kwargs: Additional arguments to pass to Redis client

Raises:
CacheXError: If redis-py is not installed
"""
try:
# Import top-level package first so tests that monkeypatch
Expand Down Expand Up @@ -218,12 +221,15 @@ def load_from_config(config: RedisConfig) -> "AsyncRedisCacheBackend":

Args:
config: RedisConfig instance

Returns:
An instance of AsyncRedisCacheBackend

Warns:
DeprecationWarning: ``config`` sets ``encoding`` explicitly; the
field is removed in 0.4.0.
RuntimeWarning: That ``encoding`` is not UTF-8, which corrupts
non-ASCII content read back (emitted by the constructor).
"""
encoding: str | None = None
if "encoding" in config.model_fields_set:
Expand Down
5 changes: 4 additions & 1 deletion fastapi_cachex/lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,13 @@ async def acquire(
ttl: Override default TTL (seconds)

Returns:
True if the lock was acquired, False on timeout or failure
True if the lock was acquired; False if it is held elsewhere
(non-blocking) or the timeout elapsed first (blocking)

Raises:
RuntimeError: If this CacheLock instance is already held.
BackendNotFoundError: If no ``backend`` was passed and none is
registered with ``BackendProxy.set()``.
"""
if self._is_held:
msg = (
Expand Down
27 changes: 19 additions & 8 deletions fastapi_cachex/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,10 @@ def __init__(
Raises:
BackendNotFoundError: If ``backend`` is None and no backend has
been set with ``BackendProxy.set()``.
TypeError: If ``lock`` is not a bool or None, or ``lock_ttl`` is
not an int.
ValueError: If ``default_ttl`` or ``lock_ttl`` is zero or negative.
TypeError: If ``lock`` is not a bool or None, or ``default_ttl``
or ``lock_ttl`` is not an int.
ValueError: If ``default_ttl`` or ``lock_ttl`` is zero, negative
or larger than ``MAX_TTL``, or ``lock_ttl`` is None.

Warns:
UserWarning: If ``key_prefix`` contains a glob metacharacter
Expand Down Expand Up @@ -207,8 +208,9 @@ async def set(self, key: str, value: Any, ttl: int | None = None) -> None:
(which itself defaults to no expiry).

Raises:
TypeError: If ``value`` is not JSON-serializable.
ValueError: If ``ttl`` is zero or negative.
TypeError: If ``value`` is not JSON-serializable, or ``ttl`` is
not an int.
ValueError: If ``ttl`` is zero, negative or larger than ``MAX_TTL``.
"""
effective_ttl = validate_ttl(ttl if ttl is not None else self.default_ttl)
entry = self._encode(value)
Expand Down Expand Up @@ -238,8 +240,9 @@ async def add(self, key: str, value: Any, ttl: int | None = None) -> bool:
True if the value was stored, False if the key already existed.

Raises:
TypeError: If ``value`` is not JSON-serializable.
ValueError: If ``ttl`` is zero or negative.
TypeError: If ``value`` is not JSON-serializable, or ``ttl`` is
not an int.
ValueError: If ``ttl`` is zero, negative or larger than ``MAX_TTL``.
"""
effective_ttl = validate_ttl(ttl if ttl is not None else self.default_ttl)
entry = self._encode(value)
Expand Down Expand Up @@ -441,7 +444,8 @@ async def get_or_set( # noqa: PLR0913
TypeError: If the value produced by ``factory`` is not JSON-serializable,
or if ``lock``, ``ttl``, ``lock_ttl``, or ``wait_timeout``
have invalid types.
ValueError: If ``ttl``, ``lock_ttl``, or ``wait_timeout`` is zero or negative.
ValueError: If ``ttl``, ``lock_ttl``, or ``wait_timeout`` is zero or
negative, or ``ttl`` or ``lock_ttl`` is larger than ``MAX_TTL``.
LockTimeoutError: If ``raise_on_timeout=True`` and waiting exceeds ``wait_timeout``.

Warns:
Expand Down Expand Up @@ -545,6 +549,10 @@ async def clear_pattern(self, pattern: str) -> int:
async def clear_prefix(self, prefix: str | None = None) -> int:
"""Clear all keys under this manager's namespace matching a sub-prefix.

Built on ``get_all_keys()`` and ``delete_many()``, so on a backend
without key enumeration (e.g. Memcached) it clears nothing and returns
0, with a ``RuntimeWarning``.

Args:
prefix: Optional additional prefix (relative to ``self.key_prefix``)
to restrict which keys are cleared. If None, clears everything
Expand All @@ -564,6 +572,9 @@ async def clear_prefix(self, prefix: str | None = None) -> int:
async def clear(self) -> int:
"""Clear all keys under this manager's namespace.

Same as ``clear_prefix()`` with no prefix, so it is a no-op on a
backend without key enumeration (e.g. Memcached).

Returns:
Number of cache entries cleared.
"""
Expand Down
2 changes: 1 addition & 1 deletion i18n/zh-TW/docs/APP_CACHE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ await manager.clear_pattern("user:*") # 比對 "myapp:user:*"
- `AppCache` 依賴項在第一次使用時會建立並註冊一個預設的 `CacheManager`;`CacheManagerProxy.set()` 則可改為註冊你自己的實例。

> [!NOTE]
> `clear()`/`clear_prefix()` 是以後端的 `get_all_keys()` 與 `delete_many()` 實作(在 Redis 上是一次批次 `DEL`)。由於 Memcached 不支援列舉鍵(見[後端](BACKENDS.md#memcached)),這些方法以及 `clear_pattern()` 在 Memcached 後端上不會有任何作用;`get()`/`set()`/`add()`/`delete()`/`has()` 則照常運作。若需要大量清除,請使用 Redis 或記憶體後端。
> `clear()`/`clear_prefix()` 是以後端的 `get_all_keys()` 與 `delete_many()` 實作(在 Redis 上是每批 100 個鍵的 `DEL`)。由於 Memcached 不支援列舉鍵(見[後端](BACKENDS.md#memcached)),這些方法以及 `clear_pattern()` 在 Memcached 後端上不會有任何作用,只會回傳 0 並發出 `RuntimeWarning`;`get()`/`set()`/`add()`/`delete()`/`has()` 則照常運作。若需要大量清除,請使用 Redis 或記憶體後端。

## Cache stampede 保護 {#stampede-protection}

Expand Down
4 changes: 2 additions & 2 deletions i18n/zh-TW/docs/BACKENDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ BackendProxy.set(backend)

**限制**:

- Memcached 協定不支援依模式清除鍵(`clear_pattern`)
- Memcached 協定不支援依模式清除鍵(`clear_pattern`):它會回傳 0 並發出 `RuntimeWarning`
- 無法列舉鍵:`get_all_keys()`/`get_cache_data()` 會回傳空結果(並發出 `RuntimeWarning`),因此監控路由不會顯示任何內容
- `clear_path()` 找不到 HTTP 快取項目:它只會刪除名稱與路徑完全相同的鍵,忽略 `include_params`,而且每次呼叫都會發出 `RuntimeWarning`。資料變更後要刪除某個快取路由的項目,請呼叫 [`invalidate(request)`](HTTP_CACHING.md#invalidating-a-single-cached-route),它會重建完全相同的鍵
- `clear()` 會發出 `flush_all`,清空整台 Memcached 伺服器,而不只是這個命名空間
Expand Down Expand Up @@ -191,7 +191,7 @@ if await backend.set_if_absent(f"stream:{user_id}", owner, ttl=300):
await backend.delete_if_equals(f"stream:{user_id}", owner)
```

- `increment(key, delta=1, ttl=None) -> int`:記憶體後端在鎖內執行讀取—修改—寫入,Redis 執行 Lua 腳本(`EXISTS` + `INCRBY` + `EXPIRE`),Memcached 則使用 `ADD` + `INCR`/`DECR`(Memcached 的計數器最低停在 0)。Memcached 以整秒計時,因此 `ttl` 很短的新計數器可能在 `ADD` 與 `INCR` 之間就過期;此時 Memcached 會重試 `ADD` + `INCR`,從 `delta` 開始新的時間窗,只有連續 16 次嘗試計數器都消失時才拋出 `CacheXError`。計數器可透過 `get()` 讀到,形式為 fingerprint 為 `COUNTER_FINGERPRINT`、內容為十進位數值的 `CacheEntry`,因此 `delete`/`clear*` 與監控路由都會把它當成一般項目處理。對存放其他內容的鍵執行 increment,在每個後端上都會拋出 `CacheXError`,即使是本文剛好是數字的快取回應也一樣。以 `set(key, counter_entry(n))` 寫入的計數器在每個後端上都可以 increment,唯一的例外是 Memcached 的計數器沒有正負號:在它上面 `n` 必須介於 0 到 2**64 - 1 之間,對負數的計數器執行 increment 會拋出 `CacheXError`。`delta` 必須是 signed 64 位元範圍內的 `int`,否則會在存取後端之前拋出 `TypeError` 或 `ValueError`。
- `increment(key, delta=1, ttl=None) -> int`:記憶體後端在鎖內執行讀取—修改—寫入,Redis 執行 Lua 腳本(`EXISTS` + `INCRBY` + `EXPIRE`),Memcached 則使用 `ADD` + `INCR`/`DECR`(Memcached 的計數器最低停在 0)。Memcached 以整秒計時,因此 `ttl` 很短的新計數器可能在 `ADD` 與 `INCR` 之間就過期;此時 Memcached 會重試 `ADD` + `INCR`,從 `delta` 開始新的時間窗,只有連續 16 次嘗試計數器都消失時才拋出 `CacheXError`。計數器可透過 `get()` 讀到,形式為 fingerprint 為 `COUNTER_FINGERPRINT`、內容為十進位數值的 `CacheEntry`,因此 `delete`/`clear*` 與監控路由都會把它當成一般項目處理。對存放其他內容的鍵執行 increment,在每個後端上都會拋出 `CacheXError`,即使是本文剛好是數字的快取回應也一樣。以 `set(key, counter_entry(n))` 寫入的計數器在每個後端上都可以 increment,前提是 `n` 在伺服器計數器的範圍內:Redis 的計數器是 signed 64 位元,因此在它上面 `n` 必須介於 -2**63 到 2**63 - 1 之間;Memcached 的計數器沒有正負號,因此在它上面 `n` 必須介於 0 到 2**64 - 1 之間。對超出該範圍的計數器執行 increment 會拋出 `CacheXError`。`delta` 必須是 signed 64 位元範圍內的 `int`,否則會在存取後端之前拋出 `TypeError` 或 `ValueError`。
- `get_and_delete(key) -> CacheEntry | None`:記憶體後端在鎖內 pop,Redis 使用 `GETDEL`(伺服器 6.2 以上),Memcached 使用 `GETS` + `exptime=-1` 的 `CAS` 寫入(若中間有其他寫入者替換了值則會重試;連續 16 次都被替換時會拋出 `CacheXError`,而不是當成鍵不存在)。`StateManager.consume_state`、`StateManager.delete_state`、`CacheManager.delete` 與 `invalidate()` 都建立在它之上。
- `set_if_absent(key, value, ttl=None) -> bool`:只在 `key` 不存在時儲存 `value`(已過期的鍵視為不存在),並回報是否有寫入。記憶體後端在鎖內檢查,Redis 使用 `SET NX EX`,Memcached 使用 `ADD`。
- `delete_if_equals(key, expected) -> bool`:只在 `key` 仍存放 `expected` 時才移除它,因此項目已過期的持有者無法釋放已被他人取得的鎖。請在你儲存的項目中放入唯一的權杖,並以同一個項目釋放。記憶體後端在鎖內比較,Redis 透過 Lua 腳本刪除,並在腳本中重新檢查先前比較過的值,Memcached 則使用 `GETS` + 一個讓項目立即過期的 `CAS` 寫入(傳統協定的 `DELETE` 不接受 CAS 權杖)。
Expand Down
Loading