From a8068e2254b2e261c9c166316e90b13cfce41977 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Sat, 26 Sep 2026 23:12:33 +0000 Subject: [PATCH] perf(session): write on lookup only when sliding expiration renews get_session() saved the session on every lookup to record last_accessed, so each authenticated request cost a backend write, and one that also modified the session cost two. It now saves only when sliding expiration renewed the session, which is the only lookup that changes the expiry or backend TTL. The keyword-only touch=True keeps the old behaviour for callers that need the stored last_accessed to be exact. Session entries are never compared by fingerprint, so they store the constant "session" instead of a SHA-256 of the payload. Closes #115 --- CLAUDE.md | 1 + docs/SESSION.md | 7 ++ fastapi_cachex/session/manager.py | 19 +++- i18n/zh-TW/docs/SESSION.md | 2 + tests/session/test_lookup_writes.py | 165 ++++++++++++++++++++++++++++ 5 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 tests/session/test_lookup_writes.py diff --git a/CLAUDE.md b/CLAUDE.md index b833176..62154db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,6 +69,7 @@ The library has four independent subsystems: - Optional subsystem, activated via `SessionMiddleware` and `SessionManagerProxy`. - `SessionManager` handles create/get/update/delete/invalidate/regenerate operations. It stores `Session` Pydantic models serialized as JSON, wrapped in `CacheEntry` for backend compatibility. - Token signing: `simple` format uses HMAC-SHA256 (`SecurityManager`); `jwt` format uses PyJWT (optional dependency `fastapi-cachex[jwt]`). +- `get_session()` saves only when sliding expiration renewed the session (or with `touch=True`), so the stored `last_accessed` is the last write, not the last lookup. Session entries use the constant fingerprint `"session"`; nothing compares it. - Session token is passed via custom header (`X-Session-Token` by default) or `Authorization: Bearer` token. - `SessionManagerProxy` mirrors the `BackendProxy` pattern for managing the `SessionManager` singleton. - Key FastAPI dependencies: `get_session`, `require_session`, `get_optional_session` (in `session/dependencies.py`). diff --git a/docs/SESSION.md b/docs/SESSION.md index 8427adf..db4cd9e 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -666,6 +666,13 @@ signature or binding mismatch), `SessionNotFoundError`, `SessionInvalidError` Since 0.3.8, `SessionError` derives from `CacheXError`, so `except CacheXError` catches session errors too. +`get_session()` writes to the backend only when sliding expiration renewed the session, so a +request that only reads its session costs a single backend read. The returned +session's `last_accessed` is the current time, but the stored value is updated +only when the session is next written (created, modified, renewed or +regenerated). Pass `touch=True` to save it on every lookup. Before 0.3.8 every +lookup saved the session. + Every method with its signature is in the generated [Session API reference](api/session.md). diff --git a/fastapi_cachex/session/manager.py b/fastapi_cachex/session/manager.py index 5ef06fe..5576652 100644 --- a/fastapi_cachex/session/manager.py +++ b/fastapi_cachex/session/manager.py @@ -26,6 +26,10 @@ logger = logging.getLogger(__name__) +# Session entries are never compared by fingerprint, so a constant saves +# hashing the payload on every write. +_SESSION_FINGERPRINT = "session" + def _now() -> datetime: return datetime.now(timezone.utc) @@ -181,13 +185,23 @@ async def get_session( token_string: str, ip_address: str | None = None, user_agent: str | None = None, + *, + touch: bool = False, ) -> tuple[Session, str | None]: """Retrieve and validate a session. + A lookup writes to the backend only when sliding expiration renewed + the session, since that is the only change to its expiry or TTL. The + returned session's ``last_accessed`` is always the current time, but + it is stored only when the session is next written, unless ``touch`` + is set. + Args: token_string: Session token string ip_address: Current request IP address user_agent: Current request User-Agent + touch: Save the session even when it was not renewed, so the + stored ``last_accessed`` is exact Returns: Tuple of (session, new_token_string). new_token_string is non-None @@ -297,7 +311,8 @@ async def get_session( self.config.session_ttl, ) - await self._save_session(session) + if renewed_token is not None or touch: + await self._save_session(session) return session, renewed_token @@ -486,7 +501,7 @@ async def _save_session(self, session: Session) -> None: ttl = max(ttl, 1) # Ensure at least 1 second entry = CacheEntry( - fingerprint=self.security.hash_data(payload), + fingerprint=_SESSION_FINGERPRINT, content=payload.encode("utf-8"), ) await self.backend.set( diff --git a/i18n/zh-TW/docs/SESSION.md b/i18n/zh-TW/docs/SESSION.md index fbdac9f..0e77028 100644 --- a/i18n/zh-TW/docs/SESSION.md +++ b/i18n/zh-TW/docs/SESSION.md @@ -540,6 +540,8 @@ session, new_token = await manager.regenerate_session_id(session) `SessionManager(backend, config, token_serializer=None)` 處理整個生命週期:`create_session()`/`create_anonymous_session()` 回傳 `(session, token)`;`get_session()` 回傳 `(session, renewed_token)`,其中 `renewed_token` 只有在滑動過期更新了權杖時才會有值,並應傳回給用戶端。`get_session()` 失敗時會拋出 `SessionError` 的子類別:`SessionTokenError`(權杖格式錯誤)、`SessionSecurityError`(簽章錯誤或綁定不符)、`SessionNotFoundError`、`SessionInvalidError`(Session 不是啟用狀態)或 `SessionExpiredError`(超過 TTL 或絕對逾時)。從 0.3.8 起,`SessionError` 繼承自 `CacheXError`,因此 `except CacheXError` 也會捕捉 Session 錯誤。 +`get_session()` 只有在滑動過期更新了 Session 時才寫入後端,因此只讀取 Session 的請求只需一次後端讀取。回傳的 Session 中 `last_accessed` 是目前時間,但儲存的值只會在 Session 下一次被寫入(建立、修改、更新或重新產生)時更新。傳入 `touch=True` 可在每次查詢時都儲存它。0.3.8 之前,每次查詢都會儲存 Session。 + 每個方法及其簽名請見自動產生的 [Session API 參考](https://fastapi-cachex.readthedocs.io/en/latest/api/session/)(英文)。 ## 依賴項 {#dependencies} diff --git a/tests/session/test_lookup_writes.py b/tests/session/test_lookup_writes.py new file mode 100644 index 0000000..e6fadf7 --- /dev/null +++ b/tests/session/test_lookup_writes.py @@ -0,0 +1,165 @@ +"""A session lookup writes to the backend only when something needs storing (#115). + +Before 0.3.8 every ``get_session()`` re-saved the session to record +``last_accessed``, so each authenticated request cost a write, and one that +also modified the session cost two. +""" + +from datetime import datetime +from datetime import timedelta +from datetime import timezone +from typing import Annotated + +import pytest +from fastapi import Depends +from fastapi import FastAPI +from fastapi import Request +from fastapi.testclient import TestClient + +from fastapi_cachex.backends.memory import MemoryBackend +from fastapi_cachex.session.config import SessionConfig +from fastapi_cachex.session.dependencies import require_session +from fastapi_cachex.session.manager import SessionManager +from fastapi_cachex.session.middleware import FastAPICacheXSessionMiddleware +from fastapi_cachex.session.middleware import SessionMiddleware +from fastapi_cachex.session.models import Session +from fastapi_cachex.session.models import SessionUser +from fastapi_cachex.types import CacheEntry + + +class SpyBackend(MemoryBackend): + """A memory backend that records every key passed to ``set``.""" + + def __init__(self) -> None: + super().__init__() + self.writes: list[str] = [] + + async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None: + self.writes.append(key) + await super().set(key, value, ttl=ttl) + + +def _manager(**overrides: object) -> tuple[SessionManager, SpyBackend]: + backend = SpyBackend() + config = SessionConfig(secret_key="a" * 32, **overrides) + return SessionManager(backend, config), backend + + +async def _stored(manager: SessionManager, session_id: str) -> Session: + stored = await manager._load_session(session_id) + assert stored is not None + return stored + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sliding_expiration", [True, False]) +async def test_plain_lookup_does_not_write(sliding_expiration: bool) -> None: + """A lookup that renews nothing leaves the backend untouched.""" + manager, backend = _manager(sliding_expiration=sliding_expiration) + session, token = await manager.create_session(SessionUser(user_id="u1")) + backend.writes.clear() + + loaded, renewed = await manager.get_session(token) + + assert renewed is None + assert backend.writes == [] + assert loaded.session_id == session.session_id + + +@pytest.mark.asyncio +async def test_sliding_renewal_writes_once_and_extends_the_stored_expiry() -> None: + """A renewal must reach the backend, or its TTL would not move.""" + manager, backend = _manager(session_ttl=3600, sliding_threshold=0.5) + session, token = await manager.create_session(SessionUser(user_id="u1")) + session.expires_at = datetime.now(timezone.utc) + timedelta(seconds=100) + await manager.update_session(session) + backend.writes.clear() + + loaded, renewed = await manager.get_session(token) + + assert renewed is not None + assert len(backend.writes) == 1 + stored = await _stored(manager, session.session_id) + assert stored.expires_at == loaded.expires_at + assert stored.expires_at > datetime.now(timezone.utc) + timedelta(seconds=3000) # type: ignore[operator] + + +@pytest.mark.asyncio +async def test_touch_stores_last_accessed() -> None: + """``touch=True`` keeps the stored ``last_accessed`` exact.""" + manager, backend = _manager() + session, token = await manager.create_session(SessionUser(user_id="u1")) + backend.writes.clear() + + untouched, _ = await manager.get_session(token) + assert backend.writes == [] + assert (await _stored(manager, session.session_id)).last_accessed < ( + untouched.last_accessed + ) + + touched, _ = await manager.get_session(token, touch=True) + assert len(backend.writes) == 1 + stored = await _stored(manager, session.session_id) + assert stored.last_accessed == touched.last_accessed + + +@pytest.mark.asyncio +async def test_session_entries_use_a_constant_fingerprint() -> None: + """Nothing compares session fingerprints, so the payload is not hashed.""" + manager, backend = _manager() + session, _ = await manager.create_session(SessionUser(user_id="u1")) + + entry = await backend.get(manager._get_backend_key(session.session_id)) + + assert entry is not None + assert entry.fingerprint == "session" + + +def test_middleware_reads_without_writing_and_writes_changes_once() -> None: + """Reading ``request.session`` costs no write; changing it costs exactly one.""" + manager, backend = _manager() + app = FastAPI() + app.add_middleware(FastAPICacheXSessionMiddleware, session_manager=manager) + + @app.get("/read") + async def read(request: Request) -> dict[str, object]: + return {"count": request.session.get("count", 0)} + + @app.get("/bump") + async def bump(request: Request) -> dict[str, object]: + request.session["count"] = request.session.get("count", 0) + 1 + return {"count": request.session["count"]} + + client = TestClient(app) + assert client.get("/bump").json() == {"count": 1} # creates the session + backend.writes.clear() + + assert client.get("/read").json() == {"count": 1} + assert backend.writes == [] + + assert client.get("/bump").json() == {"count": 2} + assert len(backend.writes) == 1 + assert client.get("/read").json() == {"count": 2} + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +async def test_header_middleware_lookup_does_not_write() -> None: + """The deprecated header middleware loads sessions the same way.""" + manager, backend = _manager() + app = FastAPI() + app.add_middleware(SessionMiddleware, session_manager=manager) + + @app.get("/me") + async def me( + session: Annotated[Session, Depends(require_session)], + ) -> dict[str, object]: + return {"user": session.user.user_id if session.user else None} + + _, token = await manager.create_session(SessionUser(user_id="u1")) + backend.writes.clear() + + response = TestClient(app).get("/me", headers={"X-Session-Token": token}) + + assert response.json() == {"user": "u1"} + assert backend.writes == []