123 lines
4.2 KiB
Python
123 lines
4.2 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=str(exc) or "任务执行失败",
|
|
)
|
|
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))
|