Files
Nexus/server/infrastructure/database/sync_log_repo.py
T
Nexus Agent 69068e2e39 feat(ops): server_batch 回收、sync_logs 清理与 Agent 安装跳过
部署对齐三项后端能力:启动/周期收尾僵尸批量任务、30 天推送日志 purge、已安装且版本不低于主站时跳过批量安装 Agent。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 15:23:03 +08:00

157 lines
5.6 KiB
Python

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