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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ def build_report() -> dict:

@app.get("/report")
async def report(cache: AppCache):
# Cache any JSON value in your own code. lock=True runs build_report once
# for concurrent misses (the default from 0.4.0).
return await cache.get_or_set("report", build_report, ttl=300, lock=True)
# Cache any JSON value in your own code. Concurrent misses run
# build_report once: get_or_set() locks by default.
return await cache.get_or_set("report", build_report, ttl=300)
```

> [!IMPORTANT]
Expand Down
8 changes: 8 additions & 0 deletions changelog.d/280.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
**`CacheManager.get_or_set()` uses stampede protection by default.**
`CacheManager(lock=...)` defaults to `True`, including the manager `AppCache`
creates, so concurrent misses of one key run `factory` once while the other
callers wait for its result. A miss costs six backend round trips instead of
two on Redis and Memcached, and each waiting caller polls; a hit is unchanged.
Pass `lock=False` to `get_or_set()` or `CacheManager()` to keep computing on
every miss. `CacheManager(lock=None)` now raises `TypeError`. See the
[migration guide](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_4/#get-or-set-lock).
30 changes: 19 additions & 11 deletions docs/APP_CACHE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ async def expensive_operation(cache: AppCache):
return result


# Or instantiate directly, e.g. outside of a request. Pass `lock` explicitly:
# its default turns from False to True in 0.4.0 (see "Stampede protection").
manager = CacheManager(key_prefix="myapp:", default_ttl=60, lock=False)
# Or instantiate directly, e.g. outside of a request. `lock` defaults to True
# (see "Stampede protection").
manager = CacheManager(key_prefix="myapp:", default_ttl=60)
await manager.set("user:42", {"name": "Alice"})
user = await manager.get("user:42") # {"name": "Alice"}
await manager.delete("user:42")
Expand All @@ -46,8 +46,8 @@ Complete runnable example: [`examples/app_cache.py`](https://github.com/allen009
- `get()` returns `None` (or a supplied `default=`) on a cache miss — it never
raises for missing or corrupted entries.
- `set()` lets `TypeError` propagate for values that are not JSON-serializable.
- `get_or_set()` supports opt-in stampede protection via `lock=True` (or
manager-level default `CacheManager(lock=True)`), preventing concurrent misses
- `get_or_set()` uses stampede protection by default (`lock=True`, per call
or manager-wide with `CacheManager(lock=...)`), preventing concurrent misses
from running `factory` simultaneously, with graceful fallback to computing
directly if waiting times out. The distributed lock key is
`lock:<prefix><key>` (`lock:cache:user:42` by default).
Expand Down Expand Up @@ -97,23 +97,23 @@ Complete runnable example: [`examples/app_cache.py`](https://github.com/allen009

When the factory is expensive (a slow database query, a rate-limited upstream
API) and the key is hot, cache expiry turns into simultaneous recomputations.
You can enable distributed stampede protection built on `CacheLock` either
per-call or manager-wide:
Distributed stampede protection built on `CacheLock` is on by default; you can
tune or turn it off per call or manager-wide:

```python
# Per-call protection:
profile = await manager.get_or_set(
"user:42",
lambda: load_user(42),
ttl=300,
lock=True,
lock=True, # default: the manager's lock, True unless set otherwise
lock_ttl=30, # lease upper bound for factory run (default: 60)
wait_timeout=10, # caller latency budget in seconds (default: None, wait while held)
raise_on_timeout=False, # True raises LockTimeoutError, False falls back to factory (default: False)
)

