feat(bot): модель данных, миграции Alembic, FSM в БД, TTL-чистка
- app/db/models.py: 13 таблиц (ARCHITECTURE §2.10); перечисления — VARCHAR + CHECK, JSON → JSONB на Postgres, BIGINT/INTEGER-ключи для SQLite - миграция 0001 (автогенерация + ручная чистка: импорт sa.Text, дубли CHECK) - миграции применяются при старте бота программно (app.db.migrate), script_location из пакета - SqlStorage: FSM aiogram поверх fsm_state — состояние переживает рестарт (перенесено из #4) - cleanup_loop: истёкшие черновики, снимки контейнеров и попытки входа старше суток - тесты: upgrade/downgrade, дрейф моделей и миграций, CHECK, CASCADE, FSM, чистка Refs #5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
"""Periodic cleanup of TTL data: expired drafts, old container snapshots and access attempts."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, cast
|
||||
|
||||
from sqlalchemy import CursorResult, Delete, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import AccessAttempt, ContainerSnapshot, Draft, utcnow
|
||||
from app.db.session import SessionMaker
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SNAPSHOT_TTL = timedelta(days=1)
|
||||
ATTEMPT_TTL = timedelta(days=1)
|
||||
|
||||
|
||||
async def _delete(session: AsyncSession, stmt: Delete) -> int:
|
||||
# DML executes into a CursorResult; the generic annotation of execute() just doesn't say so
|
||||
return cast(CursorResult[Any], await session.execute(stmt)).rowcount
|
||||
|
||||
|
||||
async def cleanup_expired(sessions: SessionMaker, now: datetime) -> dict[str, int]:
|
||||
async with sessions.begin() as session:
|
||||
return {
|
||||
"drafts": await _delete(session, delete(Draft).where(Draft.expires_at < now)),
|
||||
"snapshots": await _delete(
|
||||
session, delete(ContainerSnapshot).where(ContainerSnapshot.at < now - SNAPSHOT_TTL)
|
||||
),
|
||||
"access_attempts": await _delete(
|
||||
session, delete(AccessAttempt).where(AccessAttempt.at < now - ATTEMPT_TTL)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def cleanup_loop(sessions: SessionMaker, interval: float = 3600.0) -> None:
|
||||
while True:
|
||||
try:
|
||||
removed = await cleanup_expired(sessions, utcnow())
|
||||
if any(removed.values()):
|
||||
log.info("cleanup: %s", removed)
|
||||
except Exception: # a failed cleanup must not kill the bot; retry next round
|
||||
log.exception("cleanup failed")
|
||||
await asyncio.sleep(interval)
|
||||
Reference in New Issue
Block a user