Files
photoanalyzer/photo_pipeline/models/archives.py

128 lines
6.0 KiB
Python

"""Archive location, plan, and transfer-journal persistence (US06-01, US06-02).
An archive location is a *medium*, not a path. External disks get mounted at
different mountpoints, and a different disk can be mounted at the same one, so a
recorded root alone can never prove "these bytes went to that volume". Each
location therefore owns a marker file written onto the medium itself; its
``media_id`` is the stable identity, and the root is only where it was last seen.
``capabilities`` and ``state`` are the last probe result, kept so the UI can list
locations without touching a sleeping disk. Preflight always re-probes — a stored
state is a hint, never evidence.
An ``ArchivePlan`` is one approved preflight turned into durable work, and each
``ArchiveOperation`` is one file's crash-safe journal row (US06-02). The row records
what the transfer *intends* to do before it does it — source, destination, expected
hash — because after a crash that intent plus the files on disk is the only evidence
available for deciding whether an original may be removed.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from photo_pipeline.db import Base
class ArchiveLocation(Base):
__tablename__ = "archive_locations"
id: Mapped[str] = mapped_column(String, primary_key=True)
name: Mapped[str] = mapped_column(String, nullable=False, unique=True)
root: Mapped[str] = mapped_column(String, nullable=False)
# Written into the marker file on the medium; proves the right volume is mounted.
media_id: Mapped[str] = mapped_column(String, nullable=False, unique=True)
capabilities: Mapped[str | None] = mapped_column(String) # JSON, last probe
# online | offline | wrong_volume | unwritable — the last probe's verdict.
state: Mapped[str] = mapped_column(String, nullable=False, default="offline")
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
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()
)
class ArchivePlan(Base):
__tablename__ = "archive_plans"
id: Mapped[str] = mapped_column(String, primary_key=True)
location_id: Mapped[str] = mapped_column(
ForeignKey("archive_locations.id"), nullable=False, index=True
)
# The preflight token this plan was approved against; re-verified before apply.
token: Mapped[str] = mapped_column(String, nullable=False)
albums: Mapped[str | None] = mapped_column(String) # JSON array
# archive | restore — the same journal read in the opposite direction (US06-04).
direction: Mapped[str] = mapped_column(String, nullable=False, default="archive")
# planned | applying | complete | failed
state: Mapped[str] = mapped_column(String, nullable=False, default="planned")
schema_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
byte_size: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Bumped on every claim and used as the fencing token, so a superseded attempt
# cannot commit.
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
worker_id: Mapped[str | None] = mapped_column(String)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
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()
)
class ArchiveOperation(Base):
__tablename__ = "archive_operations"
__table_args__ = (
UniqueConstraint("plan_id", "sequence", name="uq_archive_operations_plan_sequence"),
)
id: Mapped[str] = mapped_column(String, primary_key=True)
plan_id: Mapped[str] = mapped_column(
ForeignKey("archive_plans.id", ondelete="CASCADE"), nullable=False, index=True
)
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
album: Mapped[str] = mapped_column(String, nullable=False)
asset_id: Mapped[str] = mapped_column(ForeignKey("assets.id"), nullable=False, index=True)
# archive: library → medium. restore: medium → library (US06-04). ``source_path``
# and ``destination_path`` always mean "from" and "to" for this direction.
direction: Mapped[str] = mapped_column(String, nullable=False, default="archive")
source_path: Mapped[str] = mapped_column(String, nullable=False)
destination_path: Mapped[str] = mapped_column(String, nullable=False)
# Relative to the location root, because the medium can be mounted elsewhere.
archive_path: Mapped[str] = mapped_column(String, nullable=False)
expected_sha256: Mapped[str] = mapped_column(String, nullable=False)
byte_size: Mapped[int | None] = mapped_column(Integer)
same_filesystem: Mapped[bool | None] = mapped_column(Boolean)
# planned | transferring | verified | removing | complete | failed
journal_state: Mapped[str] = mapped_column(String, nullable=False, default="planned")
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
fencing_token: Mapped[int | None] = mapped_column(Integer)
worker_id: Mapped[str | None] = mapped_column(String)
verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
removed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
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()
)