123 lines
5.6 KiB
Python
123 lines
5.6 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
|
|
|
|
# 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)
|
|
|
|
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()
|
|
)
|