"""Nexus — SyncLog Repository (Async SQLAlchemy implementation)""" from datetime import datetime, timedelta, timezone from typing import Optional, List, Tuple from sqlalchemy import delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from server.domain.models import SyncLog, Server SYNC_LOG_RETENTION_DAYS = 30 SYNC_LOG_PURGE_BATCH_SIZE = 1000 _STALE_RUNNING_MESSAGE = ( "推送状态异常:长时间未完成(已自动订正,可能因历史缺陷未落库终态)" ) def _utc_aware(dt: datetime) -> datetime: """MySQL DateTime columns are naive UTC; normalize for timedelta math.""" if dt.tzinfo is None: return dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc) class SyncLogRepositoryImpl: """Async SQLAlchemy implementation of SyncLog data access""" def __init__(self, session: AsyncSession): self.session = session async def get_by_server_id(self, server_id: int, limit: int = 50) -> List[SyncLog]: result = await self.session.execute( select(SyncLog) .where(SyncLog.server_id == server_id) .order_by(SyncLog.started_at.desc()) .limit(limit) ) return list(result.scalars().all()) async def get_all_paginated( self, server_id: Optional[int] = None, status: Optional[str] = None, sync_mode: Optional[str] = None, trigger_type: Optional[str] = None, offset: int = 0, limit: int = 50, ) -> Tuple[list, int]: """Global sync log list with filters + server name join + total count.""" filters = [] if server_id: filters.append(SyncLog.server_id == server_id) if status: filters.append(SyncLog.status == status) if sync_mode: filters.append(SyncLog.sync_mode == sync_mode) if trigger_type: filters.append(SyncLog.trigger_type == trigger_type) # Count count_stmt = select(func.count(SyncLog.id)) if filters: count_stmt = count_stmt.where(*filters) total = int((await self.session.execute(count_stmt)).scalar() or 0) # Data (with server name join) data_stmt = ( select(SyncLog, Server.name.label("server_name")) .join(Server, SyncLog.server_id == Server.id, isouter=True) .order_by(SyncLog.started_at.desc()) .offset(offset) .limit(limit) ) if filters: data_stmt = data_stmt.where(*filters) rows = (await self.session.execute(data_stmt)).all() return rows, total async def create(self, log: SyncLog) -> SyncLog: self.session.add(log) await self.session.commit() return log async def update(self, log: SyncLog) -> SyncLog: """Persist in-memory mutations on an existing SyncLog (used after rsync completes).""" self.session.add(log) await self.session.commit() await self.session.refresh(log) return log async def reconcile_stale_running(self, max_age_minutes: int = 120) -> int: """Mark ``running`` logs older than *max_age_minutes* as ``failed``.""" cutoff = datetime.now(timezone.utc) - timedelta(minutes=max_age_minutes) result = await self.session.execute( select(SyncLog).where( SyncLog.status == "running", SyncLog.started_at < cutoff, ) ) stale = list(result.scalars().all()) if not stale: return 0 now = datetime.now(timezone.utc) for log in stale: log.status = "failed" if log.finished_at is None: log.finished_at = now if not log.error_message: log.error_message = _STALE_RUNNING_MESSAGE if log.duration_seconds == 0 and log.started_at: log.duration_seconds = max( 0, int((now - _utc_aware(log.started_at)).total_seconds()) ) await self.session.commit() return len(stale) async def purge_older_than( self, retention_days: int = SYNC_LOG_RETENTION_DAYS, batch_size: int = SYNC_LOG_PURGE_BATCH_SIZE, ) -> int: """Delete sync_logs rows with started_at older than retention_days (batched).""" if retention_days <= 0: return 0 cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days) total = 0 while True: result = await self.session.execute( select(SyncLog.id) .where(SyncLog.started_at < cutoff) .order_by(SyncLog.id) .limit(batch_size) ) ids = [int(row[0]) for row in result.all()] if not ids: break await self.session.execute(delete(SyncLog).where(SyncLog.id.in_(ids))) await self.session.commit() total += len(ids) if len(ids) < batch_size: break return total # Allowed fields for update_status — prevents arbitrary field injection via **kwargs SYNC_LOG_UPDATABLE_FIELDS = {"finished_at", "duration_seconds", "error_message", "diff_summary", "status"} async def update_status(self, id: int, status: str, **kwargs) -> Optional[SyncLog]: log = await self.session.get(SyncLog, id) if log: log.status = status for key, value in kwargs.items(): if key in self.SYNC_LOG_UPDATABLE_FIELDS: setattr(log, key, value) await self.session.commit() return log