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
56 changes: 55 additions & 1 deletion docs/APP_CACHE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -133,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:<old generation>:* 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
Expand Down
7 changes: 5 additions & 2 deletions docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
54 changes: 54 additions & 0 deletions docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -866,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
Expand Down
41 changes: 38 additions & 3 deletions docs/LOCK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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).
42 changes: 41 additions & 1 deletion i18n/zh-TW/docs/APP_CACHE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<prefix><key>`(預設為 `lock:cache:user:42`)。
- `get_or_set()` 在未命中與命中時都回傳經 JSON 解碼後的值(見 [JSON 往返](#json-round-trip)),因此兩條路徑的結果相同。
Expand All @@ -72,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)):
Expand Down
2 changes: 1 addition & 1 deletion i18n/zh-TW/docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
Loading
Loading