US06-02: Transfer, Verify, and Remove Active Sources (#78)
This commit was merged in pull request #78.
This commit is contained in:
@@ -5,7 +5,7 @@ Alembic environment relies on.
|
||||
"""
|
||||
|
||||
from photo_pipeline.models.albums import AlbumProposal
|
||||
from photo_pipeline.models.archives import ArchiveLocation
|
||||
from photo_pipeline.models.archives import ArchiveLocation, ArchiveOperation, ArchivePlan
|
||||
from photo_pipeline.models.assets import Asset, AssetPath
|
||||
from photo_pipeline.models.duplicates import (
|
||||
DuplicateCluster,
|
||||
@@ -21,6 +21,8 @@ from photo_pipeline.models.workflow import AnalysisResult, SafetyReview
|
||||
__all__ = [
|
||||
"AlbumProposal",
|
||||
"ArchiveLocation",
|
||||
"ArchiveOperation",
|
||||
"ArchivePlan",
|
||||
"Asset",
|
||||
"AssetPath",
|
||||
"DuplicateCluster",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Archive location persistence (US06-01).
|
||||
"""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
|
||||
@@ -9,13 +9,27 @@ location therefore owns a marker file written onto the medium itself; its
|
||||
``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 DateTime, String, func
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from photo_pipeline.db import Base
|
||||
@@ -40,3 +54,69 @@ class ArchiveLocation(Base):
|
||||
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()
|
||||
)
|
||||
|
||||
@@ -35,7 +35,14 @@ class Asset(Base):
|
||||
|
||||
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
missing_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# active | archiving | archived_online | archived_offline | restoring |
|
||||
# missing_unexpected (concept §9). ``current_path`` is NULL once archived; the
|
||||
# original is then explained by the location plus its relative archive path.
|
||||
availability_state: Mapped[str] = mapped_column(String, nullable=False, default="active")
|
||||
# Not a declared foreign key: SQLite cannot add one to an existing table, so the
|
||||
# link is written and read by the archive service (US06-02).
|
||||
archive_location_id: Mapped[str | None] = mapped_column(String)
|
||||
archive_path: Mapped[str | None] = mapped_column(String)
|
||||
# Duplicate canonical link: NULL when the asset is itself canonical or undecided.
|
||||
canonical_asset_id: Mapped[str | None] = mapped_column(ForeignKey("assets.id"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user