From 9ed0f5aa613453a3fcf7d0ebae5f1a8ce223e066 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Mon, 28 Sep 2026 03:16:05 +0000 Subject: [PATCH] fix(backends): warn on every Memcached clear_path() call Memcached cannot enumerate keys, so clear_path() only deletes a key named exactly as the path and never matches an HTTP cache key (method|||host|||path|||query). Only include_params=True warned, so the default call returned 0 silently and a response stayed cached after a write. Warn on every call and point to invalidate(request), which rebuilds the route's exact key; document that in BACKENDS.md and HTTP_CACHING.md. Closes #320 --- changelog.d/320.fixed.md | 1 + docs/BACKENDS.md | 6 ++- docs/CACHE_FLOW.md | 3 +- docs/HTTP_CACHING.md | 4 ++ examples/http_cache.py | 1 + fastapi_cachex/backends/memcached.py | 34 ++++++------ i18n/zh-TW/docs/BACKENDS.md | 2 +- i18n/zh-TW/docs/CACHE_FLOW.md | 3 +- i18n/zh-TW/docs/HTTP_CACHING.md | 2 + tests/backends/test_memcached.py | 81 ++++++++++++++++++++++++++-- 10 files changed, 112 insertions(+), 25 deletions(-) create mode 100644 changelog.d/320.fixed.md diff --git a/changelog.d/320.fixed.md b/changelog.d/320.fixed.md new file mode 100644 index 0000000..58cd149 --- /dev/null +++ b/changelog.d/320.fixed.md @@ -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. diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index 7dc5ea2..7787eb1 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -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 diff --git a/docs/CACHE_FLOW.md b/docs/CACHE_FLOW.md index 286e61a..0af14d5 100644 --- a/docs/CACHE_FLOW.md +++ b/docs/CACHE_FLOW.md @@ -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 diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index 1c4e340..c349d44 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -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 diff --git a/examples/http_cache.py b/examples/http_cache.py index e317df4..73c9954 100644 --- a/examples/http_cache.py +++ b/examples/http_cache.py @@ -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]} diff --git a/fastapi_cachex/backends/memcached.py b/fastapi_cachex/backends/memcached.py index 50b01ee..7df4d7d 100644 --- a/fastapi_cachex/backends/memcached.py +++ b/fastapi_cachex/backends/memcached.py @@ -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) diff --git a/i18n/zh-TW/docs/BACKENDS.md b/i18n/zh-TW/docs/BACKENDS.md index 0223c7d..6c4dff7 100644 --- a/i18n/zh-TW/docs/BACKENDS.md +++ b/i18n/zh-TW/docs/BACKENDS.md @@ -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)) diff --git a/i18n/zh-TW/docs/CACHE_FLOW.md b/i18n/zh-TW/docs/CACHE_FLOW.md index e34e65c..26fd263 100644 --- a/i18n/zh-TW/docs/CACHE_FLOW.md +++ b/i18n/zh-TW/docs/CACHE_FLOW.md @@ -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 執行緒中執行,並使用連線 diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index 53ebbe3..209824f 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -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} diff --git a/tests/backends/test_memcached.py b/tests/backends/test_memcached.py index 0fea524..017be1c 100644 --- a/tests/backends/test_memcached.py +++ b/tests/backends/test_memcached.py @@ -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 @@ -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 @@ -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 @@ -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)) @@ -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", [ @@ -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", @@ -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) @@ -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}