Source code for fastrag.serve.cleanup
"""Expired-row cleanup, callable from an external scheduler."""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine
_DELETE_SESSIONS = text(
"DELETE FROM sessions WHERE id IN "
"(SELECT id FROM sessions WHERE expires_at < now() LIMIT :batch_size)"
)
_DELETE_CHATS = text(
"DELETE FROM chats WHERE id IN "
"(SELECT id FROM chats WHERE expires_at < now() LIMIT :batch_size)"
)
[docs]
@dataclass(frozen=True)
class CleanupReport:
"""How many expired rows were deleted."""
sessions: int
chats: int
[docs]
async def cleanup_expired(
engine: AsyncEngine,
*,
target: str = "all",
batch_size: int = 1000,
) -> CleanupReport:
"""Delete expired sessions and chats.
Deleting a session cascades to its chats and messages; chats are swept
separately because a chat can go idle while its session stays active.
Deletes in bounded batches — one transaction per batch — for the same
reason a single unbounded ``DELETE`` would be wrong: cascading through
chats and messages in one very large transaction would hold locks and
bloat WAL for as long as it ran. Sessions are swept before chats,
mirroring the cascade.
This function carries the deletion logic but never runs it itself —
nothing in the request path calls it. It is meant to be invoked on a
daily cadence by an external scheduler (cron, a Kubernetes ``CronJob``,
...); a short script under cron calls it.
Args:
engine: The async engine to run the deletes on. The caller typically
passes a :class:`~fastrag.query.chat.postgres.PostgresChatStore`'s
``engine``. The SQL targets the plain table names
(``sessions``/``chats``) on the engine's search path — a store
pointed at a non-default schema requires a schema-qualified
variant (see this task's result file).
target: ``"sessions"``, ``"chats"``, or ``"all"``.
batch_size: Rows deleted per transaction.
Returns:
A :class:`CleanupReport` of rows deleted per table.
"""
if target not in ("sessions", "chats", "all"):
raise ValueError("target must be one of: sessions, chats, all")
deleted_sessions = 0
deleted_chats = 0
if target in ("sessions", "all"):
deleted_sessions = await _delete_expired_batches(
engine, _DELETE_SESSIONS, batch_size
)
if target in ("chats", "all"):
deleted_chats = await _delete_expired_batches(engine, _DELETE_CHATS, batch_size)
return CleanupReport(sessions=deleted_sessions, chats=deleted_chats)
async def _delete_expired_batches(engine: AsyncEngine, sql, batch_size: int) -> int:
"""Delete in bounded batches, one transaction per batch."""
total = 0
while True:
async with engine.begin() as connection:
result = await connection.execute(sql, {"batch_size": batch_size})
deleted = result.rowcount
total += deleted
if deleted == 0:
break
return total