69068e2e39
部署对齐三项后端能力:启动/周期收尾僵尸批量任务、30 天推送日志 purge、已安装且版本不低于主站时跳过批量安装 Agent。 Co-authored-by: Cursor <cursoragent@cursor.com>
157 lines
5.6 KiB
Python
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 |