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
7 changes: 7 additions & 0 deletions changelog.d/327.changed.md
Original file line number Diff line number Diff line change
@@ -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`.
6 changes: 6 additions & 0 deletions docs/BACKENDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down
3 changes: 2 additions & 1 deletion docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 12 additions & 1 deletion fastapi_cachex/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
2 changes: 2 additions & 0 deletions i18n/zh-TW/docs/BACKENDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion i18n/zh-TW/docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ entry = CacheEntry(

TTL 不儲存在 `CacheEntry` 中:過期由後端負責(`MemoryBackend` 將它存在 `CacheItem.expiry`,Redis 使用 `SET ... EX`,Memcached 使用 exptime)。

若尚未以 `BackendProxy.set()` 設定後端,裝飾器會在第一個請求時建立 `MemoryBackend` 並註冊它。
若尚未以 `BackendProxy.set()` 設定後端,裝飾器會在第一個請求時建立 `MemoryBackend`、註冊它,並記錄一則警告,說明這個快取是每個行程各自一份。

**判斷邏輯**(`cache.py` 的包裝函式,依序執行):

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

Expand Down
153 changes: 153 additions & 0 deletions tests/test_fallback_warning.py
Original file line number Diff line number Diff line change
@@ -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()
Loading