# Or manager-wide default:
manager = CacheManager(lock=True, lock_ttl=60)
manager = CacheManager(lock=False) # compute on every concurrent miss
```

1. **Miss**: On a miss, callers attempt non-blocking lock acquisition using `CacheLock` under the key `lock:<prefix><key>` (`lock:cache:user:42` by default).
Expand All @@ -125,9 +125,17 @@ manager = CacheManager(lock=True, lock_ttl=60)

Ensure `lock_ttl` exceeds the expected execution time of `factory`. If `factory` outlives `lock_ttl`, the lock expires mid-run and a waiting caller may start a second computation.

### The default changes in 0.4.0
### Cost {#cost}

Stampede protection is off by default in 0.3.x and **on by default from 0.4.0**. A `get_or_set()` call that passes no `lock=`, on a manager created without `lock=` (including the one `AppCache` creates for you), emits a `FutureWarning` once per manager. Pass `lock=False` to keep the current behaviour or `lock=True` to opt in now, either per call or to `CacheManager(...)`; for `AppCache`, register your own manager with `CacheManagerProxy.set(CacheManager(lock=...))`. See [Migrating to 0.4.0](MIGRATING_0_4.md#get-or-set-lock).
Stampede protection is on by default since 0.4.0 ([#280](https://github.com/allen0099/FastAPI-CacheX/issues/280)), including the manager `AppCache` creates. It costs backend round trips on a miss; a hit is one `GET` either way. Counted on Redis and Memcached, where each step is a network round trip:

| Path | `lock=False` | `lock=True` |
|------|--------------|-------------|
| Hit | 1: `GET` | 1: `GET` |
| Miss, no other caller | 2: `GET`, `SET` | 6: `GET`, lock `SET NX` (Memcached `add`), re-check `GET`, `SET`, and the lock release, which reads the lock and deletes it only if it still holds this caller's token (2) |
| Each waiting caller | none: it runs `factory` itself | 2 at the start (`GET`, lock attempt), then 2 per poll (`GET`, lock attempt) until the value appears |

Waiters poll with the backoff above: about five polls in the first second, then one every 500 ms, so each waiter adds about four backend operations per second for as long as `factory` runs. The memory backend does the same steps in process. Pass `lock=False` to a call, or to `CacheManager(...)`, for keys where a duplicate computation is cheaper than the extra round trips; for `AppCache`, register your own manager with `CacheManagerProxy.set(CacheManager(lock=False))`.

## JSON round-trip

Expand Down
2 changes: 1 addition & 1 deletion docs/MIGRATING_0_4.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ SessionConfig(secret_key=SECRET, token_source_priority=["header", "cookie"])

### get_or_set lock {#get-or-set-lock}

`CacheManager.get_or_set()` turns stampede protection on by default in 0.4.0 ([#280](https://github.com/allen0099/FastAPI-CacheX/issues/280)): on concurrent misses of one key, only one caller runs `factory`, and the others wait for its result. This adds backend round trips on a miss (see [Stampede protection](APP_CACHE.md#stampede-protection)). In 0.3.9, a `get_or_set()` call that passes no `lock=`, on a manager created without `lock=`, emits a `FutureWarning` once per manager. That includes the manager `AppCache` creates for you.
`CacheManager.get_or_set()` turns stampede protection on by default in 0.4.0 ([#280](https://github.com/allen0099/FastAPI-CacheX/issues/280)): on concurrent misses of one key, only one caller runs `factory`, and the others wait for its result. This adds backend round trips on a miss: six instead of two on Redis and Memcached when no other caller waits, plus two per poll for each waiting caller (see [Cost](APP_CACHE.md#cost)). A hit is unchanged. In 0.3.9, a `get_or_set()` call that passes no `lock=`, on a manager created without `lock=`, emits a `FutureWarning` once per manager. That includes the manager `AppCache` creates for you. `CacheManager(lock=None)`, which meant "not chosen" in 0.3.9, raises `TypeError`; pass a bool. `get_or_set(lock=None)` still inherits the manager's setting. Waiting callers now block until the first caller's `factory` returns, bounded by `lock_ttl` (60 s by default); pass `wait_timeout=` to cap that wait (see [Stampede protection](APP_CACHE.md#stampede-protection)). While `factory` runs, the backend holds a short-lived lock key, `lock:<prefix><key>`, which `get_all_keys()` and the monitoring routes list.

Before:

Expand Down
6 changes: 3 additions & 3 deletions examples/app_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@
backend = MemoryBackend()
BackendProxy.set(backend)
# Optional: without this, AppCache creates a CacheManager with the defaults
# (key prefix "cache:", no TTL) on first use. lock=True runs the factory once for
# concurrent misses of the same key; it becomes the default in 0.4.0.
CacheManagerProxy.set(CacheManager(key_prefix="example:", default_ttl=300, lock=True))
# (key prefix "cache:", no TTL, lock=True) on first use. The lock runs the
# factory once for concurrent misses of the same key.
CacheManagerProxy.set(CacheManager(key_prefix="example:", default_ttl=300))


@asynccontextmanager
Expand Down
43 changes: 8 additions & 35 deletions fastapi_cachex/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def __init__(
key_prefix: str = "cache:",
default_ttl: int | None = None,
*,
lock: bool | None = None,
lock: bool = True,
lock_ttl: int = 60,
) -> None:
r"""Initialize CacheManager.
Expand All @@ -114,17 +114,15 @@ def __init__(
default_ttl: Default TTL (seconds) applied when set() is called
without an explicit ttl. None means no expiry by default.
lock: Whether get_or_set() uses distributed locking by default to
prevent cache stampedes. ``None`` (the default) means ``False``
in 0.3.x, and ``get_or_set()`` emits a ``FutureWarning`` the
first time it relies on it: the default becomes ``True`` in
0.4.0. Pass ``False`` or ``True`` explicitly to keep the
current behaviour or opt in now.
prevent cache stampedes (default: True). Pass ``False`` to
compute on every concurrent miss without the extra backend
round trips.
lock_ttl: Default TTL in seconds for stampede protection locks (default: 60).

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 ``default_ttl``
TypeError: If ``lock`` is not a bool, 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.
Expand All @@ -139,16 +137,13 @@ def __init__(
self.key_prefix = key_prefix
self.default_ttl = validate_ttl(default_ttl)

_validate_lock(lock, allow_none=True)
_validate_lock(lock)

effective_lock_ttl = validate_ttl(lock_ttl)
if effective_lock_ttl is None:
msg = "lock_ttl must be a positive int, got None"
raise ValueError(msg)
self.lock: bool = bool(lock)
# Whether get_or_set() still has to warn that the lock default flips
# in 0.4.0 (#280): only while neither the manager nor the call chose.
self._warn_lock_default = lock is None
self.lock: bool = lock
self.lock_ttl: int = effective_lock_ttl

if self._prefix_has_glob:
Expand Down Expand Up @@ -422,10 +417,7 @@ async def get_or_set( # noqa: PLR0913
ttl: Time-to-live in seconds for a newly created value. If None,
uses ``self.default_ttl``.
lock: Whether to use distributed locking for stampede protection.
If None, inherits the manager's ``lock`` setting. When the
manager was created without ``lock=`` either, the first such
call emits a ``FutureWarning``: the default becomes ``True``
in 0.4.0.
If None, inherits the manager's ``lock`` setting.
lock_ttl: Upper bound in seconds for the lock lease. If None,
inherits the manager's ``lock_ttl``.
wait_timeout: Maximum seconds waiting callers poll the cache before
Expand All @@ -447,29 +439,10 @@ async def get_or_set( # noqa: PLR0913
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:
FutureWarning: Once per manager, when neither this call nor the
manager's constructor passed ``lock``: the default becomes
``True`` in 0.4.0.
"""
validated_wait_timeout = _validate_get_or_set_args(
lock, ttl, lock_ttl, wait_timeout
)
if lock is None and self._warn_lock_default:
self._warn_lock_default = False
warnings.warn(
"CacheManager.get_or_set() is relying on the default lock=False: "
"neither the call nor CacheManager(...) passed lock=. Version "
"0.4.0 turns stampede protection on by default (lock=True). "
"Pass lock=False to keep the current behaviour, or lock=True to "
"opt in now, to get_or_set() or to CacheManager() (for AppCache, "
"register one with CacheManagerProxy.set()) "
"(https://github.com/allen0099/FastAPI-CacheX/issues/280).",
FutureWarning,
stacklevel=2,
)

cached = await self.get(key, default=_SENTINEL)
if cached is not _SENTINEL:
return cached
Expand Down
26 changes: 17 additions & 9 deletions i18n/zh-TW/docs/APP_CACHE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ async def expensive_operation(cache: AppCache):
return result


# 也可以直接建立實例,例如在請求之外使用。請明確傳入 `lock`:
# 它的預設值會在 0.4.0 從 False 改為 True(見「Cache stampede 保護」)。
manager = CacheManager(key_prefix="myapp:", default_ttl=60, lock=False)
# 也可以直接建立實例,例如在請求之外使用。`lock` 預設為 True
# (見「Cache stampede 保護」)。
manager = CacheManager(key_prefix="myapp:", default_ttl=60)
await manager.set("user:42", {"name": "Alice"})
user = await manager.get("user:42") # {"name": "Alice"}
await manager.delete("user:42")
Expand All @@ -43,7 +43,7 @@ await manager.clear_pattern("user:*") # 比對 "myapp:user:*"

- `get()` 在快取未命中時回傳 `None`(或你提供的 `default=`),遇到不存在或損毀的項目也絕不會拋出例外。
- `set()` 遇到無法 JSON 序列化的值時,會讓 `TypeError` 直接往外拋出。
- `get_or_set()` 支援以 `lock=True`(或 manager 全域設定 `CacheManager(lock=True)`)選用 cache stampede 保護,避免多次並行未命中時同時執行 `factory`,若等待超時則具備直接計算的優雅降級回退。分散式鎖的鍵名格式為 `lock:<prefix><key>`(預設為 `lock:cache:user:42`)。
- `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)),因此兩條路徑的結果相同。
- `add()` 只在鍵尚未被占用時寫入值,並回傳是否有寫入。檢查與寫入是同一個後端原子操作(`set_if_absent`),因此適合「每個鍵只做一次」的工作,例如 webhook 或電子郵件的去重。已過期的鍵視為未被占用;存放無法解碼之值的鍵則不算,即使 `get()` 會把它當成未命中。
- 鍵預設位於獨立、以 `cache:` 為前綴的命名空間,與 HTTP 路由快取及 OAuth state 分開,因此 `clear()`/`clear_prefix()` 絕不會動到無關的快取項目。
Expand All @@ -56,22 +56,22 @@ await manager.clear_pattern("user:*") # 比對 "myapp:user:*"

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

