Files
xiaobaifupan/next/backend/jobs/service.py
T

130 lines
4.4 KiB
Python

from __future__ import annotations
import sqlite3
from collections.abc import Callable
from datetime import datetime
from typing import Any
from zoneinfo import ZoneInfo
from backend.database.connection import Database
from backend.jobs.repository import JobRepository, public_job
SHANGHAI = ZoneInfo("Asia/Shanghai")
class JobAlreadyRunning(RuntimeError):
pass
class JobService:
def __init__(self, database: Database, repository: JobRepository) -> None:
self._database = database
self._repository = repository
def execute(
self,
*,
kind: str,
run_key: str,
requested_date: str,
trigger: str,
operation: Callable[[], dict[str, Any]],
stale_after_seconds: int,
) -> dict[str, Any]:
started = datetime.now(SHANGHAI)
try:
with self._database.transaction() as connection:
row = self._repository.begin(
connection,
kind=kind,
run_key=run_key,
requested_date=requested_date,
trigger=trigger,
started_at=started,
stale_after_seconds=stale_after_seconds,
)
except sqlite3.IntegrityError as exc:
raise JobAlreadyRunning("同类任务正在运行") from exc
run_id = int(row["id"])
try:
result = operation()
except Exception as exc:
finished = datetime.now(SHANGHAI)
with self._database.transaction() as connection:
self._repository.fail(
connection,
run_id,
finished_at=finished,
duration_ms=_duration(started, finished),
error_code=type(exc).__name__,
error_message=_exception_message(exc),
)
raise
finished = datetime.now(SHANGHAI)
coverage = result.get("coverage")
with self._database.transaction() as connection:
self._repository.finish(
connection,
run_id,
finished_at=finished,
duration_ms=_duration(started, finished),
coverage=float(coverage) if isinstance(coverage, (int, float)) else None,
source_set=[str(value) for value in result.get("source_set") or []],
output_version=str(result.get("output_version") or ""),
payload=result,
)
return result
def latest(self, limit: int = 40) -> list[dict[str, Any]]:
with self._database.read() as connection:
return [public_job(row) for row in self._repository.latest(connection, limit)]
def latest_for_kind(self, kind: str) -> dict[str, Any] | None:
with self._database.read() as connection:
row = self._repository.latest_for_kind(connection, kind)
return public_job(row) if row else None
def latest_success(self, kind: str, requested_date: str = "") -> dict[str, Any] | None:
with self._database.read() as connection:
row = self._repository.latest_success(connection, kind, requested_date)
return public_job(row) if row else None
def ready_for_schedule(
self,
kind: str,
*,
now: datetime,
completed_after_seconds: int,
failed_after_seconds: int,
) -> bool:
"""Return whether a scheduler may start another run of this job kind."""
latest = self.latest_for_kind(kind)
if latest is None:
return True
if latest["status"] == "running":
return False
reference = latest.get("finished_at") or latest.get("started_at")
if not reference:
return True
try:
elapsed = (now - datetime.fromisoformat(str(reference))).total_seconds()
except ValueError:
return True
cooldown = (
completed_after_seconds
if latest["status"] == "completed"
else failed_after_seconds
)
return elapsed >= cooldown
def _duration(started: datetime, finished: datetime) -> int:
return max(0, round((finished - started).total_seconds() * 1000))
def _exception_message(error: Exception) -> str:
message = getattr(error, "message", None)
if isinstance(message, str) and message.strip():
return message.strip()
return str(error).strip() or "任务执行失败"