Add production run history cleanup

This commit is contained in:
2026-05-10 14:20:10 +08:00
parent 5c73e2c1c2
commit 3818f6e03e
7 changed files with 507 additions and 82 deletions
+73 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import re
from typing import Any, Dict, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException
@@ -18,6 +19,9 @@ from ..services.task_service import task_service
router = APIRouter(prefix="/dinsar-production", tags=["dinsar-production"])
_RUN_ID_RE = re.compile(r"^[\w\-]{4,128}$")
_LOG_MAX_BYTES = 200 * 1024
DINSAR_PRODUCTION_JOB_MAX_ATTEMPTS = read_int_env(
"DINSAR_PRODUCTION_JOB_MAX_ATTEMPTS",
1,
@@ -351,7 +355,74 @@ async def submit_run(
@router.get("/runs")
async def list_runs(limit: int = 20):
async def list_runs(limit: int = 20, offset: int = 0):
async with _new_session() as db:
result = await dinsar_production_service.list_runs(db, limit=limit)
result = await dinsar_production_service.list_runs(db, limit=limit, offset=offset)
return result
@router.get("/runs/{run_id}/log")
async def get_run_log(
run_id: str,
current_user: AuthUserORM = Depends(_get_current_user),
):
_ = current_user
normalized_run_id = str(run_id or "").strip()
if not _RUN_ID_RE.match(normalized_run_id):
raise HTTPException(status_code=400, detail="Invalid run_id format.")
try:
return await asyncio.to_thread(
dinsar_production_service.read_run_log,
normalized_run_id,
max_bytes=_LOG_MAX_BYTES,
)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail="Run log file not found.") from exc
@router.delete("/runs/{run_id}/log")
async def delete_run_log(
run_id: str,
current_user: AuthUserORM = Depends(_require_admin),
):
_ = current_user
normalized_run_id = str(run_id or "").strip()
if not _RUN_ID_RE.match(normalized_run_id):
raise HTTPException(status_code=400, detail="Invalid run_id format.")
deleted = await asyncio.to_thread(
dinsar_production_service.delete_run_log,
normalized_run_id,
)
if not deleted:
raise HTTPException(status_code=404, detail="Run log file not found.")
return {
"run_id": normalized_run_id,
"deleted": True,
}
@router.delete("/runs/{run_id}")
async def delete_run_record(
run_id: str,
current_user: AuthUserORM = Depends(_require_admin),
):
_ = current_user
normalized_run_id = str(run_id or "").strip()
if not _RUN_ID_RE.match(normalized_run_id):
raise HTTPException(status_code=400, detail="Invalid run_id format.")
async with _new_session() as db:
try:
result = await dinsar_production_service.delete_run_record(
normalized_run_id,
db=db,
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
if result is None:
raise HTTPException(status_code=404, detail="Production run not found.")
return {
**result,
"deleted": True,
"products_deleted": False,
}
+20
View File
@@ -181,6 +181,26 @@ async def clear_task_logs(
}
@router.delete("/tasks/{task_id}")
async def delete_task_record(
task_id: str,
admin_user: AuthUserORM = Depends(_require_admin),
):
task = await task_service.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found.")
if str(task.status or "").upper() in {"PENDING", "RUNNING"}:
raise HTTPException(status_code=409, detail="Cannot delete a pending or running task.")
deleted = await task_service.delete_task_record(task_id)
if not deleted:
raise HTTPException(status_code=404, detail="Task not found.")
return {
"task_id": task_id,
"deleted": True,
}
@router.post("/tasks/{task_id}/force-cancel")
async def force_cancel_task(
task_id: str,
@@ -9,7 +9,7 @@ import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy import func, select
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from .. import database
@@ -18,7 +18,12 @@ from ..models import (
DinsarProductionExecutionORM,
DinsarProductionRunItemORM,
DinsarProductionRunORM,
SystemJobORM,
SystemTaskORM,
TaskLogORM,
WorkflowArtifactORM,
WorkflowRunORM,
WorkflowStepORM,
)
from .envi_service import RUNTIME_DIR, _collect_task_folders, _resolve_dinsar_pair_identity, _to_local_path
from .task_service import task_service
@@ -1000,6 +1005,81 @@ class DinsarProductionService:
def append_run_log(self, run_id: str, message: str) -> str:
return _append_run_log_sync(run_id, message)
def read_run_log(self, run_id: str, *, max_bytes: int = 200 * 1024) -> Dict[str, Any]:
log_path = _run_log_path(run_id)
if not os.path.isfile(log_path):
raise FileNotFoundError(log_path)
size_bytes = os.path.getsize(log_path)
truncated = size_bytes > max_bytes
with open(log_path, "r", encoding="utf-8", errors="replace") as fp:
if truncated:
fp.seek(size_bytes - max_bytes)
content = "...[日志已截断,仅显示末尾]...\n" + fp.read()
else:
content = fp.read()
return {
"run_id": run_id,
"content": content,
"size_bytes": size_bytes,
"truncated": truncated,
"log_path": log_path,
}
def delete_run_log(self, run_id: str) -> bool:
log_path = _run_log_path(run_id)
if not os.path.isfile(log_path):
return False
os.unlink(log_path)
return True
async def delete_run_record(
self,
run_id: str,
*,
db: AsyncSession,
) -> Optional[Dict[str, Any]]:
run = await self.get_run(run_id, db)
if run is None:
return None
if str(run.status or "").strip().upper() not in TERMINAL_RUN_STATUSES:
raise ValueError("Cannot delete a pending or running production run.")
task_id = str(run.task_id or "").strip()
workflow_run_id = str(run.workflow_run_id or "").strip()
if task_id:
task_result = await db.execute(
select(SystemTaskORM).where(SystemTaskORM.task_id == task_id)
)
task = task_result.scalar_one_or_none()
if task is not None and str(task.status or "").strip().upper() in {"PENDING", "RUNNING"}:
raise ValueError("Cannot delete a production run with a pending or running task.")
deleted = {
"run_id": run.run_id,
"task_id": task_id or None,
"workflow_run_id": workflow_run_id or None,
}
await db.execute(delete(DinsarProductionExecutionORM).where(DinsarProductionExecutionORM.run_id == run.run_id))
await db.execute(delete(DinsarProductionRunItemORM).where(DinsarProductionRunItemORM.run_id == run.run_id))
await db.execute(delete(DinsarProductionRunORM).where(DinsarProductionRunORM.run_id == run.run_id))
if task_id:
await db.execute(delete(TaskLogORM).where(TaskLogORM.task_id == task_id))
await db.execute(delete(SystemJobORM).where(SystemJobORM.task_id == task_id))
await db.execute(delete(SystemTaskORM).where(SystemTaskORM.task_id == task_id))
if workflow_run_id:
await db.execute(delete(SystemJobORM).where(SystemJobORM.workflow_run_id == workflow_run_id))
await db.execute(delete(WorkflowArtifactORM).where(WorkflowArtifactORM.run_id == workflow_run_id))
await db.execute(delete(WorkflowStepORM).where(WorkflowStepORM.run_id == workflow_run_id))
await db.execute(delete(WorkflowRunORM).where(WorkflowRunORM.run_id == workflow_run_id))
await db.commit()
try:
deleted["log_deleted"] = await asyncio.to_thread(self.delete_run_log, run_id)
except OSError:
deleted["log_deleted"] = False
return deleted
def build_execution_manifest(
self,
*,
+27
View File
@@ -479,5 +479,32 @@ class TaskService:
if gen_db:
await db.close()
async def delete_task_record(
self,
task_id: str,
db: Optional[AsyncSession] = None,
) -> bool:
gen_db = db is None
if gen_db:
db = get_db_session()
try:
result = await db.execute(
select(SystemTaskORM).where(SystemTaskORM.task_id == task_id)
)
task = result.scalar_one_or_none()
if task is None:
return False
await db.execute(delete(TaskLogORM).where(TaskLogORM.task_id == task_id))
await db.delete(task)
await db.commit()
return True
except Exception:
await db.rollback()
raise
finally:
if gen_db:
await db.close()
task_service = TaskService()