From 8b715db452dc34302c012fa9977922bc34a90788 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Tue, 29 Sep 2026 06:10:36 +0000 Subject: [PATCH] feat(proxy): warn when the MemoryBackend fallback is registered implicitly get_backend_or_fallback() now logs a WARNING from inside the get_or_create factory, so it fires once per registration (once per process unless the proxy is reset). An explicit BackendProxy.set(MemoryBackend()) stays silent. --- changelog.d/327.changed.md | 7 ++ docs/BACKENDS.md | 6 ++ docs/CACHE_FLOW.md | 3 +- docs/HTTP_CACHING.md | 3 +- fastapi_cachex/proxy.py | 13 ++- i18n/zh-TW/docs/BACKENDS.md | 2 + i18n/zh-TW/docs/CACHE_FLOW.md | 2 +- i18n/zh-TW/docs/HTTP_CACHING.md | 2 +- tests/test_fallback_warning.py | 153 ++++++++++++++++++++++++++++++++ 9 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 changelog.d/327.changed.md create mode 100644 tests/test_fallback_warning.py diff --git a/changelog.d/327.changed.md b/changelog.d/327.changed.md new file mode 100644 index 0000000..c717312 --- /dev/null +++ b/changelog.d/327.changed.md @@ -0,0 +1,7 @@ +**The implicit `MemoryBackend` fallback now logs a warning.** When `@cache`, +`CacheBackend` or `AppCache` registers a `MemoryBackend` because no backend was +set, the `fastapi_cachex.proxy` logger logs a `WARNING` once per process: the +cache is per process, so under multiple workers an invalidation reaches only +one worker. Configure a backend with `BackendProxy.set(...)` at startup to +silence it; an explicit `BackendProxy.set(MemoryBackend())` does not warn. The +fallback used to be logged only at `DEBUG`. diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index 7787eb1..4d85d8f 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -24,6 +24,12 @@ This is suitable for development and testing purposes. The backend automatically a cleanup task to remove expired entries every 60 seconds (`MemoryBackend(cleanup_interval=60)`; the interval must be positive). +When `@cache`, `CacheBackend` or `AppCache` registers this fallback because no +backend was set, the `fastapi_cachex.proxy` logger logs a `WARNING` once per +process, since its cache is per process: under multiple workers, invalidation +reaches only one worker. Set the backend explicitly at startup to silence it; +an explicit `MemoryBackend` stays silent: + ```python from fastapi_cachex.backends import MemoryBackend from fastapi_cachex import BackendProxy diff --git a/docs/CACHE_FLOW.md b/docs/CACHE_FLOW.md index 0af14d5..ec5d86b 100644 --- a/docs/CACHE_FLOW.md +++ b/docs/CACHE_FLOW.md @@ -193,7 +193,8 @@ The TTL is not stored in `CacheEntry`: expiry is the backend's responsibility Memcached uses the exptime). If no backend has been configured with `BackendProxy.set()`, the decorator -creates a `MemoryBackend` on the first request and registers it. +creates a `MemoryBackend` on the first request, registers it and logs a +warning that the cache is per process. **Decision logic** (the `cache.py` wrapper, in order): diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index 60687e8..0fd4da8 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -36,7 +36,8 @@ async def non_store_endpoint(): Only GET requests are cached; other methods run the handler as usual. The handler does not need to declare a `Request` parameter — the decorator adds one when it is missing. If no backend has been configured, `@cache` falls back to a -`MemoryBackend` (see [Backends](BACKENDS.md)). +`MemoryBackend` and logs a warning once per process (see +[Backends](BACKENDS.md#in-memory-default)). ### Decorator order diff --git a/fastapi_cachex/proxy.py b/fastapi_cachex/proxy.py index 2e445a8..7cde895 100644 --- a/fastapi_cachex/proxy.py +++ b/fastapi_cachex/proxy.py @@ -154,10 +154,21 @@ def get_backend_or_fallback() -> BaseCacheBackend: `BackendProxy.get_or_create`, so concurrent first callers, including ones on worker threads, all end up with the same fallback instead of each installing its own and overwriting the others. + + Registering the fallback logs a warning. The factory runs under the + proxy's lock only while no backend is set, so that is once per process + (again only if something resets the proxy with `BackendProxy.set(None)`). """ return BackendProxy.get_or_create(_memory_fallback) def _memory_fallback() -> BaseCacheBackend: - logger.debug("No backend configured; using MemoryBackend fallback") + logger.warning( + "No cache backend configured; registering an in-process MemoryBackend. " + "Its cache is per process: under multiple workers, invalidation reaches " + "only one worker and the others keep serving stale entries. Call " + "BackendProxy.set(...) at startup to configure a shared backend, or " + "BackendProxy.set(MemoryBackend()) to keep the in-memory cache without " + "this warning." + ) return MemoryBackend() diff --git a/i18n/zh-TW/docs/BACKENDS.md b/i18n/zh-TW/docs/BACKENDS.md index 6c4dff7..62c4be9 100644 --- a/i18n/zh-TW/docs/BACKENDS.md +++ b/i18n/zh-TW/docs/BACKENDS.md @@ -17,6 +17,8 @@ Redis 與 Memcached 後端會以前綴為鍵建立命名空間(預設為 `fast 若未指定後端,FastAPI-CacheX 預設會使用記憶體快取。這適合開發與測試用途。此後端會自動執行清理工作,每 60 秒移除一次已過期的項目(`MemoryBackend(cleanup_interval=60)`;間隔必須大於 0)。 +當 `@cache`、`CacheBackend` 或 `AppCache` 因為尚未設定後端而註冊這個後備後端時,`fastapi_cachex.proxy` logger 會在每個行程記錄一次 `WARNING`,因為它的快取是每個行程各自一份:在多個 worker 下,快取失效只會傳到其中一個 worker。在啟動時明確設定後端即可消除這個警告;明確設定的 `MemoryBackend` 不會發出警告: + ```python from fastapi_cachex.backends import MemoryBackend from fastapi_cachex import BackendProxy diff --git a/i18n/zh-TW/docs/CACHE_FLOW.md b/i18n/zh-TW/docs/CACHE_FLOW.md index 26fd263..991bc65 100644 --- a/i18n/zh-TW/docs/CACHE_FLOW.md +++ b/i18n/zh-TW/docs/CACHE_FLOW.md @@ -148,7 +148,7 @@ entry = CacheEntry( TTL 不儲存在 `CacheEntry` 中:過期由後端負責(`MemoryBackend` 將它存在 `CacheItem.expiry`,Redis 使用 `SET ... EX`,Memcached 使用 exptime)。 -若尚未以 `BackendProxy.set()` 設定後端,裝飾器會在第一個請求時建立 `MemoryBackend` 並註冊它。 +若尚未以 `BackendProxy.set()` 設定後端,裝飾器會在第一個請求時建立 `MemoryBackend`、註冊它,並記錄一則警告,說明這個快取是每個行程各自一份。 **判斷邏輯**(`cache.py` 的包裝函式,依序執行): diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index 76b87f5..f556588 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -31,7 +31,7 @@ async def non_store_endpoint(): return {"Hello": "World"} ``` -只有 GET 請求會被快取;其他方法照常執行 handler。handler 不需要宣告 `Request` 參數:缺少時裝飾器會自動加上。如果尚未設定任何後端,`@cache` 會改用 `MemoryBackend`(見[後端](BACKENDS.md))。 +只有 GET 請求會被快取;其他方法照常執行 handler。handler 不需要宣告 `Request` 參數:缺少時裝飾器會自動加上。如果尚未設定任何後端,`@cache` 會改用 `MemoryBackend`,並在每個行程記錄一次警告(見[後端](BACKENDS.md#in-memory-default))。 ### 裝飾器順序 {#decorator-order} diff --git a/tests/test_fallback_warning.py b/tests/test_fallback_warning.py new file mode 100644 index 0000000..807a05a --- /dev/null +++ b/tests/test_fallback_warning.py @@ -0,0 +1,153 @@ +"""The implicit `MemoryBackend` fallback logs a warning once per process (#327).""" + +import contextlib +import logging +import threading +import time +from collections.abc import Iterator +from concurrent.futures import ThreadPoolExecutor + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import fastapi_cachex.proxy +from fastapi_cachex import AppCache +from fastapi_cachex import BackendProxy +from fastapi_cachex import CacheBackend +from fastapi_cachex import cache +from fastapi_cachex.backends import MemoryBackend +from fastapi_cachex.dependencies import get_app_cache +from fastapi_cachex.exceptions import BackendNotFoundError +from fastapi_cachex.proxy import get_backend_or_fallback + +LOGGER = "fastapi_cachex.proxy" + +app = FastAPI() + + +@app.get("/cached") +@cache(ttl=60) +async def cached_endpoint() -> dict[str, str]: + return {"hello": "world"} + + +@app.get("/app-cache") +async def app_cache_endpoint(app_cache: AppCache) -> dict[str, str]: + return {"backend": type(app_cache.backend).__name__} + + +@app.get("/cache-backend") +async def cache_backend_endpoint(backend: CacheBackend) -> dict[str, str]: + return {"backend": type(backend).__name__} + + +client = TestClient(app) + + +def _fallback_warnings(caplog: pytest.LogCaptureFixture) -> list[str]: + return [ + record.getMessage() + for record in caplog.records + if record.name == LOGGER and record.levelno == logging.WARNING + ] + + +@pytest.fixture +def no_backend(caplog: pytest.LogCaptureFixture) -> Iterator[None]: + """Start with no backend and capture the proxy's warnings.""" + BackendProxy.set(None) + caplog.set_level(logging.WARNING, logger=LOGGER) + yield + with contextlib.suppress(BackendNotFoundError): + backend = BackendProxy.get() + if isinstance(backend, MemoryBackend): + backend.stop_cleanup() + + +@pytest.mark.usefixtures("no_backend") +@pytest.mark.parametrize("path", ["/cached", "/app-cache", "/cache-backend"]) +def test_implicit_fallback_warns_once( + caplog: pytest.LogCaptureFixture, path: str +) -> None: + for _ in range(3): + assert client.get(path).status_code == 200 + get_backend_or_fallback() + get_app_cache() + + assert isinstance(BackendProxy.get(), MemoryBackend) + assert len(_fallback_warnings(caplog)) == 1 + + +@pytest.mark.usefixtures("no_backend") +def test_implicit_fallback_warning_names_the_fix( + caplog: pytest.LogCaptureFixture, +) -> None: + get_backend_or_fallback() + + [message] = _fallback_warnings(caplog) + assert "BackendProxy.set(" in message + assert "per process" in message + assert "multiple workers" in message + + +@pytest.mark.usefixtures("no_backend") +def test_concurrent_first_calls_warn_once( + caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + """Racing first callers on worker threads register, and warn, only once.""" + + class SlowMemoryBackend(MemoryBackend): + def __init__(self) -> None: + time.sleep(0.05) # widen the window between the check and the set + super().__init__() + + monkeypatch.setattr(fastapi_cachex.proxy, "MemoryBackend", SlowMemoryBackend) + workers = 8 + barrier = threading.Barrier(workers) + + def first_call(index: int) -> object: + barrier.wait() + # Half through `AppCache`'s dependency, half through the bare helper. + if index % 2: + return get_app_cache().backend + return get_backend_or_fallback() + + with ThreadPoolExecutor(max_workers=workers) as pool: + backends = list(pool.map(first_call, range(workers))) + + assert len({id(backend) for backend in backends}) == 1 + assert len(_fallback_warnings(caplog)) == 1 + + +@pytest.mark.usefixtures("no_backend") +def test_fallback_warns_again_after_the_proxy_is_reset( + caplog: pytest.LogCaptureFixture, +) -> None: + """Resetting to `None` makes the next call register a new fallback. + + That is a second registration, so it warns again; only tests reset the + proxy this way. + """ + first = get_backend_or_fallback() + assert isinstance(first, MemoryBackend) + BackendProxy.set(None) + get_backend_or_fallback() + + assert len(_fallback_warnings(caplog)) == 2 + + +@pytest.mark.parametrize("path", ["/cached", "/app-cache", "/cache-backend"]) +def test_explicit_memory_backend_does_not_warn( + caplog: pytest.LogCaptureFixture, path: str +) -> None: + backend = MemoryBackend() + BackendProxy.set(backend) + caplog.set_level(logging.DEBUG, logger=LOGGER) + + assert client.get(path).status_code == 200 + get_backend_or_fallback() + + assert BackendProxy.get() is backend + assert _fallback_warnings(caplog) == [] + backend.stop_cleanup()