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
5 changes: 5 additions & 0 deletions docs/SESSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 17 additions & 11 deletions fastapi_cachex/session/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions i18n/zh-TW/docs/SESSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
54 changes: 54 additions & 0 deletions tests/session/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Loading