"""备份保留策略清理。""" from datetime import datetime, timedelta from typing import Optional from sqlalchemy.orm import Session from app.core.storage.factory import create_storage from app.models.job import Job from app.models.run import Run from app.utils.logging import get_logger log = get_logger(__name__) def apply_retention(db: Session, job: Job) -> int: """根据 job 的 retention_count / retention_days 清理旧备份。 返回删除的 run 数。 """ # 取该 job 所有成功 runs,按 finished_at 倒序 runs = ( db.query(Run) .filter(Run.job_id == job.id, Run.status == "success") .order_by(Run.finished_at.desc()) .all() ) if not runs: return 0 keep_count = job.retention_count or 7 keep_days = job.retention_days to_keep_ids: set[int] = set() for i, r in enumerate(runs): keep_by_count = i < keep_count keep_by_days = True if keep_days and r.finished_at: threshold = datetime.utcnow() - timedelta(days=keep_days) if r.finished_at < threshold: keep_by_days = False if keep_by_count or keep_by_days: to_keep_ids.add(r.id) to_delete = [r for r in runs if r.id not in to_keep_ids] if not to_delete: return 0 storage = create_storage(job.storage) removed = 0 for r in to_delete: try: if r.artifact_path: # 用同步方法简单调用即可(local 是同步包装 async,但这里 aioboto3/aiofiles 都是异步; # 实际上 base storage 都是 async 方法,所以这里需要在同步上下文跑 event loop) pass except Exception: log.exception("Failed to delete artifact %s (db row still kept)", r.artifact_path) db.delete(r) removed += 1 db.commit() log.info("Retention cleanup for job=%s: deleted %s runs", job.name, removed) # 真实删除 S3/local 文件(需要在 async 上下文) import asyncio try: loop = asyncio.new_event_loop() try: for r in to_delete: if r.artifact_path: try: loop.run_until_complete(storage.delete(r.artifact_path)) except Exception: log.exception("Artifact delete failed: %s", r.artifact_path) finally: loop.close() except Exception: log.exception("Artifact cleanup loop error") return removed