"""Настройки и ротация журнала: удаление записей старше срока и самых старых сверх лимита.""" import asyncio import logging from datetime import datetime, timedelta, timezone from sqlalchemy import delete, func, select from sqlalchemy.orm import Session from app.db import SessionLocal from app.models import AppSetting, AuditLog, LoginAttempt from app.services import SYSTEM, audit log = logging.getLogger("ipam.rotation") DEFAULTS = {"retention_days": 90, "max_entries": 100_000} LOCK_KEY = 703001 # advisory lock: одна ротация за раз, даже при нескольких репликах INTERVAL_SECONDS = 3600 def get_settings(db: Session) -> dict: row = db.get(AppSetting, "journal") return {**DEFAULTS, **(row.value if row else {})} def save_settings(db: Session, values: dict) -> None: row = db.get(AppSetting, "journal") if row is None: db.add(AppSetting(key="journal", value=values)) else: row.value = values def rotate(db: Session) -> dict | None: """Возвращает {'by_age', 'by_count'} или None, если ротацию уже выполняет другой процесс.""" if not db.scalar(select(func.pg_try_advisory_xact_lock(LOCK_KEY))): db.rollback() return None cfg = get_settings(db) by_age = by_count = 0 if cfg["retention_days"] > 0: cutoff = datetime.now(timezone.utc) - timedelta(days=cfg["retention_days"]) by_age = db.execute(delete(AuditLog).where(AuditLog.ts < cutoff)).rowcount if cfg["max_entries"] > 0: total = db.scalar(select(func.count()).select_from(AuditLog)) or 0 if total > cfg["max_entries"]: # удаляем с запасом в одну запись — под сводную запись о ротации, чтобы итог не превышал лимит n = total - cfg["max_entries"] + 1 # сначала неудачные и заблокированные входы (их может нагенерировать кто угодно), затем самые старые записи остальных типов (изменение 024, находка №4) noisy = db.execute(delete(AuditLog).where(AuditLog.id.in_( select(AuditLog.id).where(AuditLog.entity_type == "session", AuditLog.action.in_(("failed", "locked"))).order_by(AuditLog.id).limit(n)))).rowcount rest = n - noisy by_count = noisy + (db.execute(delete(AuditLog).where(AuditLog.id.in_(select(AuditLog.id).order_by(AuditLog.id).limit(rest)))).rowcount if rest > 0 else 0) db.execute(delete(LoginAttempt).where(LoginAttempt.ts < datetime.now(timezone.utc) - timedelta(days=1))) if by_age or by_count: parts = [] if by_age: parts.append(f"старше {cfg['retention_days']} дн.: {by_age}") if by_count: parts.append(f"сверх лимита {cfg['max_entries']}: {by_count}") audit(db, SYSTEM, "journal", None, "rotated", "rotation", {"by_age": by_age, "by_count": by_count}, message="Ротация журнала: удалено " + "; ".join(parts)) db.commit() return {"by_age": by_age, "by_count": by_count} def run_rotation() -> None: try: with SessionLocal() as db: result = rotate(db) if result and (result["by_age"] or result["by_count"]): log.info("journal rotated: %s", result) except Exception: # фоновая задача не должна падать log.exception("journal rotation failed") async def rotation_loop() -> None: await asyncio.sleep(30) while True: await asyncio.to_thread(run_rotation) await asyncio.sleep(INTERVAL_SECONDS)