US02-02: Persist and Coordinate Durable Jobs (#55)

This commit was merged in pull request #55.
This commit is contained in:
2026-07-15 22:47:13 +02:00
parent 22558a4efa
commit 0ebacfa544
7 changed files with 721 additions and 0 deletions

View File

@@ -0,0 +1,71 @@
"""Durable jobs, their per-item progress, and their event history.
A job is a unit of long-running work whose lifecycle survives browser, API, and
worker interruption. Progress is derived from ``job_items`` (the durable source of
truth), never from browser memory. ``job_events`` is an append-only activity log.
Leases (owner + expiry + heartbeat) and a monotonic ``fencing_token`` let a
recovered worker be fenced out after a newer worker takes ownership.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from photo_pipeline.db import Base
class Job(Base):
__tablename__ = "jobs"
id: Mapped[str] = mapped_column(String, primary_key=True)
job_type: Mapped[str] = mapped_column(String, nullable=False)
state: Mapped[str] = mapped_column(String, nullable=False)
lock_key: Mapped[str | None] = mapped_column(String)
idempotency_key: Mapped[str | None] = mapped_column(String, unique=True)
lease_owner: Mapped[str | None] = mapped_column(String)
lease_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
fencing_token: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
error_code: Mapped[str | None] = mapped_column(String)
error_message: Mapped[str | None] = mapped_column(String)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
class JobItem(Base):
__tablename__ = "job_items"
job_id: Mapped[str] = mapped_column(ForeignKey("jobs.id"), primary_key=True)
item_key: Mapped[str] = mapped_column(String, primary_key=True)
state: Mapped[str] = mapped_column(String, nullable=False)
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
error_code: Mapped[str | None] = mapped_column(String)
error_message: Mapped[str | None] = mapped_column(String)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class JobEvent(Base):
__tablename__ = "job_events"
id: Mapped[str] = mapped_column(String, primary_key=True)
job_id: Mapped[str] = mapped_column(ForeignKey("jobs.id"), nullable=False)
event_type: Mapped[str] = mapped_column(String, nullable=False)
message: Mapped[str | None] = mapped_column(String)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)