From 27fc3e3684c27283a5413689769184f66af90a3f Mon Sep 17 00:00:00 2001 From: allen0099 Date: Tue, 29 Sep 2026 08:31:21 +0000 Subject: [PATCH] docs(backends): align backend, CacheManager and CacheLock docs with the code --- docs/APP_CACHE.md | 5 +++-- docs/BACKENDS.md | 11 +++++++---- fastapi_cachex/backends/base.py | 3 ++- fastapi_cachex/backends/memcached.py | 5 ++++- fastapi_cachex/backends/redis.py | 6 ++++++ fastapi_cachex/lock.py | 5 ++++- fastapi_cachex/manager.py | 27 +++++++++++++++++++-------- i18n/zh-TW/docs/APP_CACHE.md | 2 +- i18n/zh-TW/docs/BACKENDS.md | 4 ++-- 9 files changed, 48 insertions(+), 20 deletions(-) diff --git a/docs/APP_CACHE.md b/docs/APP_CACHE.md index 9daae06..08705fd 100644 --- a/docs/APP_CACHE.md +++ b/docs/APP_CACHE.md @@ -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. diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index 47824e2..2ac9f06 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -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 @@ -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 diff --git a/fastapi_cachex/backends/base.py b/fastapi_cachex/backends/base.py index da2d30a..319cc23 100644 --- a/fastapi_cachex/backends/base.py +++ b/fastapi_cachex/backends/base.py @@ -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) diff --git a/fastapi_cachex/backends/memcached.py b/fastapi_cachex/backends/memcached.py index 7df4d7d..898f67e 100644 --- a/fastapi_cachex/backends/memcached.py +++ b/fastapi_cachex/backends/memcached.py @@ -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 """ diff --git a/fastapi_cachex/backends/redis.py b/fastapi_cachex/backends/redis.py index 6d87203..14daefc 100644 --- a/fastapi_cachex/backends/redis.py +++ b/fastapi_cachex/backends/redis.py @@ -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 @@ -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: diff --git a/fastapi_cachex/lock.py b/fastapi_cachex/lock.py index f5c0f88..553d524 100644 --- a/fastapi_cachex/lock.py +++ b/fastapi_cachex/lock.py @@ -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 = ( diff --git a/fastapi_cachex/manager.py b/fastapi_cachex/manager.py index 0137f4e..a0ab325 100644 --- a/fastapi_cachex/manager.py +++ b/fastapi_cachex/manager.py @@ -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 @@ -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) @@ -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) @@ -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: @@ -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 @@ -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. """ diff --git a/i18n/zh-TW/docs/APP_CACHE.md b/i18n/zh-TW/docs/APP_CACHE.md index 9f46afe..39aacb3 100644 --- a/i18n/zh-TW/docs/APP_CACHE.md +++ b/i18n/zh-TW/docs/APP_CACHE.md @@ -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} diff --git a/i18n/zh-TW/docs/BACKENDS.md b/i18n/zh-TW/docs/BACKENDS.md index 2507657..dcd305c 100644 --- a/i18n/zh-TW/docs/BACKENDS.md +++ b/i18n/zh-TW/docs/BACKENDS.md @@ -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 伺服器,而不只是這個命名空間 @@ -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 權杖)。