當 `factory` 的運算成本很高(例如慢速資料庫查詢、受速率限制的外部 API)且該鍵又是熱門鍵時,快取過期會導致多個請求同時重新計算。你可以透過 `CacheLock` 在單次呼叫或 manager 全域啟用分散式 cache stampede 保護:
當 `factory` 的運算成本很高(例如慢速資料庫查詢、受速率限制的外部 API)且該鍵又是熱門鍵時,快取過期會導致多個請求同時重新計算。以 `CacheLock` 實作的分散式 cache stampede 保護預設開啟;你可以在單次呼叫或 manager 全域調整或關閉它:

```python
# 單次呼叫保護:
profile = await manager.get_or_set(
"user:42",
lambda: load_user(42),
ttl=300,
lock=True,
lock=True, # 預設:沿用 manager 的 lock,未另行設定時為 True
lock_ttl=30, # factory 執行的租約上限(秒,預設:60)
wait_timeout=10, # 呼叫者等待的延遲預算(秒,預設:None,持鎖期間持續等待)
raise_on_timeout=False, # True 拋出 LockTimeoutError,False 回退至執行 factory(預設:False)
)

# 或 manager 全域預設:
manager = CacheManager(lock=True, lock_ttl=60)
manager = CacheManager(lock=False) # 每次並行未命中都各自計算
```

