Ports the safety review and the Photo Analyzer Library/Analyze/Stats experiences onto the shared API + service layer, and adds the Workflow home, enforcing the pipeline gates and the one-mutating-job policy. Backend - migration 0005 + models: safety_reviews (append-only, latest row is the current decision) and analysis_results (donor photos schema re-keyed to asset_id). - SafetyService: persist scores/decisions, review queue with filters, and the EXIF safety checkpoint (mutually-exclusive sfw/nsfw keyword written, read back, current_sha256 refreshed) that upload eligibility depends on. - AnalysisService: the privacy gate — the vision provider is called ONLY for canonical, confirmed-SFW assets; nsfw/undecided are recorded skipped without a request. Provider is an injected adapter (real OpenAI-compatible Gemini call extracted from photo_analyzer.analyze_image; a fake in tests). - LibraryService: Library search + Stats read model ported from webapp/query.py (LIKE search in place of FTS5; facets, top tags, years, albums, people). - WorkflowService + GET /api/v1/workflow: per-stage readiness derived from the source tables — counts, blockers, last-run, action, and an active_job that drives read-only-during-jobs. Safety scoring and analysis run as durable jobs under the library_write lock via new domain handlers, so a second mutating job is refused. - routes: workflow, safety (queue/counts/decisions/jobs), analysis (counts/results/jobs), library (assets/facets/stats). Frontend - five views (frontend/js/views.js) on the US02-05 shell: Workflow stepper (status text+icon, not colour alone; actions disabled with a reason while a job runs), Safety review (filter tabs, decide, persists across reload), Library (search + cards), Analyze (counts + live job log via the SSE adapter), Stats. Shared DOM helpers extracted to dom.js; Workflow is the home route. Tests - integration: provider-call privacy (nsfw never reaches the provider), sfw→nsfw flip drops analysis eligibility, decision persistence, one-mutating- job rejection, workflow counts, and the exiftool safety-keyword write/verify. - e2e: Workflow cards, actions disabled+explained during a job, safety decide-persists-across-reload, Library search, Stats, Analyze counts. - traceability map updated for US02-05 and US02-06. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
139 lines
5.6 KiB
Python
139 lines
5.6 KiB
Python
"""Durable worker: claims jobs and runs their items safely.
|
|
|
|
Each worker serves a set of job types (its lanes), claims one job at a time (a
|
|
bounded single lane), and processes items sequentially with heartbeats. It holds
|
|
the fencing token from its claim and stamps every state write with it, so a worker
|
|
that was superseded after a lease expiry cannot commit stale results. Cancellation
|
|
is cooperative (checked between items and offered to handlers), leaving items
|
|
resumable. On resume, items left ``running`` by a dead worker are reset to
|
|
``queued`` and re-run (handlers are idempotent).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping, Sequence
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from photo_pipeline.jobs.handlers import REGISTRY, Cancelled, Handler, JobContext
|
|
from photo_pipeline.models import JobItem
|
|
from photo_pipeline.services.jobs import ItemState, JobConflict, JobService, JobState
|
|
|
|
|
|
class Worker:
|
|
def __init__(
|
|
self,
|
|
session_factory: sessionmaker,
|
|
handlers: Mapping[str, Handler] | None = None,
|
|
worker_id: str = "worker",
|
|
*,
|
|
job_types: Sequence[str] | None = None,
|
|
lease_seconds: int = 60,
|
|
) -> None:
|
|
self._session_factory = session_factory
|
|
self.service = JobService(session_factory)
|
|
self.handlers: Mapping[str, Handler] = handlers if handlers is not None else REGISTRY
|
|
self.worker_id = worker_id
|
|
self.job_types = list(job_types if job_types is not None else self.handlers.keys())
|
|
self.lease_seconds = lease_seconds
|
|
|
|
def run_once(self) -> str | None:
|
|
"""Recover stragglers, then claim and fully process one job. Returns its id."""
|
|
self.service.recover_stale()
|
|
claimed = self.service.claim(
|
|
self.job_types, self.worker_id, lease_seconds=self.lease_seconds
|
|
)
|
|
if claimed is None:
|
|
return None
|
|
self._process(claimed["id"], claimed["job_type"], claimed["fencing_token"])
|
|
return claimed["id"]
|
|
|
|
def _process(self, job_id: str, job_type: str, token: int) -> None:
|
|
handler = self.handlers[job_type]
|
|
ctx = JobContext(job_id, self.worker_id, token, self.service, self._session_factory)
|
|
self._reset_interrupted_items(job_id, token)
|
|
|
|
cancelled = False
|
|
any_failed = False
|
|
for item_key in self._queued_items(job_id):
|
|
if ctx.cancelled():
|
|
cancelled = True
|
|
break
|
|
try:
|
|
self.service.set_item(job_id, item_key, ItemState.RUNNING, fencing_token=token)
|
|
handler(item_key, ctx)
|
|
except Cancelled:
|
|
# Cooperative stop: leave the item resumable.
|
|
self.service.set_item(job_id, item_key, ItemState.QUEUED, fencing_token=token)
|
|
cancelled = True
|
|
break
|
|
except JobConflict:
|
|
# Superseded mid-item; stop and let the new owner finish.
|
|
return
|
|
except Exception as error: # handler failure for this item
|
|
any_failed = True
|
|
self.service.set_item(
|
|
job_id,
|
|
item_key,
|
|
ItemState.FAILED,
|
|
error=("handler_error", str(error)[:200]),
|
|
fencing_token=token,
|
|
)
|
|
else:
|
|
self.service.set_item(job_id, item_key, ItemState.SUCCEEDED, fencing_token=token)
|
|
self.service.heartbeat(job_id, self.worker_id, lease_seconds=self.lease_seconds)
|
|
|
|
self._finalize(job_id, token, cancelled=cancelled, any_failed=any_failed)
|
|
|
|
def _finalize(self, job_id: str, token: int, *, cancelled: bool, any_failed: bool) -> None:
|
|
snapshot = self.service.get(job_id)
|
|
if snapshot is None:
|
|
return
|
|
try:
|
|
if cancelled or snapshot["state"] == JobState.CANCELLING:
|
|
self.service.transition(
|
|
job_id, JobState.CANCELLED, worker_id=self.worker_id, fencing_token=token
|
|
)
|
|
elif any_failed:
|
|
self.service.transition(
|
|
job_id,
|
|
JobState.FAILED,
|
|
worker_id=self.worker_id,
|
|
fencing_token=token,
|
|
error=("items_failed", "one or more items failed"),
|
|
)
|
|
else:
|
|
self.service.transition(
|
|
job_id, JobState.SUCCEEDED, worker_id=self.worker_id, fencing_token=token
|
|
)
|
|
except JobConflict:
|
|
# A newer worker owns the job now; do not overwrite its outcome.
|
|
return
|
|
|
|
def _reset_interrupted_items(self, job_id: str, token: int) -> None:
|
|
for item_key in self._items_in_state(job_id, ItemState.RUNNING):
|
|
self.service.set_item(job_id, item_key, ItemState.QUEUED, fencing_token=token)
|
|
|
|
def _queued_items(self, job_id: str) -> list[str]:
|
|
return self._items_in_state(job_id, ItemState.QUEUED)
|
|
|
|
def _items_in_state(self, job_id: str, state: str) -> list[str]:
|
|
with self._session_factory() as session:
|
|
return list(
|
|
session.execute(
|
|
select(JobItem.item_key)
|
|
.where(JobItem.job_id == job_id, JobItem.state == state)
|
|
.order_by(JobItem.item_key)
|
|
).scalars()
|
|
)
|
|
|
|
def run_forever(self, *, idle_sleep: float = 1.0, iterations: int | None = None) -> None:
|
|
import time
|
|
|
|
count = 0
|
|
while iterations is None or count < iterations:
|
|
if self.run_once() is None:
|
|
time.sleep(idle_sleep)
|
|
count += 1
|