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
1 change: 1 addition & 0 deletions changelog.d/320.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**`MemcachedBackend.clear_path()` warns on every call, not only with `include_params=True`.** Memcached cannot enumerate keys, so `clear_path()` deletes only a key named exactly as the path and never an HTTP cache entry; the default call used to return `0` silently, leaving a response cached after a write. The warning and BACKENDS.md now point to `invalidate(request)`, which drops a cached route's entry on every backend.
6 changes: 5 additions & 1 deletion docs/BACKENDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,11 @@ BackendProxy.set(backend)
- Pattern-based key clearing (`clear_pattern`) is not supported by the Memcached protocol
- Keys cannot be enumerated: `get_all_keys()`/`get_cache_data()` return empty
results (with a `RuntimeWarning`), so the monitoring routes show nothing
- `clear_path()` deletes only the exact key given; `include_params` has no effect
- `clear_path()` cannot find HTTP cache entries: it deletes only a key named exactly
as the path, ignores `include_params` and emits a `RuntimeWarning` on every call.
To drop a cached route's entry after a write, call
[`invalidate(request)`](HTTP_CACHING.md#invalidating-a-single-cached-route), which
rebuilds the exact key
- `clear()` issues `flush_all`, which wipes the whole Memcached server, not just this namespace
- A key Memcached would reject (over 250 bytes, whitespace, non-ASCII) is stored
under its SHA-256 digest
Expand Down
3 changes: 2 additions & 1 deletion docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,8 @@ value: the JSON document above
# get_cache_data() are no-ops that return 0/[]/{} and emit a RuntimeWarning;
# CacheManager.clear()/clear_prefix() therefore do nothing on this backend
# - clear_path() only deletes a key exactly equal to the given path, so it
# cannot clear HTTP route entries
# cannot clear HTTP route entries, and it emits a RuntimeWarning on every
# call; use invalidate(request) to drop a cached route's entry
# - clear() issues flush_all, which wipes the ENTIRE Memcached server (not just
# this key prefix) and emits a RuntimeWarning
# - The synchronous pymemcache client runs in worker threads, with connection
Expand Down
4 changes: 4 additions & 0 deletions docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,10 @@ pattern written as a bare path (for example `clear_pattern("/api/users/*")`)
cannot match an HTTP key; when such a call clears nothing it emits a
`RuntimeWarning` pointing you to `clear_path()`.

On Memcached, which cannot enumerate keys, `clear_path()` cannot find HTTP
entries at all: it deletes only a key named exactly as the path and emits a
`RuntimeWarning` on every call. Use `invalidate()` (below) there instead.

What each backend supports is listed under [Backends](BACKENDS.md).

### Invalidating a single cached route
Expand Down
1 change: 1 addition & 0 deletions examples/http_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ async def update_product(
if product_id not in PRODUCTS:
raise HTTPException(status_code=404, detail="Unknown product")
PRODUCTS[product_id]["price"] = price
# Memcached cannot match paths; there, use invalidate() (HTTP_CACHING.md).
await cache_backend.clear_path(f"/products/{product_id}")
return {"id": product_id, **PRODUCTS[product_id]}

Expand Down
34 changes: 18 additions & 16 deletions fastapi_cachex/backends/memcached.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,30 +429,32 @@ async def clear(self) -> None:
logger.debug("Memcached CLEAR; flush_all issued")

async def clear_path(self, path: str, include_params: bool = False) -> int:
"""Clear cached responses for a specific path.
"""Delete the key that is exactly ``path``; warns on every call.

Note: Memcached does not support pattern-based queries, so this
only deletes the key that is exactly ``path``. HTTP route keys
(``method|||host|||path|||query``) are not matched. For path-based
clearing, use the Redis or memory backend.
Memcached cannot enumerate keys, so this cannot find HTTP route keys
(``method|||host|||path|||query``): it only deletes a key stored under
the literal name ``path``, and ``include_params`` has no effect. It
warns every time, because on this backend ``clear_path()`` after a write
would otherwise leave the cached response in place without a sign. Use
``invalidate(request)`` to drop a ``@cache`` route's entry, or the Redis
or memory backend for path-based clearing.

Args:
path: The exact key to delete
include_params: Unsupported; emits a ``RuntimeWarning`` and is
otherwise ignored
include_params: Unsupported; ignored

Returns:
Number of cache entries cleared (0 or 1 for exact match only)
"""
if include_params:
warnings.warn(
"Memcached backend does not support pattern-based key clearing. "
"Only exact key matches can be deleted. "
"The include_params option has no effect. "
"Consider using Redis backend for pattern support.",
RuntimeWarning,
stacklevel=2,
)
warnings.warn(
"Memcached backend does not support pattern-based key clearing, so "
"clear_path() cannot remove HTTP cache entries "
"(method|||host|||path|||query): it only deletes a key named "
"exactly as the path, and include_params has no effect. Use "
"invalidate(request) to drop a cached route's entry.",
RuntimeWarning,
stacklevel=2,
)

# Try to delete the prefixed key (exact match only)
prefixed_key = self._make_key(path)
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 @@ -94,7 +94,7 @@ BackendProxy.set(backend)

- Memcached 協定不支援依模式清除鍵(`clear_pattern`)
- 無法列舉鍵:`get_all_keys()`/`get_cache_data()` 會回傳空結果(並發出 `RuntimeWarning`),因此監控路由不會顯示任何內容
- `clear_path()` 只會刪除完全相符的那個鍵;`include_params` 沒有作用
- `clear_path()` 找不到 HTTP 快取項目:它只會刪除名稱與路徑完全相同的鍵,忽略 `include_params`,而且每次呼叫都會發出 `RuntimeWarning`。資料變更後要刪除某個快取路由的項目,請呼叫 [`invalidate(request)`](HTTP_CACHING.md#invalidating-a-single-cached-route),它會重建完全相同的鍵
- `clear()` 會發出 `flush_all`,清空整台 Memcached 伺服器,而不只是這個命名空間
- Memcached 會拒絕的鍵(超過 250 位元組、含空白字元或非 ASCII 字元)會改以其 SHA-256 摘要儲存
- 過期時間落在 2038-01-19 之後的 `ttl` 會拋出 `ValueError`(見 [TTL 值](#ttl-values))
Expand Down
3 changes: 2 additions & 1 deletion i18n/zh-TW/docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,8 @@ value: 上述的 JSON 文件
# get_cache_data() 都是 no-op,回傳 0/[]/{} 並發出 RuntimeWarning;
# 因此 CacheManager.clear()/clear_prefix() 在此後端上不會有任何作用
# - clear_path() 只會刪除與指定路徑完全相同的快取鍵,因此
# 無法清除 HTTP 路由的項目
# 無法清除 HTTP 路由的項目,而且每次呼叫都會發出 RuntimeWarning;
# 要刪除快取路由的項目請使用 invalidate(request)
# - clear() 會送出 flush_all,清空「整個」Memcached 伺服器(不只是
# 這個快取鍵前綴)並發出 RuntimeWarning
# - 同步的 pymemcache 用戶端在 worker 執行緒中執行,並使用連線
Expand Down
2 changes: 2 additions & 0 deletions i18n/zh-TW/docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,8 @@ async def clear(cache: CacheBackend) -> None:

`clear_path()` 會比對該路徑在所有方法與主機下的項目。只寫成路徑的模式(例如 `clear_pattern("/api/users/*")`)無法比對到 HTTP 鍵;這類呼叫沒有清除任何項目時,會發出 `RuntimeWarning`,提示你改用 `clear_path()`。

Memcached 無法列舉鍵,因此 `clear_path()` 完全找不到 HTTP 項目:它只會刪除名稱與路徑完全相同的鍵,而且每次呼叫都會發出 `RuntimeWarning`。在 Memcached 上請改用下方的 `invalidate()`。

各後端支援的功能列於[後端](BACKENDS.md)。

### 使單一快取路由失效 {#invalidating-a-single-cached-route}
Expand Down
81 changes: 76 additions & 5 deletions tests/backends/test_memcached.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@

import pytest
import pytest_asyncio
from fastapi import FastAPI
from fastapi.testclient import TestClient
from starlette.requests import Request as StarletteRequest

from fastapi_cachex import BackendProxy
from fastapi_cachex import cache
from fastapi_cachex import invalidate
from fastapi_cachex.backends import MemcachedBackend
from fastapi_cachex.backends.codec import encode_entry
from fastapi_cachex.backends.memcached import _CAS_MAX_RETRIES
Expand Down Expand Up @@ -139,7 +145,8 @@ async def test_memcached_clear_path(memcached_backend: MemcachedBackend):
await memcached_backend.set(path, value)

# Test clearing the exact path
cleared = await memcached_backend.clear_path(path, include_params=False)
with pytest.warns(RuntimeWarning, match="does not support pattern-based"):
cleared = await memcached_backend.clear_path(path, include_params=False)
assert cleared == 1 # Should clear the exact path match

# Verify the path is cleared
Expand Down Expand Up @@ -167,7 +174,8 @@ async def test_memcached_clear_path_not_match(memcached_backend: MemcachedBacken
assert other_value is None

# Test clearing a non-matching path
cleared = await memcached_backend.clear_path(other_path, include_params=False)
with pytest.warns(RuntimeWarning, match="does not support pattern-based"):
cleared = await memcached_backend.clear_path(other_path, include_params=False)
assert cleared == 0 # Should return 0 as the path does not match


Expand Down Expand Up @@ -256,10 +264,29 @@ async def test_memcached_clear_path_raises_when_the_server_is_unreachable() -> N
closed_port = probe.getsockname()[1]
backend = MemcachedBackend(servers=[f"127.0.0.1:{closed_port}"])

with pytest.raises(ConnectionRefusedError):
with (
pytest.warns(RuntimeWarning, match="clear_path"),
pytest.raises(ConnectionRefusedError),
):
await backend.clear_path("/nope")


@pytest.mark.parametrize("include_params", [False, True])
async def test_memcached_clear_path_always_warns(include_params: bool) -> None:
"""Without the warning, `clear_path()` after a write fails silently (#320).

Only ``include_params=True`` used to warn, yet the default call cannot
match an HTTP route key either.
"""
backend = stubbed_backend()
backend.client.delete.return_value = False

with pytest.warns(RuntimeWarning, match=r"invalidate\(request\)") as record:
assert await backend.clear_path("/products/1", include_params) == 0

assert record[0].filename == __file__


def _unreachable_backend() -> MemcachedBackend:
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
Expand All @@ -270,6 +297,12 @@ def _unreachable_backend() -> MemcachedBackend:
_ENTRY = CacheEntry(fingerprint="f", content=b"x")


async def _clear_path(backend: MemcachedBackend) -> int:
"""`clear_path()` warns on every call (#320), before it reaches the server."""
with pytest.warns(RuntimeWarning, match="clear_path"):
return await backend.clear_path("/k")


@pytest.mark.parametrize(
"call",
[
Expand All @@ -282,7 +315,7 @@ def _unreachable_backend() -> MemcachedBackend:
lambda b: b.expire_if_equals("k", _ENTRY, 5),
lambda b: b.delete_many(["a", "b"]),
lambda b: b.delete("k"),
lambda b: b.clear_path("/k"),
_clear_path,
],
ids=[
"get",
Expand Down Expand Up @@ -342,7 +375,10 @@ def boom(*args, **kwargs) -> None:
raise RuntimeError(msg)

monkeypatch.setattr(memcached_backend.client, "delete", boom)
with pytest.raises(RuntimeError, match="delete failed"):
with (
pytest.warns(RuntimeWarning, match="clear_path"),
pytest.raises(RuntimeError, match="delete failed"),
):
await memcached_backend.clear_path("/nope", include_params=False)


Expand Down Expand Up @@ -1073,3 +1109,38 @@ async def test_memcached_aclose_closes_every_pooled_socket() -> None:
not server.client_pool.free and not server.client_pool.used
for server in backend.client.clients.values()
)


@requires_memcached
async def test_memcached_invalidate_drops_what_clear_path_cannot(
memcached_backend: MemcachedBackend,
) -> None:
"""The #320 report: after a write, only `invalidate()` removes the entry."""
BackendProxy.set(memcached_backend)
app = FastAPI()
price = {"value": 9.99}

@app.get("/products/1")
@cache(ttl=60)
async def product() -> dict[str, float]:
return {"price": price["value"]}

client = TestClient(app)
client.get("/products/1")
price["value"] = 5.0

with pytest.warns(RuntimeWarning, match="clear_path"):
assert await memcached_backend.clear_path("/products/1") == 0
assert client.get("/products/1").json() == {"price": 9.99}

request = StarletteRequest(
{
"type": "http",
"method": "GET",
"path": "/products/1",
"query_string": b"",
"headers": [(b"host", b"testserver")],
}
)
assert await invalidate(request) is True
assert client.get("/products/1").json() == {"price": 5.0}
Loading