diff --git a/README.md b/README.md index b94f452..3ce1747 100644 --- a/README.md +++ b/README.md @@ -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] diff --git a/changelog.d/280.changed.md b/changelog.d/280.changed.md new file mode 100644 index 0000000..3b89dff --- /dev/null +++ b/changelog.d/280.changed.md @@ -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). diff --git a/docs/APP_CACHE.md b/docs/APP_CACHE.md index 4fee405..0cf9a7e 100644 --- a/docs/APP_CACHE.md +++ b/docs/APP_CACHE.md @@ -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") @@ -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:` (`lock:cache:user:42` by default). @@ -97,8 +97,8 @@ 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: @@ -106,14 +106,14 @@ 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:` (`lock:cache:user:42` by default). @@ -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 diff --git a/docs/MIGRATING_0_4.md b/docs/MIGRATING_0_4.md index ae8ebe0..d570333 100644 --- a/docs/MIGRATING_0_4.md +++ b/docs/MIGRATING_0_4.md @@ -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:`, which `get_all_keys()` and the monitoring routes list. Before: diff --git a/examples/app_cache.py b/examples/app_cache.py index d59998e..da9d1d0 100644 --- a/examples/app_cache.py +++ b/examples/app_cache.py @@ -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 diff --git a/fastapi_cachex/manager.py b/fastapi_cachex/manager.py index a0ab325..1bbfb78 100644 --- a/fastapi_cachex/manager.py +++ b/fastapi_cachex/manager.py @@ -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. @@ -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. @@ -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: @@ -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 @@ -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 diff --git a/i18n/zh-TW/docs/APP_CACHE.md b/i18n/zh-TW/docs/APP_CACHE.md index d72b47b..fa951e0 100644 --- a/i18n/zh-TW/docs/APP_CACHE.md +++ b/i18n/zh-TW/docs/APP_CACHE.md @@ -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") @@ -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:`(預設為 `lock:cache:user:42`)。 +- `get_or_set()` 預設使用 cache stampede 保護(`lock=True`,可針對單次呼叫或以 `CacheManager(lock=...)` 全域設定),避免多次並行未命中時同時執行 `factory`,若等待超時則具備直接計算的優雅降級回退。分散式鎖的鍵名格式為 `lock:`(預設為 `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()` 絕不會動到無關的快取項目。 @@ -56,7 +56,7 @@ await manager.clear_pattern("user:*") # 比對 "myapp:user:*" ## Cache stampede 保護 {#stampede-protection} -當 `factory` 的運算成本很高(例如慢速資料庫查詢、受速率限制的外部 API)且該鍵又是熱門鍵時,快取過期會導致多個請求同時重新計算。你可以透過 `CacheLock` 在單次呼叫或 manager 全域啟用分散式 cache stampede 保護: +當 `factory` 的運算成本很高(例如慢速資料庫查詢、受速率限制的外部 API)且該鍵又是熱門鍵時,快取過期會導致多個請求同時重新計算。以 `CacheLock` 實作的分散式 cache stampede 保護預設開啟;你可以在單次呼叫或 manager 全域調整或關閉它: ```python # 單次呼叫保護: @@ -64,14 +64,14 @@ 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:`(預設為 `lock:cache:user:42`)。 @@ -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} diff --git a/i18n/zh-TW/docs/MIGRATING_0_4.md b/i18n/zh-TW/docs/MIGRATING_0_4.md index cc17021..0f6379a 100644 --- a/i18n/zh-TW/docs/MIGRATING_0_4.md +++ b/i18n/zh-TW/docs/MIGRATING_0_4.md @@ -219,7 +219,7 @@ SessionConfig(secret_key=SECRET, token_source_priority=["header", "cookie"]) ### get_or_set 的鎖 {#get-or-set-lock} -0.4.0 起,`CacheManager.get_or_set()` 預設開啟 cache stampede 保護([#280](https://github.com/allen0099/FastAPI-CacheX/issues/280)):同一個鍵同時未命中時,只有一個呼叫者執行 `factory`,其他呼叫者等待它的結果。這會讓未命中時多出幾次後端往返(見 [Cache stampede 保護](APP_CACHE.md#stampede-protection))。在 0.3.9 中,若 `get_or_set()` 呼叫沒有傳入 `lock=`,而 manager 建立時也沒有傳入 `lock=`,每個 manager 會發出一次 `FutureWarning`。`AppCache` 自動建立的 manager 也包含在內。 +0.4.0 起,`CacheManager.get_or_set()` 預設開啟 cache stampede 保護([#280](https://github.com/allen0099/FastAPI-CacheX/issues/280)):同一個鍵同時未命中時,只有一個呼叫者執行 `factory`,其他呼叫者等待它的結果。這會讓未命中時多出後端往返:沒有其他呼叫者等待時,在 Redis 與 Memcached 上是六次而非兩次,另外每個等待中的呼叫者每次輪詢兩次(見[成本](APP_CACHE.md#cost))。命中時不變。在 0.3.9 中,若 `get_or_set()` 呼叫沒有傳入 `lock=`,而 manager 建立時也沒有傳入 `lock=`,每個 manager 會發出一次 `FutureWarning`。`AppCache` 自動建立的 manager 也包含在內。在 0.3.9 表示「未選擇」的 `CacheManager(lock=None)` 會引發 `TypeError`,請傳入 bool。`get_or_set(lock=None)` 仍沿用 manager 的設定。等待中的呼叫者現在會阻塞到第一個呼叫者的 `factory` 回傳為止,上限為 `lock_ttl`(預設 60 秒);可傳入 `wait_timeout=` 限制等待時間(見 [Cache stampede 保護](APP_CACHE.md#stampede-protection))。`factory` 執行期間,後端會保存一個短期的鎖鍵 `lock:`,`get_all_keys()` 與監控路由都會列出它。 修改前: diff --git a/i18n/zh-TW/docs/index.md b/i18n/zh-TW/docs/index.md index 92f8bc2..f4feb18 100644 --- a/i18n/zh-TW/docs/index.md +++ b/i18n/zh-TW/docs/index.md @@ -66,9 +66,9 @@ def build_report() -> dict: @app.get("/report") async def report(cache: AppCache): - # 在自己的程式碼中快取任意 JSON 值。lock=True 讓同時未命中時只執行一次 - # build_report(0.4.0 起的預設值)。 - return await cache.get_or_set("report", build_report, ttl=300, lock=True) + # 在自己的程式碼中快取任意 JSON 值。同時未命中時只執行一次 + # build_report:get_or_set() 預設會取鎖。 + return await cache.get_or_set("report", build_report, ttl=300) ``` > [!IMPORTANT] diff --git a/tests/test_cache_manager.py b/tests/test_cache_manager.py index 092c01a..ea0cb4f 100644 --- a/tests/test_cache_manager.py +++ b/tests/test_cache_manager.py @@ -66,7 +66,7 @@ async def cache_manager(request: Any) -> AsyncGenerator[CacheManager, Any]: ) BackendProxy.set(backend) - manager = CacheManager(lock=False) + manager = CacheManager() yield manager @@ -225,7 +225,7 @@ async def test_get_or_set_treats_corrupted_content_as_miss( memory_backend: MemoryBackend, ) -> None: """get_or_set() calls factory when stored content can't be decoded.""" - manager = CacheManager(backend=memory_backend, lock=False) + manager = CacheManager(backend=memory_backend) cache_key = f"{manager.key_prefix}bad" entry = CacheEntry(fingerprint="x", content=b"not valid json") await memory_backend.set(cache_key, entry, ttl=60) @@ -264,7 +264,7 @@ async def test_get_or_set_miss_encodes_the_value_once( memory_backend: MemoryBackend, monkeypatch: pytest.MonkeyPatch ) -> None: """get_or_set() serialises the factory's value once and stores those bytes.""" - manager = CacheManager(backend=memory_backend, lock=False) + manager = CacheManager(backend=memory_backend) encoded: list[Any] = [] original = CacheManager._encode @@ -721,10 +721,10 @@ def test_get_app_cache_reuses_existing_proxy_instance( # --- Stampede protection (get_or_set) -------------------------------------------- -async def test_stampede_protection_default_off( +async def test_stampede_protection_default_on( memory_backend: MemoryBackend, ) -> None: - """By default (lock=False), concurrent misses for the same key each invoke factory.""" + """By default (lock=True since 0.4.0, #280), concurrent misses run factory once.""" manager = CacheManager(backend=memory_backend) calls = 0 @@ -734,11 +734,32 @@ async def factory() -> str: await asyncio.sleep(0.02) return "data" - with pytest.warns(FutureWarning, match="default lock=False"): - results = await asyncio.gather( - manager.get_or_set("key", factory), - manager.get_or_set("key", factory), - ) + results = await asyncio.gather( + manager.get_or_set("key", factory), + manager.get_or_set("key", factory), + ) + assert manager.lock is True + assert list(results) == ["data", "data"] + assert calls == 1 + + +async def test_stampede_protection_manager_lock_false( + memory_backend: MemoryBackend, +) -> None: + """With lock=False, concurrent misses for the same key each invoke factory.""" + manager = CacheManager(backend=memory_backend, lock=False) + calls = 0 + + async def factory() -> str: + nonlocal calls + calls += 1 + await asyncio.sleep(0.02) + return "data" + + results = await asyncio.gather( + manager.get_or_set("key", factory), + manager.get_or_set("key", factory), + ) assert list(results) == ["data", "data"] assert calls == 2 @@ -1048,6 +1069,10 @@ async def test_stampede_protection_validation(memory_backend: MemoryBackend) -> with pytest.raises(TypeError, match="lock must be a bool"): CacheManager(backend=memory_backend, lock=1) # type: ignore[arg-type] + # None meant "not chosen" in 0.3.x; the manager-wide default is a bool now. + with pytest.raises(TypeError, match="lock must be a bool, got None"): + CacheManager(backend=memory_backend, lock=None) # type: ignore[arg-type] + with pytest.raises(ValueError, match="ttl must be a positive number"): CacheManager(backend=memory_backend, lock_ttl=0) @@ -1260,9 +1285,9 @@ async def expensive_factory() -> dict[str, str]: return {"status": "ok"} results = await asyncio.gather( - manager.get_or_set("report", expensive_factory, lock=True), - manager.get_or_set("report", expensive_factory, lock=True), - manager.get_or_set("report", expensive_factory, lock=True), + manager.get_or_set("report", expensive_factory), + manager.get_or_set("report", expensive_factory), + manager.get_or_set("report", expensive_factory), ) assert list(results) == [{"status": "ok"}, {"status": "ok"}, {"status": "ok"}] @@ -1441,59 +1466,39 @@ async def sneaky_get(_key: str, default: Any = None) -> Any: assert result == "sneaky_cached" -# --- 0.4.0 lock default advance notice (#280) --------------------------------- +# --- Lock default (#280) -------------------------------------------------------- -async def test_get_or_set_warns_when_relying_on_the_lock_default( +async def test_get_or_set_uses_the_lock_by_default_without_warning( memory_backend: MemoryBackend, ) -> None: - """Neither the manager nor the call chose lock=: the 0.4.0 flip is announced once.""" + """The 0.3.9 notice is gone: relying on the default is silent.""" manager = CacheManager(backend=memory_backend) - assert manager.lock is False - with pytest.warns(FutureWarning, match=r"0\.4\.0 turns stampede protection on"): - assert await manager.get_or_set("a", lambda: 1) == 1 - # Once per manager: later calls relying on the default stay quiet. - assert await manager.get_or_set("b", lambda: 2) == 2 - - -async def test_get_or_set_warning_points_at_the_caller( - memory_backend: MemoryBackend, -) -> None: - manager = CacheManager(backend=memory_backend) - - with pytest.warns(FutureWarning) as record: - await manager.get_or_set("a", lambda: 1) - - assert record[0].filename == __file__ - - -@pytest.mark.parametrize("lock", [False, True]) -async def test_get_or_set_is_silent_with_an_explicit_manager_lock( - memory_backend: MemoryBackend, lock: bool -) -> None: - manager = CacheManager(backend=memory_backend, lock=lock) + # filterwarnings = ["error"] turns a leftover FutureWarning into a failure. - assert manager.lock is lock assert await manager.get_or_set("a", lambda: 1) == 1 + assert manager.lock is True @pytest.mark.parametrize("lock", [False, True]) -async def test_get_or_set_is_silent_with_an_explicit_call_lock( +async def test_get_or_set_keeps_an_explicit_manager_lock( memory_backend: MemoryBackend, lock: bool ) -> None: - manager = CacheManager(backend=memory_backend) - - assert await manager.get_or_set("a", lambda: 1, lock=lock) == 1 - # An explicit call does not use up the warning for a later default call. - with pytest.warns(FutureWarning, match="default lock=False"): - await manager.get_or_set("b", lambda: 2) + """An explicit manager setting decides for calls that pass no ``lock=``.""" + manager = CacheManager(backend=memory_backend, lock=lock) + calls = 0 + async def factory() -> int: + nonlocal calls + calls += 1 + await asyncio.sleep(0.02) + return 1 -async def test_lock_none_is_the_unset_default(memory_backend: MemoryBackend) -> None: - """lock=None means "not chosen": False today, and get_or_set() still warns.""" - manager = CacheManager(backend=memory_backend, lock=None) + results = await asyncio.gather( + manager.get_or_set("a", factory), + manager.get_or_set("a", factory), + ) - assert manager.lock is False - with pytest.warns(FutureWarning, match="default lock=False"): - await manager.get_or_set("a", lambda: 1) + assert list(results) == [1, 1] + assert calls == (1 if lock else 2) diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index 416e027..e5c604b 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -64,6 +64,11 @@ async def test_get_app_cache_falls_back_to_memory_without_a_backend(): assert CacheManagerProxy.get() is manager +async def test_get_app_cache_default_manager_locks(): + """The manager `AppCache` creates follows the 0.4.0 default (#280).""" + assert get_app_cache().lock is True + + async def test_get_app_cache_uses_the_configured_backend(): """A configured backend must not be replaced by the fallback.""" backend = MemoryBackend()