From 2cb1a621f1cbc3ea57fb90151cbf285467d2e963 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Sat, 26 Sep 2026 13:31:36 +0000 Subject: [PATCH] fix(session): clear inactive sessions and batch session sweeps clear_expired_sessions() only looked at expires_at, so records already marked INVALIDATED (invalidate_session) or EXPIRED (an expired read) stayed in the backend until their TTL ran out. Treat any record that is no longer ACTIVE as removable too. Both clear_expired_sessions() and delete_user_sessions() now collect the matching keys and remove them with one backend.delete_many() call instead of one sequential delete per session; the count comes from delete_many. Closes #165 --- docs/SESSION.md | 5 +++ fastapi_cachex/session/manager.py | 28 +++++++++------- i18n/zh-TW/docs/SESSION.md | 2 ++ tests/session/test_manager.py | 54 +++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 11 deletions(-) diff --git a/docs/SESSION.md b/docs/SESSION.md index 7a1e8ba..5e4dd65 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -317,6 +317,11 @@ Changes made to a `Session` object inside a handler (flash messages, `session.da size of the backend. On the Memcached backend, which cannot enumerate keys, they find nothing and return `0` (with a `RuntimeWarning` from the backend). +`clear_expired_sessions()` removes every session that can no longer be used: those past their +`expires_at`, and those no longer `ACTIVE` (invalidated with `invalidate_session()`, or marked +expired by an earlier read) that would otherwise stay in the backend until their TTL runs out. +Both methods delete what they find with a single `backend.delete_many()` call. + ## Migration: SessionMiddleware → FastAPICacheXSessionMiddleware `SessionMiddleware` has been deprecated since 0.3.1 (it emits a `DeprecationWarning` when diff --git a/fastapi_cachex/session/manager.py b/fastapi_cachex/session/manager.py index 60f0fce..5ef06fe 100644 --- a/fastapi_cachex/session/manager.py +++ b/fastapi_cachex/session/manager.py @@ -390,26 +390,32 @@ async def delete_user_sessions(self, user_id: str) -> int: Returns: Number of sessions deleted """ - count = 0 - async for key, session in self._iter_sessions(): - if session.user and session.user.user_id == user_id: - await self.backend.delete(key) - count += 1 + keys = [ + key + async for key, session in self._iter_sessions() + if session.user and session.user.user_id == user_id + ] + count = await self.backend.delete_many(keys) logger.debug("User sessions deleted; user_id=%s count=%s", user_id, count) return count async def clear_expired_sessions(self) -> int: - """Clear all expired sessions. + """Clear every session that can no longer be used. + + That is any session past its ``expires_at`` and any session no longer + ``ACTIVE``: one that ``invalidate_session()`` or an expired read already + marked, which would otherwise stay in the backend until its TTL. Returns: Number of sessions cleared """ - count = 0 - async for key, session in self._iter_sessions(): - if session.is_expired(): - await self.backend.delete(key) - count += 1 + keys = [ + key + async for key, session in self._iter_sessions() + if session.status != SessionStatus.ACTIVE or session.is_expired() + ] + count = await self.backend.delete_many(keys) logger.debug("Expired sessions cleared; count=%s", count) return count diff --git a/i18n/zh-TW/docs/SESSION.md b/i18n/zh-TW/docs/SESSION.md index b9bc94e..37d3962 100644 --- a/i18n/zh-TW/docs/SESSION.md +++ b/i18n/zh-TW/docs/SESSION.md @@ -296,6 +296,8 @@ def get_user_roles(username: str) -> list[str]: `delete_user_sessions()` 與 `clear_expired_sessions()` 會透過 `get_all_keys()` 列舉後端中的每一個鍵,並載入 `backend_key_prefix` 底下的每個 Session,因此其成本會隨後端的大小增加。在無法列舉鍵的 Memcached 後端上,它們找不到任何東西並回傳 `0`(後端會發出 `RuntimeWarning`)。 +`clear_expired_sessions()` 會移除所有已無法使用的 Session:超過 `expires_at` 的,以及不再是 `ACTIVE` 的(以 `invalidate_session()` 作廢,或先前讀取時已標記為過期),否則它們會留在後端直到 TTL 到期。兩個方法都以單一次 `backend.delete_many()` 呼叫刪除找到的 Session。 + ## 遷移:SessionMiddleware → FastAPICacheXSessionMiddleware {#migration-sessionmiddleware-fastapicachexsessionmiddleware} `SessionMiddleware` 自 0.3.1 起已棄用(建構時會發出 `DeprecationWarning`),並將於 0.4.0 移除。請改用 `FastAPICacheXSessionMiddleware`: diff --git a/tests/session/test_manager.py b/tests/session/test_manager.py index 9f5ea4e..92918af 100644 --- a/tests/session/test_manager.py +++ b/tests/session/test_manager.py @@ -2,6 +2,7 @@ import base64 import json +from collections.abc import Iterable from datetime import datetime from datetime import timedelta from datetime import timezone @@ -18,6 +19,7 @@ from fastapi_cachex.session.exceptions import SessionTokenError from fastapi_cachex.session.manager import SessionManager from fastapi_cachex.session.models import Session +from fastapi_cachex.session.models import SessionStatus from fastapi_cachex.session.models import SessionToken from fastapi_cachex.session.models import SessionUser from fastapi_cachex.types import CacheEntry @@ -769,3 +771,55 @@ async def test_session_sweeps_skip_entries_they_cannot_read() -> None: ) assert await manager.delete_user_sessions("u1") == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", [SessionStatus.INVALIDATED, SessionStatus.EXPIRED]) +async def test_clear_expired_sessions_removes_sessions_no_longer_active( + status: SessionStatus, +) -> None: + """A record marked invalidated or expired is unusable before its TTL (#165).""" + backend = MemoryBackend() + manager = SessionManager(backend, SessionConfig(secret_key="a" * 32)) + marked, _ = await manager.create_session(user=SessionUser(user_id="u1")) + _, active_token = await manager.create_session(user=SessionUser(user_id="u2")) + marked.status = status + await manager.update_session(marked) + + assert await manager.clear_expired_sessions() == 1 + + assert await backend.get(manager._get_backend_key(marked.session_id)) is None + assert await manager.get_session(active_token) is not None + + +@pytest.mark.asyncio +async def test_session_sweeps_delete_in_one_batch() -> None: + """Both sweeps hand every matching key to a single delete_many (#165).""" + + class CountingBackend(MemoryBackend): + def __init__(self) -> None: + super().__init__() + self.deletes = 0 + self.batches: list[int] = [] + + async def delete(self, key: str) -> None: + self.deletes += 1 + await super().delete(key) + + async def delete_many(self, keys: Iterable[str]) -> int: + keys = list(keys) + self.batches.append(len(keys)) + return await super().delete_many(keys) + + backend = CountingBackend() + manager = SessionManager(backend, SessionConfig(secret_key="a" * 32)) + for _ in range(3): + session, _ = await manager.create_session(user=SessionUser(user_id="u1")) + for _ in range(2): + session, _ = await manager.create_session(user=SessionUser(user_id="u2")) + await manager.invalidate_session(session) + + assert await manager.clear_expired_sessions() == 2 + assert await manager.delete_user_sessions("u1") == 3 + assert backend.batches == [2, 3] + assert backend.deletes == 0