1. **未命中**:未命中時,呼叫者嘗試使用 `CacheLock` 進行非阻塞式取鎖,鎖鍵名稱為 `lock:<prefix><key>`(預設為 `lock:cache:user:42`)。
Expand All @@ -83,9 +83,17 @@ manager = CacheManager(lock=True, lock_ttl=60)

請確保 `lock_ttl` 超過 `factory` 的預期執行時間。若 `factory` 執行時間超過 `lock_ttl`,鎖會在執行途中過期,導致等待中的呼叫者發起第二次計算。

### 0.4.0 的預設值變更 {#the-default-changes-in-040}
### 成本 {#cost}

Cache stampede 保護在 0.3.x 預設關閉,**0.4.0 起預設開啟**。若 `get_or_set()` 呼叫沒有傳入 `lock=`,而 manager 建立時也沒有傳入 `lock=`(包括 `AppCache` 自動建立的 manager),每個 manager 會發出一次 `FutureWarning`。傳入 `lock=False` 可保留目前的行為,傳入 `lock=True` 則現在就啟用,可以針對單次呼叫或傳給 `CacheManager(...)`;使用 `AppCache` 時,請以 `CacheManagerProxy.set(CacheManager(lock=...))` 註冊自己的 manager。請參閱[遷移至 0.4.0](MIGRATING_0_4.md#get-or-set-lock)。
0.4.0 起 cache stampede 保護預設開啟([#280](https://github.com/allen0099/FastAPI-CacheX/issues/280)),`AppCache` 自動建立的 manager 也一樣。它會讓未命中時多出後端往返;命中時兩者都只有一次 `GET`。以下以 Redis 與 Memcached 計算,每一步都是一次網路往返:

| 路徑 | `lock=False` | `lock=True` |
|------|--------------|-------------|
| 命中 | 1:`GET` | 1:`GET` |
| 未命中,沒有其他呼叫者 | 2:`GET`、`SET` | 6:`GET`、取鎖的 `SET NX`(Memcached 為 `add`)、再次檢查的 `GET`、`SET`,以及釋放鎖;釋放時會先讀取鎖,只有鎖仍持有此呼叫者的權杖時才刪除(2) |
| 每個等待中的呼叫者 | 無:自己執行 `factory` | 開始時 2 次(`GET`、嘗試取鎖),之後每次輪詢 2 次(`GET`、嘗試取鎖),直到值出現 |

等待者依上述退避輪詢:第一秒約五次,之後每 500 ms 一次,因此在 `factory` 執行期間,每個等待者每秒約多出四次後端操作。記憶體後端在行程內執行相同的步驟。對重複計算比額外往返更便宜的鍵,請在單次呼叫或 `CacheManager(...)` 傳入 `lock=False`;使用 `AppCache` 時,請以 `CacheManagerProxy.set(CacheManager(lock=False))` 註冊自己的 manager。

## JSON 往返 {#json-round-trip}

Expand Down
Loading
Loading