diff --git a/changelog.d/71.changed.md b/changelog.d/71.changed.md index 9ee52ae..e133cea 100644 --- a/changelog.d/71.changed.md +++ b/changelog.d/71.changed.md @@ -1,9 +1,10 @@ **`BaseCacheBackend.delete()` returns whether a key was removed.** The memory, Redis and Memcached backends return `True` when the key held an entry and `False` otherwise; on memory an entry that had already expired counts as -absent, as it does on Redis. The base `delete_many()` fallback now counts only -the keys that existed, and the non-atomic `get_and_delete()` and -`delete_if_equals()` fallbacks use the result, so a caller whose delete found -the key already gone no longer wins as well. A third-party backend that declares `-> None` fails type -checking and should return the result instead. See the -[migration guide](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_4/#backend-delete). +absent, as it does on Redis. The non-atomic fallbacks on the base class use the +result: `delete_many()` counts only the keys that existed, and +`get_and_delete()` and `delete_if_equals()` let a caller win only if its delete +removed the key. A third-party backend whose `delete()` still declares +`-> None` fails type checking; at runtime the fallbacks count `None` as +removed, as 0.3.x did, and emit a `FutureWarning`, and 0.5.0 treats it as +`False`. See the [migration guide](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_4/#backend-delete). diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index 2d1c0b3..5432c6e 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -335,7 +335,9 @@ if await backend.set_if_absent(f"stream:{user_id}", owner, ttl=300): All six have a non-atomic fallback on `BaseCacheBackend`, so a third-party backend that only implements the abstract methods keeps working; override them to get -real atomicity. +real atomicity. The fallbacks rely on `delete()` returning whether the key held +an entry; a `delete()` that still returns `None`, as in 0.3.x, warns and counts +as removed until 0.5.0 (see [delete() return value](MIGRATING_0_4.md#backend-delete)). Complete runnable example: [`examples/rate_limit.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/rate_limit.py). diff --git a/docs/MIGRATING_0_4.md b/docs/MIGRATING_0_4.md index dae2f97..81455e0 100644 --- a/docs/MIGRATING_0_4.md +++ b/docs/MIGRATING_0_4.md @@ -43,7 +43,7 @@ Every warning below names the setting to change and links to its issue. `FutureW | `SessionConfig.use_bearer_token` stays deprecated, removed in 0.5.0 | [#377](https://github.com/allen0099/FastAPI-CacheX/issues/377) | `DeprecationWarning` | [Token sources](#token-source-priority) | | `UserSessionDep` requires a user | [#127](https://github.com/allen0099/FastAPI-CacheX/issues/127) | No | [UserSessionDep](#user-session-dep) | | `memcache` extra removed | [#202](https://github.com/allen0099/FastAPI-CacheX/issues/202) | No | [memcache extra](#memcache-extra) | -| `BaseCacheBackend.delete()` returns `bool` | [#71](https://github.com/allen0099/FastAPI-CacheX/issues/71) | No | [delete() return value](#backend-delete) | +| `BaseCacheBackend.delete()` returns `bool` | [#71](https://github.com/allen0099/FastAPI-CacheX/issues/71) | No (0.4.0 warns if it returns `None`: `FutureWarning`) | [delete() return value](#backend-delete) | | `CacheEntry.headers` becomes a tuple of pairs | [#105](https://github.com/allen0099/FastAPI-CacheX/issues/105) | No | [Repeated headers](#cache-entry-headers) | | HTTP cache key format | [#271](https://github.com/allen0099/FastAPI-CacheX/issues/271), [#270](https://github.com/allen0099/FastAPI-CacheX/issues/270), [#269](https://github.com/allen0099/FastAPI-CacheX/issues/269), [#266](https://github.com/allen0099/FastAPI-CacheX/issues/266), [#265](https://github.com/allen0099/FastAPI-CacheX/issues/265), [#72](https://github.com/allen0099/FastAPI-CacheX/issues/72) | No | [Cache keys](#cache-keys) | | Conditional session writes | [#128](https://github.com/allen0099/FastAPI-CacheX/issues/128) | No | [Session writes](#session-writes) | @@ -341,7 +341,7 @@ await backend.clear_pattern("GET|||*") # "http:v2|GET|*" with 0.4.0's key forma ### delete() return value {#backend-delete} -`BaseCacheBackend.delete()` returns whether a key was removed in 0.4.0, instead of `None` ([#71](https://github.com/allen0099/FastAPI-CacheX/issues/71)), and the base `delete_many()` fallback counts the keys that existed rather than the ones attempted. A third-party backend that returns `None` fails type checking and makes that count wrong. 0.3.9 does not warn: a subclass cannot declare `-> bool` today without a type error against the 0.3.x base class, and nobody uses the `None`. +`BaseCacheBackend.delete()` returns whether a key was removed in 0.4.0, instead of `None` ([#71](https://github.com/allen0099/FastAPI-CacheX/issues/71)), and the non-atomic fallbacks on the base class use the result: `delete_many()` counts the keys that existed rather than the ones attempted, and `get_and_delete()` and `delete_if_equals()` let a caller win only if its delete removed the key. A third-party backend that still declares `-> None` fails type checking; at runtime the fallbacks count `None` as removed, as 0.3.x did, and emit a `FutureWarning`. 0.5.0 treats `None` as `False`. 0.3.9 does not warn: a subclass cannot declare `-> bool` there without a type error against the 0.3.x base class. ```python # Before diff --git a/fastapi_cachex/backends/base.py b/fastapi_cachex/backends/base.py index 26c5140..3cc1a1f 100644 --- a/fastapi_cachex/backends/base.py +++ b/fastapi_cachex/backends/base.py @@ -156,10 +156,34 @@ async def delete_many(self, keys: Iterable[str]) -> int: """ count = 0 for key in keys: - if await self.delete(key): + if await self._delete_reporting(key): count += 1 return count + async def _delete_reporting(self, key: str) -> bool: + """Call ``delete`` for the fallbacks, accepting a 0.3.x ``None`` result. + + ``delete`` returned ``None`` before 0.4.0, and a third-party backend + written then may still do so. ``None`` counts as removed, as every + fallback assumed in 0.3.x, and warns: 0.5.0 will treat it as ``False``. + ``FutureWarning`` rather than ``DeprecationWarning``: the warning is + raised inside the package, where a ``DeprecationWarning`` is hidden by + default, and the change affects the application at runtime. + """ + result: object = await self.delete(key) + if result is None: + warnings.warn( + f"{type(self).__name__}.delete() returned None. Since " + "fastapi-cachex 0.4.0 it must return whether the key was " + "removed; None is counted as removed until 0.5.0, which treats " + "it as False. See https://fastapi-cachex.readthedocs.io/en/" + "stable/MIGRATING_0_4/#backend-delete", + FutureWarning, + stacklevel=3, + ) + return True + return bool(result) + async def get_and_delete(self, key: str) -> CacheEntry | None: """Atomically retrieve and remove a cached entry. @@ -175,7 +199,7 @@ async def get_and_delete(self, key: str) -> CacheEntry | None: The entry that was stored under ``key``, or ``None`` if there was none """ value = await self.get(key) - if value is None or not await self.delete(key): + if value is None or not await self._delete_reporting(key): # Absent, or another caller removed it between the get and the # delete: that caller got the entry. return None @@ -231,7 +255,7 @@ async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool: """ if await self.get(key) != expected: return False - return await self.delete(key) + return await self._delete_reporting(key) async def expire_if_equals(self, key: str, expected: CacheEntry, ttl: int) -> bool: """Update expiry on ``key`` to ``ttl`` seconds only while it still holds ``expected``. diff --git a/i18n/zh-TW/docs/BACKENDS.md b/i18n/zh-TW/docs/BACKENDS.md index 114c42d..7770801 100644 --- a/i18n/zh-TW/docs/BACKENDS.md +++ b/i18n/zh-TW/docs/BACKENDS.md @@ -199,7 +199,7 @@ if await backend.set_if_absent(f"stream:{user_id}", owner, ttl=300): - `expire_if_equals(key, expected, ttl) -> bool`:只在 `key` 仍存放 `expected` 時,才把它的 TTL 更新為 `ttl` 秒,因此長時間執行的鎖持有者可以續約租期,而不會在鎖已過期時動到別人的鎖。記憶體後端在鎖內更新,Redis 先在 Python 中比較,再執行 Lua 腳本(`GET` 比較 + `EXPIRE`),Memcached 則使用 `GETS` + 以新 exptime 寫回相同位元組的 `CAS`(`TOUCH` 不接受 CAS 權杖)。 - `set_if_equals(key, expected, value, ttl=None) -> bool`:只在 `key` 仍存放 `expected` 時才儲存 `value`。這是 compare-and-set:若呼叫端讀取之後有任何操作變更、刪除了該鍵,或它已過期,寫入就會失敗。Session 透過它儲存(見 [Session 寫入](MIGRATING_0_4.md#session-writes))。記憶體後端在鎖內比較,Redis 先在 Python 中比較,再執行 Lua 腳本(`GET` 比較 + `SET`,設定了 `ttl` 時加上 `EX`),Memcached 則使用 `GETS` + 寫入新值的 `CAS`。 -這六個方法在 `BaseCacheBackend` 上都有非原子性的後備實作,因此只實作抽象方法的第三方後端仍可正常運作;覆寫它們才能得到真正的原子性。 +這六個方法在 `BaseCacheBackend` 上都有非原子性的後備實作,因此只實作抽象方法的第三方後端仍可正常運作;覆寫它們才能得到真正的原子性。這些後備實作依賴 `delete()` 回傳鍵是否存有項目;仍像 0.3.x 一樣回傳 `None` 的 `delete()` 會發出警告,並在 0.5.0 之前視為已移除(見 [delete() 的回傳值](MIGRATING_0_4.md#backend-delete))。 完整可執行範例(英文):[`examples/rate_limit.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/rate_limit.py)。 diff --git a/i18n/zh-TW/docs/MIGRATING_0_4.md b/i18n/zh-TW/docs/MIGRATING_0_4.md index 2589329..95fe0b2 100644 --- a/i18n/zh-TW/docs/MIGRATING_0_4.md +++ b/i18n/zh-TW/docs/MIGRATING_0_4.md @@ -43,7 +43,7 @@ filterwarnings = [ | `SessionConfig.use_bearer_token` 維持已棄用,0.5.0 移除 | [#377](https://github.com/allen0099/FastAPI-CacheX/issues/377) | `DeprecationWarning` | [權杖來源](#token-source-priority) | | `UserSessionDep` 需要使用者 | [#127](https://github.com/allen0099/FastAPI-CacheX/issues/127) | 否 | [UserSessionDep](#user-session-dep) | | 移除 `memcache` extra | [#202](https://github.com/allen0099/FastAPI-CacheX/issues/202) | 否 | [memcache extra](#memcache-extra) | -| `BaseCacheBackend.delete()` 回傳 `bool` | [#71](https://github.com/allen0099/FastAPI-CacheX/issues/71) | 否 | [delete() 的回傳值](#backend-delete) | +| `BaseCacheBackend.delete()` 回傳 `bool` | [#71](https://github.com/allen0099/FastAPI-CacheX/issues/71) | 否(回傳 `None` 時 0.4.0 會發出 `FutureWarning`) | [delete() 的回傳值](#backend-delete) | | `CacheEntry.headers` 改為成對值的 tuple | [#105](https://github.com/allen0099/FastAPI-CacheX/issues/105) | 否 | [重複的標頭](#cache-entry-headers) | | HTTP 快取鍵格式 | [#271](https://github.com/allen0099/FastAPI-CacheX/issues/271)、[#270](https://github.com/allen0099/FastAPI-CacheX/issues/270)、[#269](https://github.com/allen0099/FastAPI-CacheX/issues/269)、[#266](https://github.com/allen0099/FastAPI-CacheX/issues/266)、[#265](https://github.com/allen0099/FastAPI-CacheX/issues/265)、[#72](https://github.com/allen0099/FastAPI-CacheX/issues/72) | 否 | [快取鍵](#cache-keys) | | 有條件的 Session 寫入 | [#128](https://github.com/allen0099/FastAPI-CacheX/issues/128) | 否 | [Session 寫入](#session-writes) | @@ -341,7 +341,7 @@ await backend.clear_pattern("GET|||*") # 在 0.4.0 的鍵格式下為 "http:v2| ### delete() 的回傳值 {#backend-delete} -0.4.0 起 `BaseCacheBackend.delete()` 回傳是否移除了鍵,而不是 `None`([#71](https://github.com/allen0099/FastAPI-CacheX/issues/71)),基底類別的 `delete_many()` 後備實作也改為計算實際存在的鍵,而不是嘗試刪除的鍵。回傳 `None` 的第三方後端無法通過型別檢查,也會讓這個計數出錯。0.3.9 不會警告:子類別目前無法宣告 `-> bool` 而不與 0.3.x 的基底類別產生型別錯誤,而且沒有人使用這個 `None`。 +0.4.0 起 `BaseCacheBackend.delete()` 回傳是否移除了鍵,而不是 `None`([#71](https://github.com/allen0099/FastAPI-CacheX/issues/71)),基底類別的非原子性後備實作也會使用這個結果:`delete_many()` 改為計算實際存在的鍵,而不是嘗試刪除的鍵;`get_and_delete()` 與 `delete_if_equals()` 只在呼叫者的刪除確實移除了鍵時才算成功。仍宣告 `-> None` 的第三方後端無法通過型別檢查;執行時,後備實作會像 0.3.x 一樣把 `None` 視為已移除,並發出 `FutureWarning`。0.5.0 會把 `None` 視為 `False`。0.3.9 不會警告:子類別在該版本無法宣告 `-> bool` 而不與 0.3.x 的基底類別產生型別錯誤。 ```python # 修改前 diff --git a/tests/backends/test_base.py b/tests/backends/test_base.py index 3d0c31d..09e067e 100644 --- a/tests/backends/test_base.py +++ b/tests/backends/test_base.py @@ -199,3 +199,36 @@ async def fail_inside() -> None: await fail_inside() assert backend.closed == 1 + + +class LegacyDictBackend(DictBackend): + """A backend written against 0.3.x, whose ``delete`` returns ``None``.""" + + async def delete(self, key: str) -> None: # type: ignore[override] + self.store.pop(key, None) + + +@pytest.mark.parametrize( + ("call", "expected"), + [ + (lambda b, e: b.get_and_delete("k"), "entry"), + (lambda b, e: b.delete_if_equals("k", e), True), + (lambda b, e: b.delete_many(["k"]), 1), + ], + ids=["get_and_delete", "delete_if_equals", "delete_many"], +) +async def test_fallbacks_count_a_none_delete_as_removed_and_warn( + call: Any, expected: object +) -> None: + """0.3.x semantics until 0.5.0: the key is gone, so the caller won.""" + backend = LegacyDictBackend() + entry = CacheEntry(fingerprint="e", content=b"v") + await backend.set("k", entry) + + with pytest.warns( + FutureWarning, match=r"LegacyDictBackend\.delete\(\) returned None" + ): + result = await call(backend, entry) + + assert result == (entry if expected == "entry" else expected) + assert "k" not in backend.store