Files
2026-02-22 18:58:55 +08:00

501 lines
16 KiB
Python

from __future__ import annotations
import json
from typing import Any
import sqlalchemy as sa
from fastapi import APIRouter, Depends, Request
from app.api.admin.deps import get_admin_claims
from app.core.errors import ApiError, ErrorCode
from app.core.request_id import get_or_create_request_id
from app.core.time import iso_utc_ms
from app.db.models.hydration_runs import HydrationRun
from app.db.models.images import Image
from app.db.models.jobs import JobRow
from app.db.session import create_sessionmaker, with_sqlite_busy_retry
router = APIRouter()
_ALLOWED_RUN_STATUSES = {"pending", "running", "paused", "canceled", "completed", "failed"}
def _parse_json_dict(value: str | None) -> dict[str, Any]:
raw = str(value or "").strip()
if not raw:
return {}
try:
loaded = json.loads(raw)
except Exception:
return {}
if isinstance(loaded, dict):
return loaded
return {}
def _serialize_job(job: JobRow | None) -> dict[str, Any] | None:
if job is None:
return None
return {
"id": str(job.id),
"status": job.status,
"attempt": int(job.attempt),
"max_attempts": int(job.max_attempts),
"run_after": job.run_after,
"last_error": job.last_error,
"locked_by": job.locked_by,
"locked_at": job.locked_at,
"updated_at": job.updated_at,
}
def _serialize_run(run: HydrationRun, *, latest_job: JobRow | None = None) -> dict[str, Any]:
return {
"id": str(run.id),
"type": run.type,
"status": run.status,
"criteria": _parse_json_dict(run.criteria_json),
"cursor": _parse_json_dict(run.cursor_json),
"total": int(run.total) if run.total is not None else None,
"processed": int(run.processed),
"success": int(run.success),
"failed": int(run.failed),
"started_at": run.started_at,
"finished_at": run.finished_at,
"last_error": run.last_error,
"created_at": run.created_at,
"updated_at": run.updated_at,
"latest_job": _serialize_job(latest_job),
}
async def _latest_jobs_by_run_ids(session, *, run_ids: list[str]) -> dict[str, JobRow]:
ids = [str(v).strip() for v in run_ids if str(v).strip()]
if not ids:
return {}
latest_per_ref = (
sa.select(JobRow.ref_id.label("ref_id"), sa.func.max(JobRow.id).label("max_job_id"))
.where(JobRow.ref_type == "hydration_run", JobRow.ref_id.in_(ids))
.group_by(JobRow.ref_id)
.subquery()
)
rows = (
await session.execute(
sa.select(JobRow, latest_per_ref.c.ref_id)
.join(latest_per_ref, JobRow.id == latest_per_ref.c.max_job_id)
.order_by(JobRow.id.desc())
)
).all()
out: dict[str, JobRow] = {}
for job, ref_id in rows:
key = str(ref_id or "").strip()
if not key:
continue
out[key] = job
return out
async def _load_create_json(request: Request) -> dict[str, Any]:
try:
data = await request.json()
except Exception as exc:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid JSON body", status_code=400) from exc
if not isinstance(data, dict):
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid JSON body", status_code=400)
run_type = str(data.get("type") or "backfill").strip().lower() or "backfill"
if run_type not in {"backfill", "manual"}:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid type", status_code=400)
criteria = data.get("criteria")
if criteria is None:
criteria = {}
if not isinstance(criteria, dict):
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid criteria", status_code=400)
return {"type": run_type, "criteria": criteria}
async def _load_manual_job_json(request: Request) -> dict[str, int | None]:
try:
data = await request.json()
except Exception as exc:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid JSON body", status_code=400) from exc
if not isinstance(data, dict):
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid JSON body", status_code=400)
illust_id_raw = data.get("illust_id")
image_id_raw = data.get("image_id")
if illust_id_raw is None and image_id_raw is None:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Either illust_id or image_id is required", status_code=400)
if illust_id_raw is not None and image_id_raw is not None:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Provide only one of illust_id or image_id", status_code=400)
illust_id: int | None = None
image_id: int | None = None
if illust_id_raw is not None:
try:
illust_id = int(illust_id_raw)
except Exception as exc:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid illust_id", status_code=400) from exc
if illust_id <= 0:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid illust_id", status_code=400)
if image_id_raw is not None:
try:
image_id = int(image_id_raw)
except Exception as exc:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid image_id", status_code=400) from exc
if image_id <= 0:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid image_id", status_code=400)
return {"illust_id": illust_id, "image_id": image_id}
@router.get("/hydration-runs")
async def list_hydration_runs(
request: Request,
limit: int = 20,
cursor: str | None = None,
status: str | None = None,
_claims: dict[str, Any] = Depends(get_admin_claims),
) -> dict[str, Any]:
_ = _claims
if limit < 1 or limit > 200:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported limit", status_code=400)
cursor_i: int | None = None
cursor_raw = str(cursor or "").strip()
if cursor_raw:
if not cursor_raw.isdigit():
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported cursor", status_code=400)
cursor_i = int(cursor_raw)
if cursor_i <= 0:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported cursor", status_code=400)
status_norm = str(status or "").strip().lower() or None
if status_norm is not None and status_norm not in _ALLOWED_RUN_STATUSES:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported status", status_code=400)
rid = get_or_create_request_id(request)
engine = request.app.state.engine
Session = create_sessionmaker(engine)
async with Session() as session:
stmt = sa.select(HydrationRun).order_by(HydrationRun.id.desc()).limit(limit + 1)
if cursor_i is not None:
stmt = stmt.where(HydrationRun.id < cursor_i)
if status_norm is not None:
stmt = stmt.where(HydrationRun.status == status_norm)
rows = (await session.execute(stmt)).scalars().all()
current_rows = rows[:limit]
run_ids = [str(int(r.id)) for r in current_rows]
jobs_by_run_id = await _latest_jobs_by_run_ids(session, run_ids=run_ids)
next_cursor = int(current_rows[-1].id) if len(rows) > limit and current_rows else None
items = [_serialize_run(row, latest_job=jobs_by_run_id.get(str(int(row.id)))) for row in current_rows]
return {
"ok": True,
"items": items,
"next_cursor": str(next_cursor) if next_cursor is not None else "",
"request_id": rid,
}
@router.get("/hydration-runs/{run_id}")
async def get_hydration_run(
run_id: int,
request: Request,
_claims: dict[str, Any] = Depends(get_admin_claims),
) -> dict[str, Any]:
_ = _claims
if run_id <= 0:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid hydration_run id", status_code=400)
rid = get_or_create_request_id(request)
engine = request.app.state.engine
Session = create_sessionmaker(engine)
async with Session() as session:
run = await session.get(HydrationRun, run_id)
if run is None:
raise ApiError(code=ErrorCode.NOT_FOUND, message="Hydration run not found", status_code=404)
jobs = await _latest_jobs_by_run_ids(session, run_ids=[str(run_id)])
item = _serialize_run(run, latest_job=jobs.get(str(run_id)))
return {"ok": True, "item": item, "request_id": rid}
@router.post("/hydration-runs/manual")
async def create_manual_hydration_job(
request: Request,
_claims: dict[str, Any] = Depends(get_admin_claims),
) -> dict[str, Any]:
_ = _claims
rid = get_or_create_request_id(request)
now = iso_utc_ms()
body = await _load_manual_job_json(request)
engine = request.app.state.engine
Session = create_sessionmaker(engine)
async def _op() -> dict[str, Any]:
async with Session() as session:
illust_id = body.get("illust_id")
image_id = body.get("image_id")
if illust_id is None and image_id is not None:
image = await session.get(Image, int(image_id))
if image is None:
raise ApiError(code=ErrorCode.NOT_FOUND, message="Image not found", status_code=404)
illust_id = int(image.illust_id)
if illust_id is None or int(illust_id) <= 0:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid illust_id", status_code=400)
existing = (
(
await session.execute(
sa.select(JobRow)
.where(
JobRow.type == "hydrate_metadata",
JobRow.ref_type == "manual_hydrate",
JobRow.ref_id == str(int(illust_id)),
JobRow.status.in_(["pending", "running"]),
)
.order_by(JobRow.id.desc())
.limit(1)
)
)
.scalars()
.first()
)
if existing is not None:
return {
"ok": True,
"created": False,
"job_id": str(int(existing.id)),
"illust_id": str(int(illust_id)),
"request_id": rid,
}
job = JobRow(
type="hydrate_metadata",
status="pending",
payload_json=json.dumps({"illust_id": int(illust_id)}, separators=(",", ":"), ensure_ascii=False),
last_error=None,
priority=0,
run_after=None,
attempt=0,
max_attempts=3,
locked_by=None,
locked_at=None,
ref_type="manual_hydrate",
ref_id=str(int(illust_id)),
updated_at=now,
)
session.add(job)
await session.flush()
await session.commit()
return {
"ok": True,
"created": True,
"job_id": str(int(job.id)),
"illust_id": str(int(illust_id)),
"request_id": rid,
}
return await with_sqlite_busy_retry(_op)
@router.post("/hydration-runs")
async def create_hydration_run(
request: Request,
_claims: dict[str, Any] = Depends(get_admin_claims),
) -> dict[str, Any]:
_ = _claims
rid = get_or_create_request_id(request)
now = iso_utc_ms()
body = await _load_create_json(request)
run_type = str(body["type"])
criteria = dict(body["criteria"])
engine = request.app.state.engine
Session = create_sessionmaker(engine)
async def _op() -> tuple[int, int]:
async with Session() as session:
run = HydrationRun(
type=run_type,
status="pending",
criteria_json=json.dumps(criteria, separators=(",", ":"), ensure_ascii=False),
cursor_json=None,
total=None,
processed=0,
success=0,
failed=0,
started_at=None,
finished_at=None,
last_error=None,
updated_at=now,
)
session.add(run)
await session.flush()
job = JobRow(
type="hydrate_metadata",
status="pending",
payload_json=json.dumps(
{"hydration_run_id": int(run.id), "criteria": criteria},
separators=(",", ":"),
ensure_ascii=False,
),
last_error=None,
priority=0,
run_after=None,
attempt=0,
max_attempts=3,
locked_by=None,
locked_at=None,
ref_type="hydration_run",
ref_id=str(int(run.id)),
updated_at=now,
)
session.add(job)
await session.flush()
await session.commit()
return int(run.id), int(job.id)
run_id, job_id = await with_sqlite_busy_retry(_op)
return {
"ok": True,
"hydration_run_id": str(run_id),
"job_id": str(job_id),
"request_id": rid,
}
async def _set_run_and_job_status(
request: Request,
*,
run_id: int,
target_status: str,
allowed_from: set[str],
job_status: str,
) -> dict[str, Any]:
if run_id <= 0:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Invalid hydration_run id", status_code=400)
rid = get_or_create_request_id(request)
now = iso_utc_ms()
engine = request.app.state.engine
Session = create_sessionmaker(engine)
async def _op() -> dict[str, Any]:
async with Session() as session:
run = await session.get(HydrationRun, run_id)
if run is None:
raise ApiError(code=ErrorCode.NOT_FOUND, message="Hydration run not found", status_code=404)
if str(run.status) not in allowed_from:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported status transition", status_code=400)
run.status = target_status
run.updated_at = now
if target_status in {"canceled", "completed", "failed"} and run.finished_at is None:
run.finished_at = now
job = (
(
await session.execute(
sa.select(JobRow)
.where(JobRow.ref_type == "hydration_run", JobRow.ref_id == str(run_id))
.order_by(JobRow.id.desc())
.limit(1)
)
)
.scalars()
.first()
)
if job is not None:
job.status = job_status
if job_status in {"pending", "canceled", "paused", "dlq"}:
job.run_after = None
job.locked_by = None
job.locked_at = None
job.updated_at = now
await session.commit()
return {
"ok": True,
"hydration_run_id": str(run_id),
"status": target_status,
"job_status": job_status if job is not None else "",
"request_id": rid,
}
return await with_sqlite_busy_retry(_op)
@router.post("/hydration-runs/{run_id}/pause")
async def pause_hydration_run(
run_id: int,
request: Request,
_claims: dict[str, Any] = Depends(get_admin_claims),
) -> dict[str, Any]:
_ = _claims
return await _set_run_and_job_status(
request,
run_id=run_id,
target_status="paused",
allowed_from={"pending", "running"},
job_status="paused",
)
@router.post("/hydration-runs/{run_id}/resume")
async def resume_hydration_run(
run_id: int,
request: Request,
_claims: dict[str, Any] = Depends(get_admin_claims),
) -> dict[str, Any]:
_ = _claims
return await _set_run_and_job_status(
request,
run_id=run_id,
target_status="pending",
allowed_from={"paused"},
job_status="pending",
)
@router.post("/hydration-runs/{run_id}/cancel")
async def cancel_hydration_run(
run_id: int,
request: Request,
_claims: dict[str, Any] = Depends(get_admin_claims),
) -> dict[str, Any]:
_ = _claims
return await _set_run_and_job_status(
request,
run_id=run_id,
target_status="canceled",
allowed_from={"pending", "running", "paused"},
job_status="canceled",
)