"""Job-type handler dispatch. A handler processes one item of a job: ``handler(item_key, ctx)`` and either returns (success), raises ``Cancelled`` (cooperative stop, resumable), or raises any other exception (item failure). Handlers must be idempotent — at-least-once delivery means the same item can run again after an interrupted attempt. Domain handlers (safety scoring, analysis, …) register here in later stories; the registry ships empty so the worker engine can be built and tested independently. """ from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING, Callable if TYPE_CHECKING: from photo_pipeline.services.jobs import JobService Handler = Callable[[str, "JobContext"], None] class Cancelled(Exception): """Raised by a handler that observed cancellation and stopped cleanly.""" @dataclass class JobContext: """What a handler needs from the coordinator: cancellation checks, heartbeats, and the session factory to build domain services against.""" job_id: str worker_id: str fencing_token: int service: "JobService" session_factory: object | None = None # Handlers that talk to an external tool (uploads) need the typed configuration; # the worker passes its own so a test stack is never read from the environment. config: object | None = None def cancelled(self) -> bool: from photo_pipeline.services.jobs import JobState snapshot = self.service.get(self.job_id) return snapshot is None or snapshot["state"] in ( JobState.CANCELLING, JobState.CANCELLED, ) def heartbeat(self, *, lease_seconds: int = 60) -> bool: return self.service.heartbeat(self.job_id, self.worker_id, lease_seconds=lease_seconds) # Populated by domain stories via register(); empty for now. REGISTRY: dict[str, Handler] = {} def register(job_type: str, handler: Handler) -> None: REGISTRY[job_type] = handler