From 4bcd79693cd3247397ff9a30ef322509ce345096 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 11:13:03 +0000 Subject: [PATCH 1/5] docs: say what still runs on a cache hit (#329) Dependencies run on every request, since FastAPI resolves them before the @cache wrapper; background tasks added by the handler run only on a miss, while one a dependency adds runs on a hit too. CACHE_FLOW.md no longer claims the handler "does not run at all". Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UTXhK1BtwXTBDaouEcKtpW --- docs/CACHE_FLOW.md | 7 +++++-- docs/HTTP_CACHING.md | 23 +++++++++++++++++++++++ i18n/zh-TW/docs/CACHE_FLOW.md | 2 +- i18n/zh-TW/docs/HTTP_CACHING.md | 10 ++++++++++ 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/docs/CACHE_FLOW.md b/docs/CACHE_FLOW.md index 02bbc3e..0902601 100644 --- a/docs/CACHE_FLOW.md +++ b/docs/CACHE_FLOW.md @@ -506,8 +506,11 @@ async def cleanup_task(): ## Performance -On a cache hit the endpoint handler does not run at all: the cost is one backend -lookup. Which backend to pick is covered in [Backends](BACKENDS.md#choosing-a-backend). +On a cache hit the route function does not run: the cost is one backend +lookup, plus the route's dependencies, which FastAPI resolves before the +`@cache` wrapper is called and which therefore run on every request (see +[What still runs on a hit](HTTP_CACHING.md#what-still-runs-on-a-hit)). +Which backend to pick is covered in [Backends](BACKENDS.md#choosing-a-backend). ## Cache invalidation scenarios diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index 789355a..55b6294 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -111,6 +111,29 @@ When a cached entry is valid (within TTL): - **With `ttl=0`**: Sends `max-age=0` and otherwise behaves like `ttl=None`. A negative `ttl`, a non-`int` one (such as `1.5` or `True`) and one above `MAX_TTL` (see [TTL values](BACKENDS.md#ttl-values)) are rejected with `CacheXError` when the decorator is applied - **With a `timedelta`**: `ttl=timedelta(minutes=5)` is the same as `ttl=300`, and so is `stale_ttl`; a `timedelta` with a fraction of a second is rejected like a `float` +### What still runs on a hit + +"Without running the handler" covers the route function only: + +- **Dependencies run on every request, hit or miss.** FastAPI resolves them + before it calls the route function, and `@cache` wraps only that function. + A database session opened in a dependency is opened on a hit too, and a + dependency that counts or rate-limits requests counts the hit. Per-user key + builders rely on this: they read what a dependency put into `request.state`. + The headers and cookies a dependency sets are sent with the hit as well + (see [Requests with credentials](#requests-with-credentials) for how they + affect storage). +- **Background tasks added by the handler run only on a miss.** On a hit the + route function never runs, so nothing is added to its `BackgroundTasks`, and + a `Response.background` the handler returned is not stored with the entry. + A task that a dependency adds through its own `BackgroundTasks` parameter + runs on a hit too, since the dependency ran. +- **Middleware** runs as for any request, around the whole thing. + +So put work that must happen on every request (auditing, rate limiting, +authentication) in a dependency or middleware, and work that belongs to +producing the response (sending the e-mail about it) in the handler. + ### The `Age` header A response served from a stored entry carries an `Age` header: the whole diff --git a/i18n/zh-TW/docs/CACHE_FLOW.md b/i18n/zh-TW/docs/CACHE_FLOW.md index 3d9f782..860ffd9 100644 --- a/i18n/zh-TW/docs/CACHE_FLOW.md +++ b/i18n/zh-TW/docs/CACHE_FLOW.md @@ -384,7 +384,7 @@ async def cleanup_task(): ## 效能 {#performance} -快取命中時,端點的 handler 完全不會執行:成本只有一次後端查詢。該選擇哪個後端,請見[後端](BACKENDS.md#choosing-a-backend)。 +快取命中時,路由函式不會執行:成本是一次後端查詢,加上路由的依賴項,因為 FastAPI 在呼叫 `@cache` 的包裝函式之前就會解析它們,所以每個請求都會執行(見[命中時仍會執行的部分](HTTP_CACHING.md#what-still-runs-on-a-hit))。該選擇哪個後端,請見[後端](BACKENDS.md#choosing-a-backend)。 ## 快取失效情境 {#cache-invalidation-scenarios} diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index e7c67fa..e1824e8 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -77,6 +77,16 @@ async def items(): ... - **使用 `ttl=0`**:送出 `max-age=0`,其餘行為與 `ttl=None` 相同。負數、非 `int`(例如 `1.5` 或 `True`)或超過 `MAX_TTL`(見 [TTL 值](BACKENDS.md#ttl-values))的 `ttl`,都會在套用裝飾器時以 `CacheXError` 拒絕 - **使用 `timedelta`**:`ttl=timedelta(minutes=5)` 等同於 `ttl=300`,`stale_ttl` 也一樣;帶有小數秒的 `timedelta` 會像 `float` 一樣被拒絕 +### 命中時仍會執行的部分 {#what-still-runs-on-a-hit} + +「不執行 handler」指的只有路由函式本身: + +- **依賴項在每個請求都會執行,不論命中與否。** FastAPI 會先解析依賴項,再呼叫路由函式,而 `@cache` 包的只有那個函式。在依賴項中開啟的資料庫 session 在命中時也會開啟;計數或限流的依賴項也會把命中算進去。每位使用者各自的 key builder 正是依賴這一點:它們讀取依賴項放進 `request.state` 的內容。依賴項設定的標頭與 cookie 也會隨命中的回應送出(它們如何影響儲存,見[帶有憑證的請求](#requests-with-credentials))。 +- **handler 加入的背景任務只在未命中時執行。** 命中時路由函式不會執行,因此不會有任何東西加進它的 `BackgroundTasks`,handler 回傳的 `Response.background` 也不會隨項目儲存。依賴項透過自己的 `BackgroundTasks` 參數加入的任務則在命中時也會執行,因為依賴項執行了。 +- **中介軟體**與任何請求一樣,包在整個流程外面執行。 + +因此,每個請求都必須做的事(稽核、限流、驗證身分)請放在依賴項或中介軟體;屬於產生回應的事(寄出相關的電子郵件)請放在 handler。 + ### `Age` 標頭 {#the-age-header} 由已儲存項目回應的回應會帶有 `Age` 標頭:從 `@cache` 儲存它起經過的整數秒數(RFC 9111 §5.1)。這包括快取命中,以及依已儲存項目的 ETag 回應的 304。`Cache-Control` 仍然是 `max-age=`,瀏覽器或 CDN 會從中扣掉 `Age`(RFC 9111 §4.2.3),因此在 60 秒 ttl 的第 50 秒時送出的回應,下游最多只會再重複使用 10 秒。沒有 `Age` 時,在項目即將過期前的命中會讓下游重新計時,內容最多可能被重複使用到 ttl 的兩倍。 From 9198a7988c207eeaa851ced4a181627d69b3b7b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 11:13:03 +0000 Subject: [PATCH 2/5] docs: CacheManager.get() cannot tell a stored None from a miss (#391) APP_CACHE.md says so and shows the alternatives: a sentinel default, has(), or get_or_set(), which does tell them apart. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UTXhK1BtwXTBDaouEcKtpW --- docs/APP_CACHE.md | 14 +++++++++++++- i18n/zh-TW/docs/APP_CACHE.md | 9 ++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/APP_CACHE.md b/docs/APP_CACHE.md index 003aa30..a8f5f22 100644 --- a/docs/APP_CACHE.md +++ b/docs/APP_CACHE.md @@ -84,7 +84,19 @@ cached wherever it is called: ## Behavior - `get()` returns `None` (or a supplied `default=`) on a cache miss — it never - raises for missing or corrupted entries. + raises for missing or corrupted entries. A stored `None` (JSON `null`) reads + back as `None` too, so `get()` alone cannot tell "cached as nothing" from + "not cached", and code that caches a negative result this way recomputes it + on every call. To tell them apart, pass a sentinel as the default, check + `has()` first, or use `get_or_set()`, which runs `factory` only on a real + miss and returns the stored `None` otherwise: + + ```python + missing = object() + value = await cache.get("user:42", default=missing) + if value is missing: + ... # not cached; a cached None comes back as None + ``` - `set()` lets `TypeError` propagate for values that are not JSON-serializable. - `get_or_set()` uses stampede protection by default (`lock=True`, per call or manager-wide with `CacheManager(lock=...)`), preventing concurrent misses diff --git a/i18n/zh-TW/docs/APP_CACHE.md b/i18n/zh-TW/docs/APP_CACHE.md index 391173c..b606389 100644 --- a/i18n/zh-TW/docs/APP_CACHE.md +++ b/i18n/zh-TW/docs/APP_CACHE.md @@ -59,7 +59,14 @@ await manager.clear_pattern("user:*") # 比對 "myapp:user:*" ## 行為 {#behavior} -- `get()` 在快取未命中時回傳 `None`(或你提供的 `default=`),遇到不存在或損毀的項目也絕不會拋出例外。 +- `get()` 在快取未命中時回傳 `None`(或你提供的 `default=`),遇到不存在或損毀的項目也絕不會拋出例外。存入的 `None`(JSON 的 `null`)讀回來也是 `None`,因此光靠 `get()` 無法分辨「快取了沒有結果」與「沒有快取」,用這種方式快取否定結果的程式碼每次呼叫都會重新計算。要分辨兩者,可以傳入一個哨兵值作為預設值、先用 `has()` 檢查,或改用 `get_or_set()`,它只在真正未命中時執行 `factory`,否則回傳存入的 `None`: + + ```python + missing = object() + value = await cache.get("user:42", default=missing) + if value is missing: + ... # 沒有快取;快取的 None 會以 None 回傳 + ``` - `set()` 遇到無法 JSON 序列化的值時,會讓 `TypeError` 直接往外拋出。 - `get_or_set()` 預設使用 cache stampede 保護(`lock=True`,可針對單次呼叫或以 `CacheManager(lock=...)` 全域設定),避免多次並行未命中時同時執行 `factory`,若等待超時則具備直接計算的優雅降級回退。分散式鎖的鍵名格式為 `lock:`(預設為 `lock:cache:user:42`)。 - `get_or_set()` 在未命中與命中時都回傳經 JSON 解碼後的值(見 [JSON 往返](#json-round-trip)),因此兩條路徑的結果相同。 From fc9a00b16a246dad93ac6494efba2e18ac2f5dc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 11:13:03 +0000 Subject: [PATCH 3/5] docs: show CacheLock's constructor options and add() for once-per-period jobs (#390) The example now shows blocking, timeout and poll_interval where they live, on the constructor, and a new section explains why a lock does not make a job run once per period and what does. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UTXhK1BtwXTBDaouEcKtpW --- docs/LOCK.md | 41 ++++++++++++++++++++++++++++++++++++++--- i18n/zh-TW/docs/LOCK.md | 31 ++++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/docs/LOCK.md b/docs/LOCK.md index dd36d33..6a20e15 100644 --- a/docs/LOCK.md +++ b/docs/LOCK.md @@ -10,15 +10,22 @@ from fastapi_cachex import CacheLock, LockTimeoutError async with CacheLock(f"report:{report_id}", ttl=30): ... # one holder at a time, as long as the work fits in the ttl -# Explicit acquire and release calls: -lock = CacheLock(f"stream:{user_id}", ttl=60) # or ttl=timedelta(minutes=1) -if not await lock.acquire(blocking=False): +# Explicit acquire and release calls. blocking, timeout and poll_interval are +# constructor arguments; acquire() can override each one per call. ttl also +# takes a timedelta. +lock = CacheLock(f"stream:{user_id}", ttl=60, blocking=False) +if not await lock.acquire(): raise HTTPException(409, detail="Lock already held") try: ... await lock.extend(60) # long-running tasks renew before expiry finally: await lock.release() + +# Wait up to 5 seconds, checking every 100 ms, then give up: +lock = CacheLock(f"report:{report_id}", ttl=30, timeout=5, poll_interval=0.1) +if not await lock.acquire(): + raise HTTPException(503, detail="Try again later") ``` Complete runnable example: [`examples/cache_lock.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/cache_lock.py). @@ -41,4 +48,32 @@ Complete runnable example: [`examples/cache_lock.py`](https://github.com/allen00 > `CacheLock` works on all built-in backends (`MemoryBackend`, `AsyncRedisCacheBackend`, `MemcachedBackend`). > Redis uses `SET NX EX` and Lua scripts, Memcached uses `ADD` and `CAS`, and the memory backend operates under its internal lock. +## Once per period across workers + +A lock is the wrong tool for "run this job once an hour, whichever worker gets +there first": the lock is released when the job ends, and the next worker to +reach the same point takes it and runs the job again. Claim the period instead +with [`CacheManager.add()`](APP_CACHE.md), which stores a key only if it is +absent, atomically, and let the key expire with the period: + +```python +from datetime import datetime, timezone + +from fastapi_cachex import AppCache + + +async def send_digest_once_per_hour(cache: AppCache) -> None: + hour = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H") + if await cache.add(f"digest:{hour}", True, ttl=3600): + # Exactly one worker gets True for this hour, however many call this. + await send_digest() +``` + +The key names the period, so a worker that starts late in the hour finds it +taken, and the TTL only has to outlive the period. If the job can fail, delete +the key in that case (`await cache.delete(f"digest:{hour}")`) so another +worker may retry; otherwise the period is simply skipped. Use a `CacheLock` +when the job must not run concurrently but should run again as soon as the +previous run is over. + The full class signature and options are documented in the [API reference](api/lock.md). diff --git a/i18n/zh-TW/docs/LOCK.md b/i18n/zh-TW/docs/LOCK.md index 3d70679..7d02a9e 100644 --- a/i18n/zh-TW/docs/LOCK.md +++ b/i18n/zh-TW/docs/LOCK.md @@ -10,15 +10,21 @@ from fastapi_cachex import CacheLock, LockTimeoutError async with CacheLock(f"report:{report_id}", ttl=30): ... # 同一時間只有一個持有者,前提是工作在 ttl 內完成 -# 明確呼叫 acquire 與 release: -lock = CacheLock(f"stream:{user_id}", ttl=60) # 或 ttl=timedelta(minutes=1) -if not await lock.acquire(blocking=False): +# 明確呼叫 acquire 與 release。blocking、timeout 與 poll_interval 是建構子的 +# 參數;acquire() 可以在每次呼叫時個別覆寫。ttl 也接受 timedelta。 +lock = CacheLock(f"stream:{user_id}", ttl=60, blocking=False) +if not await lock.acquire(): raise HTTPException(409, detail="Lock already held") try: ... await lock.extend(60) # 長時間執行的工作在過期前續約 finally: await lock.release() + +# 最多等 5 秒,每 100 毫秒檢查一次,之後放棄: +lock = CacheLock(f"report:{report_id}", ttl=30, timeout=5, poll_interval=0.1) +if not await lock.acquire(): + raise HTTPException(503, detail="Try again later") ``` 完整可執行範例(英文):[`examples/cache_lock.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/cache_lock.py)。 @@ -40,4 +46,23 @@ finally: > [!NOTE] > `CacheLock` 可用於所有內建後端(`MemoryBackend`、`AsyncRedisCacheBackend`、`MemcachedBackend`)。Redis 使用 `SET NX EX` 與 Lua 腳本,Memcached 使用 `ADD` 與 `CAS`,記憶體後端則在其內部鎖之內操作。 +## 跨 worker 每個週期只執行一次 {#once-per-period-across-workers} + +「這個工作每小時只跑一次,哪個 worker 先到就由它跑」不適合用鎖:工作結束時鎖就會釋放,下一個走到同一處的 worker 會取得鎖並再跑一次。請改用 [`CacheManager.add()`](APP_CACHE.md) 來認領這個週期,它只在鍵不存在時才以原子操作寫入,再讓鍵隨週期過期: + +```python +from datetime import datetime, timezone + +from fastapi_cachex import AppCache + + +async def send_digest_once_per_hour(cache: AppCache) -> None: + hour = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H") + if await cache.add(f"digest:{hour}", True, ttl=3600): + # 不論有多少 worker 呼叫,這一小時只有一個會得到 True。 + await send_digest() +``` + +鍵的名稱就是週期,因此在該小時較晚才啟動的 worker 會發現它已被認領,而 TTL 只要比週期長即可。若工作可能失敗,請在失敗時刪除這個鍵(`await cache.delete(f"digest:{hour}")`),讓其他 worker 可以重試;否則這個週期就會直接略過。只有在工作不能同時執行、但前一次結束後應該立刻可以再跑時,才使用 `CacheLock`。 + 完整的類別簽章與選項請見 [API 參考](https://fastapi-cachex.readthedocs.io/en/latest/api/lock/)(英文)。 From f6da810389a22a7316bacc0d0b3a2778f734ed33 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 11:13:03 +0000 Subject: [PATCH 4/5] docs: group invalidation recipe for CacheManager on Memcached (#389) A generation counter per group, read with increment(key, 0) and bumped to invalidate, makes a whole group unreachable without enumerating keys; old entries leave through their TTL. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UTXhK1BtwXTBDaouEcKtpW --- docs/APP_CACHE.md | 42 ++++++++++++++++++++++++++++++++++++ i18n/zh-TW/docs/APP_CACHE.md | 33 ++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/docs/APP_CACHE.md b/docs/APP_CACHE.md index a8f5f22..c4ac2e3 100644 --- a/docs/APP_CACHE.md +++ b/docs/APP_CACHE.md @@ -145,6 +145,48 @@ cached wherever it is called: > on Memcached: `MemcachedBackend.clear()` issues `flush_all` and wipes the whole > server, HTTP responses, sessions, locks and other applications' keys included. +### Group invalidation on Memcached {#group-invalidation-on-memcached} + +Without key enumeration, the way to drop a group of keys at once is to stop +looking for them: keep a generation number for the group, put it into every +key of the group, and bump it to invalidate. The old entries are never read +again and leave through their TTL. + +```python +from fastapi_cachex import AppCache, CacheBackend +from fastapi_cachex.backends.base import BaseCacheBackend + +# A counter in the backend. It is read through the backend, not the manager, +# so it carries no "cache:" prefix. +USERS_GENERATION = "gen:users" + + +async def user_key(backend: BaseCacheBackend, user_id: int) -> str: + # increment(..., 0) reads the counter atomically, creating it at 0. + generation = await backend.increment(USERS_GENERATION, 0) + return f"user:{generation}:{user_id}" + + +@app.get("/users/{user_id}") +async def read_user(user_id: int, cache: AppCache, backend: CacheBackend): + key = await user_key(backend, user_id) + return await cache.get_or_set(key, lambda: load_user(user_id), ttl=300) + + +@app.post("/users/import") +async def import_users(backend: CacheBackend): + await run_import() + # Every user::* entry is unreachable from now on. + await backend.increment(USERS_GENERATION) +``` + +This works on every backend and costs one extra backend read per lookup. +Give the entries a TTL, since nothing deletes them. The counter itself has +none; if the backend can lose it (Memcached evicts under memory pressure, +and `flush_all` clears everything), bump it by a large step instead, +`increment(USERS_GENERATION, int(time.time()))`, so a counter that restarts +at 0 never reuses a generation that live entries may still carry. + ## Stampede protection When the factory is expensive (a slow database query, a rate-limited upstream diff --git a/i18n/zh-TW/docs/APP_CACHE.md b/i18n/zh-TW/docs/APP_CACHE.md index b606389..914e950 100644 --- a/i18n/zh-TW/docs/APP_CACHE.md +++ b/i18n/zh-TW/docs/APP_CACHE.md @@ -79,6 +79,39 @@ await manager.clear_pattern("user:*") # 比對 "myapp:user:*" > [!NOTE] > `CacheManager.clear()`/`clear_prefix()` 是以後端的 `get_all_keys()` 與 `delete_many()` 實作(在 Redis 上是每批 100 個鍵的 `DEL`)。由於 Memcached 不支援列舉鍵(見[後端](BACKENDS.md#memcached)),這些方法以及 `CacheManager.clear_pattern()` 在 Memcached 後端上不會有任何作用,只會回傳 0 並發出 `RuntimeWarning`;`get()`/`set()`/`add()`/`delete()`/`has()` 則照常運作。若需要大量清除,請使用 Redis 或記憶體後端。不要在 Memcached 上改用後端本身的 `clear()`:`MemcachedBackend.clear()` 會發出 `flush_all`,清空整台伺服器,包括 HTTP 回應、Session、鎖以及其他應用程式的鍵。 +### 在 Memcached 上讓一組鍵失效 {#group-invalidation-on-memcached} + +無法列舉鍵時,一次讓一組鍵失效的方法是不再去找它們:為這一組保留一個世代(generation)編號,把它放進這一組的每個鍵裡,要失效時就把編號加一。舊項目從此不會再被讀到,並隨 TTL 自然消失。 + +```python +from fastapi_cachex import AppCache, CacheBackend +from fastapi_cachex.backends.base import BaseCacheBackend + +# 存放在後端的計數器。它透過後端而非 manager 讀取,所以沒有 "cache:" 前綴。 +USERS_GENERATION = "gen:users" + + +async def user_key(backend: BaseCacheBackend, user_id: int) -> str: + # increment(..., 0) 以原子操作讀取計數器,不存在時從 0 建立。 + generation = await backend.increment(USERS_GENERATION, 0) + return f"user:{generation}:{user_id}" + + +@app.get("/users/{user_id}") +async def read_user(user_id: int, cache: AppCache, backend: CacheBackend): + key = await user_key(backend, user_id) + return await cache.get_or_set(key, lambda: load_user(user_id), ttl=300) + + +@app.post("/users/import") +async def import_users(backend: CacheBackend): + await run_import() + # 從此之後,所有 user:<舊世代>:* 的項目都讀不到了。 + await backend.increment(USERS_GENERATION) +``` + +這在每個後端上都可行,代價是每次查詢多一次後端讀取。請為項目設定 TTL,因為不會有任何東西刪除它們。計數器本身沒有 TTL;若後端可能弄丟它(Memcached 在記憶體不足時會淘汰項目,`flush_all` 則會清空一切),請改以較大的步幅遞增,例如 `increment(USERS_GENERATION, int(time.time()))`,這樣從 0 重新開始的計數器就不會重複用到仍有存活項目的世代。 + ## Cache stampede 保護 {#stampede-protection} 當 `factory` 的運算成本很高(例如慢速資料庫查詢、受速率限制的外部 API)且該鍵又是熱門鍵時,快取過期會導致多個請求同時重新計算。以 `CacheLock` 實作的分散式 cache stampede 保護預設開啟;你可以在單次呼叫或 manager 全域調整或關閉它(`@cache` 路由請見[同時發生的未命中](HTTP_CACHING.md#concurrent-misses)): From d5ebf42306054f0658d5f5021429ebdedc20d944 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 11:13:03 +0000 Subject: [PATCH 5/5] docs: when to invalidate() and when to clear_path() after a write (#388) invalidate() drops the one key built from the request; other query strings and Vary variants of the same resource keep the old data. clear_path(path, include_params=True) drops every variant, on backends that can enumerate keys. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UTXhK1BtwXTBDaouEcKtpW --- docs/HTTP_CACHING.md | 31 +++++++++++++++++++++++++++++++ i18n/zh-TW/docs/HTTP_CACHING.md | 19 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index 55b6294..7d9126e 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -889,6 +889,37 @@ variant selected by the request's own header values is deleted, and as `@cache` sorts it, so `?b=2&a=1` deletes the entry stored for `?a=1&b=2`; for a route with `sort_query=False` pass `sort_query=False` here too. +### After a write: `invalidate()` or `clear_path()` + +`invalidate()` deletes exactly one key, the one built from the request you +pass. After `PUT /items/42` that is the entry for `GET /items/42` with no +query string and, on a route with `vary`, the one variant the request's +headers select. Entries that show the same item under another key keep +serving the old data until they expire: `/items/42?fields=name`, a list at +`/items?page=2` that includes it, and the `Accept-Language: fr` variant when +you invalidated with `en`. + +- `invalidate(request)` when the resource has one entry, or you want to drop + just that one. One backend operation, and it works on Memcached. +- `clear_path(path, include_params=True)` when the resource has several + entries: it removes every entry stored for the path, across query strings, + `vary` variants, hosts and methods. Call it for the item and for the lists + that include it. Redis and memory only: Memcached cannot enumerate keys, so + there you either name every key for `invalidate()` or change what the key + contains (see the [generation-counter recipe](APP_CACHE.md#group-invalidation-on-memcached)). + +```python +from fastapi_cachex import CacheBackend + + +@app.put("/items/{item_id}") +async def update_item(item_id: int, cache: CacheBackend): + await save(item_id) + # Every variant of the item, then every page of the list. + await cache.clear_path(f"/items/{item_id}", include_params=True) + await cache.clear_path("/items", include_params=True) +``` + ## Monitoring routes `add_routes()` mounts two read-only endpoints that report what is currently in diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index e1824e8..c6fcf00 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -505,6 +505,25 @@ async def update_item(item_id: int, request: Request): `invalidate(request, key_builder=None, vary=None, *, sort_query=None)` 在項目存在且已移除時回傳 `True`,否則回傳 `False`,包括尚未設定後端的情況。後端本身的錯誤則會拋給呼叫端(見[後端發生錯誤時](#when-the-backend-fails))。傳入的請求必須能產生快取路由的鍵:相同的方法、主機、路徑與查詢字串。如果快取路由使用自訂的 `key_builder`、`vary` 或 `sort_query`,這裡也要傳入相同的值,否則鍵不會相符:`invalidate()` 無法從路由讀取這些設定。使用 `vary` 時,只會刪除請求本身的標頭值所選中的變體,`clear_path()` 則會移除所有變體。預設情況下,請求的查詢會以 `@cache` 相同的方式排序,因此 `?b=2&a=1` 會刪除為 `?a=1&b=2` 儲存的項目;對設定了 `sort_query=False` 的路由,這裡也要傳入 `sort_query=False`。 +### 寫入之後:`invalidate()` 還是 `clear_path()` {#after-a-write-invalidate-or-clear_path} + +`invalidate()` 只刪除一個鍵:由你傳入的請求組出的那一個。`PUT /items/42` 之後,它刪的是 `GET /items/42` 不帶查詢字串的項目;在設定了 `vary` 的路由上,則是請求標頭所選中的那一個變體。以其他鍵呈現同一筆資料的項目會繼續提供舊資料,直到過期為止:`/items/42?fields=name`、包含它的 `/items?page=2` 清單,以及你用 `en` 失效時的 `Accept-Language: fr` 變體。 + +- 資源只有一個項目,或你只想刪掉那一個時,用 `invalidate(request)`。只需一次後端操作,而且在 Memcached 上也可用。 +- 資源有多個項目時,用 `clear_path(path, include_params=True)`:它會移除該路徑儲存的所有項目,不論查詢字串、`vary` 變體、主機與方法。請對該筆資料本身與包含它的清單都呼叫一次。只支援 Redis 與記憶體後端:Memcached 無法列舉鍵,在它上面只能把每個鍵都交給 `invalidate()`,或改變鍵的組成(見[世代計數器的做法](APP_CACHE.md#group-invalidation-on-memcached))。 + +```python +from fastapi_cachex import CacheBackend + + +@app.put("/items/{item_id}") +async def update_item(item_id: int, cache: CacheBackend): + await save(item_id) + # 先清該筆資料的所有變體,再清清單的每一頁。 + await cache.clear_path(f"/items/{item_id}", include_params=True) + await cache.clear_path("/items", include_params=True) +``` + ## 監控路由 {#monitoring-routes} `add_routes()` 會掛載兩個唯讀端點,回報後端目前的內容: