diff --git a/CHANGELOG.md b/CHANGELOG.md index 5def97a..aec316c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -146,7 +146,6 @@ Note that 0.3.3 was never released; 0.3.4 follows 0.3.2. after it raises `SessionNotFoundError`, as after an ordinary `session_ttl` expiry, instead of `SessionExpiredError`. ([#164](https://github.com/allen0099/FastAPI-CacheX/issues/164)) - - **`MemoryBackend` restarts its cleanup task on a new event loop.** The task stayed tied to the loop of the first cache call. If that loop was closed without cancelling it, a backend reused on another loop never cleaned up @@ -160,14 +159,13 @@ Note that 0.3.3 was never released; 0.3.4 follows 0.3.2. `delete_if_equals()` raised `TypeError`. A failed server is now taken out of rotation at once and tried again after one second, instead of after 60. ([#197](https://github.com/allen0099/FastAPI-CacheX/issues/197)) - - **`SessionConfig` warns when `cookie_same_site="none"` is set without `cookie_https_only=True`.** Browsers reject a `SameSite=None` cookie that is not `Secure`, so the session cookie was silently never stored. The combination is still accepted. ([#167](https://github.com/allen0099/FastAPI-CacheX/issues/167)) - -- **Memcached `get_and_delete()` uses CAS deletion to avoid deleting concurrent writes.** +- **Memcached `get_and_delete()` uses CAS deletion to avoid deleting concurrent + writes.** The get-then-delete sequence allowed a concurrent writer to update the key between the two calls, causing `get_and_delete()` to delete the new value while returning the old one. It now issues `gets` and a `cas` write with @@ -176,7 +174,6 @@ Note that 0.3.3 was never released; 0.3.4 follows 0.3.2. `CacheXError` if retries run out. ([#175](https://github.com/allen0099/FastAPI-CacheX/issues/175)) - ## [0.3.7] - 2026-09-25 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 25a08c4..e02f4b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,7 @@ Backend keys are namespaced automatically (default prefix: `fastapi_cachex:`). Four non-abstract atomic primitives live on the base class with non-atomic fallbacks, and every built-in backend overrides them (see `docs/BACKENDS.md` "Atomic backend primitives"): - `increment(key, delta=1, ttl=None) -> int`: fixed-window counter; `ttl` applies only when the counter is created. Redis runs a registered Lua script, Memcached uses `ADD` + `INCR`/`DECR`, memory works under its lock. A counter reads back through `get()` as a `CacheEntry` with `COUNTER_FINGERPRINT` (`types.py`). -- `get_and_delete(key) -> CacheEntry | None`: one-shot retrieval (Redis `GETDEL`, Memcached `gets` + `cas(..., exptime=-1)` with retry). `StateManager.consume_state`, `delete_state`, `CacheManager.delete` and `invalidate()` use it. `delete()` keeps returning `None` for 0.3.x compatibility. +- `get_and_delete(key) -> CacheEntry | None`: one-shot retrieval (Redis `GETDEL`, Memcached `gets` + `cas(..., exptime=-1)`, retried up to 16 times, then `CacheXError`). `StateManager.consume_state`, `delete_state`, `CacheManager.delete` and `invalidate()` use it. `delete()` keeps returning `None` for 0.3.x compatibility. - `set_if_absent(key, value, ttl=None) -> bool`: claim-if-free for locks/slots. Redis `SET NX EX`, Memcached `ADD`, memory under its lock. - `delete_if_equals(key, expected) -> bool`: release only while the key still holds `expected` (compared as decoded `CacheEntry`). Redis compares in Python then deletes via a Lua script that re-checks the raw bytes; Memcached uses `GETS` + `CAS` with exptime `-1` (immediate expiry), since classic `DELETE` has no CAS. diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index 14a75eb..1ec21dc 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -197,7 +197,9 @@ if await backend.set_if_absent(f"stream:{user_id}", owner, ttl=300): `set(key, counter_entry(n))` can be incremented on every backend. - `get_and_delete(key) -> CacheEntry | None` — Memory pops under its lock, Redis uses `GETDEL` (server 6.2+) and Memcached uses `GETS` + a `CAS` write with - `exptime=-1` (retrying if another writer replaced the value in between). + `exptime=-1` (retrying if another writer replaced the value in between). If + writers keep replacing it for 16 attempts in a row, Memcached raises + `CacheXError` rather than report the key as missing. `StateManager.consume_state`, `StateManager.delete_state`, `CacheManager.delete` and `invalidate()` are built on it. - `set_if_absent(key, value, ttl=None) -> bool` — stores `value` only when diff --git a/docs/STATE.md b/docs/STATE.md index 5f1d148..98dc770 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -161,7 +161,8 @@ CacheXError - **The one-time guarantee comes from the backend's atomic operation.** `get_and_delete()` is `GETDEL` on Redis (requires Redis server 6.2 or newer), `gets` followed by `cas(..., exptime=-1)` on Memcached (retrying if another writer replaced the value in between), - and a `pop` under the lock on the memory backend. + and a `pop` under the lock on the memory backend. If writers keep replacing the value for + 16 attempts in a row, Memcached raises `CacheXError` rather than report the state as missing. A custom backend that implements only the abstract methods falls back to `BaseCacheBackend`'s non-atomic version, so a concurrent replay could succeed on both sides. Override `get_and_delete()` in that case. diff --git a/i18n/zh-TW/docs/BACKENDS.md b/i18n/zh-TW/docs/BACKENDS.md index 28df835..643a4c7 100644 --- a/i18n/zh-TW/docs/BACKENDS.md +++ b/i18n/zh-TW/docs/BACKENDS.md @@ -143,7 +143,7 @@ if await backend.set_if_absent(f"stream:{user_id}", owner, ttl=300): ``` - `increment(key, delta=1, ttl=None) -> int`:記憶體後端在鎖內執行讀取—修改—寫入,Redis 執行 Lua 腳本(`EXISTS` + `INCRBY` + `EXPIRE`),Memcached 則使用 `ADD` + `INCR`/`DECR`(Memcached 的計數器最低停在 0)。計數器可透過 `get()` 讀到,形式為 fingerprint 為 `COUNTER_FINGERPRINT`、內容為十進位數值的 `CacheEntry`,因此 `delete`/`clear*` 與監控路由都會把它當成一般項目處理。對存放快取回應的鍵執行 increment 會拋出 `CacheXError`。 -- `get_and_delete(key) -> CacheEntry | None`:記憶體後端在鎖內 pop,Redis 使用 `GETDEL`(伺服器 6.2 以上),Memcached 使用 `GETS` + `exptime=-1` 的 `CAS` 寫入(若中間有其他寫入者替換了值則會重試)。`StateManager.consume_state`、`StateManager.delete_state`、`CacheManager.delete` 與 `invalidate()` 都建立在它之上。 +- `get_and_delete(key) -> CacheEntry | None`:記憶體後端在鎖內 pop,Redis 使用 `GETDEL`(伺服器 6.2 以上),Memcached 使用 `GETS` + `exptime=-1` 的 `CAS` 寫入(若中間有其他寫入者替換了值則會重試;連續 16 次都被替換時會拋出 `CacheXError`,而不是當成鍵不存在)。`StateManager.consume_state`、`StateManager.delete_state`、`CacheManager.delete` 與 `invalidate()` 都建立在它之上。 - `set_if_absent(key, value, ttl=None) -> bool`:只在 `key` 不存在時儲存 `value`(已過期的鍵視為不存在),並回報是否有寫入。記憶體後端在鎖內檢查,Redis 使用 `SET NX EX`,Memcached 使用 `ADD`。 - `delete_if_equals(key, expected) -> bool`:只在 `key` 仍存放 `expected` 時才移除它,因此項目已過期的持有者無法釋放已被他人取得的鎖。請在你儲存的項目中放入唯一的權杖,並以同一個項目釋放。記憶體後端在鎖內比較,Redis 透過 Lua 腳本刪除,並在腳本中重新檢查先前比較過的值,Memcached 則使用 `GETS` + 一個讓項目立即過期的 `CAS` 寫入(傳統協定的 `DELETE` 不接受 CAS 權杖)。 diff --git a/i18n/zh-TW/docs/STATE.md b/i18n/zh-TW/docs/STATE.md index 0f9924a..91b93e6 100644 --- a/i18n/zh-TW/docs/STATE.md +++ b/i18n/zh-TW/docs/STATE.md @@ -128,6 +128,6 @@ CacheXError ## 注意事項 {#notes} - **後端必須在多個行程之間共用。** 多 worker 部署請使用 Redis 或 Memcached。使用 `MemoryBackend` 時,state 只存在於建立它的行程中,因此落到其他 worker 的授權回呼會失敗。 -- **一次性保證來自後端的原子操作。** `get_and_delete()` 在 Redis 上是 `GETDEL`(需要 Redis 伺服器 6.2 或更新版本);在 Memcached 上是 `gets` 後接 `cas(..., exptime=-1)`(若中間有其他寫入者替換了值則會重試);在記憶體後端上則是在鎖內 `pop`。只實作抽象方法的自訂後端會退回使用 `BaseCacheBackend` 的非原子性版本,因此並行的重送可能兩邊都成功。這種情況請覆寫 `get_and_delete()`。 +- **一次性保證來自後端的原子操作。** `get_and_delete()` 在 Redis 上是 `GETDEL`(需要 Redis 伺服器 6.2 或更新版本);在 Memcached 上是 `gets` 後接 `cas(..., exptime=-1)`(若中間有其他寫入者替換了值則會重試;連續 16 次都被替換時會拋出 `CacheXError`,而不是當成 state 不存在);在記憶體後端上則是在鎖內 `pop`。只實作抽象方法的自訂後端會退回使用 `BaseCacheBackend` 的非原子性版本,因此並行的重送可能兩邊都成功。這種情況請覆寫 `get_and_delete()`。 - 不要在 state 中存放敏感資料。`metadata` 會以明文 JSON 存放在快取後端中。 - **日誌中絕不會出現 state 本身。** 來自 `fastapi_cachex.state.manager` 的日誌以 `state_ref` 識別 state,也就是其 SHA-256 的前 12 個十六進位字元;你可以從已知的 state 計算出它來比對。未知或已過期的 state 以 INFO 等級記錄,因為那是常見的用戶端輸入;格式錯誤的儲存資料則以 WARNING 等級記錄一次。