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
13 changes: 7 additions & 6 deletions changelog.d/71.changed.md
Original file line number Diff line number Diff line change
@@ -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).
4 changes: 3 additions & 1 deletion docs/BACKENDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
4 changes: 2 additions & 2 deletions docs/MIGRATING_0_4.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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
Expand Down
30 changes: 27 additions & 3 deletions fastapi_cachex/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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``.
Expand Down
2 changes: 1 addition & 1 deletion i18n/zh-TW/docs/BACKENDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。

Expand Down
4 changes: 2 additions & 2 deletions i18n/zh-TW/docs/MIGRATING_0_4.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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
# 修改前
Expand Down
33 changes: 33 additions & 0 deletions tests/backends/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading