Files
Nexus/server/infrastructure/database/sync_log_repo.py
T

157 lines
5.6 KiB
Python
Raw Normal View History

"""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