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))