"""Archive transfer journal — the durable record of every per-file transition (US06-02). Archiving is the only stage that deletes an original, so the journal exists to make one question answerable after any crash: *may this source file be removed?* Intent is written before the mutation it describes, and the recorded state plus the real files on disk are the sole basis for answering it later. This module owns the state machine, the durable writes, and the evidence table; it never touches a photo (:mod:`photo_pipeline.services.archive_transfer` does). Per-item state machine (concept §9 "Transfer and removal semantics"): ``` planned → transferring → verified → removing → complete ↘ ↘ ↘ failed ``` A restore (US06-04) uses the same rows with ``direction='restore'``: it copies from the medium back into the library and removes nothing, so it goes ``verified → complete`` directly. ``source_path``/``destination_path`` always mean "from"/"to", which is why the evidence table below needs no direction of its own. - ``transferring`` — intent recorded; a temporary copy may exist, the destination may or may not have been published. Nothing has been removed. - ``verified`` — the archived bytes exist at their final path, hash exactly as recorded, and the manifest entry is durable. Only from here may a source go. - ``removing`` — the source removal is committed to; the source may already be gone while the database still points at it. - ``complete`` — source absent, database updated, availability recorded. ``classify`` labels each incomplete item from the journal plus disk evidence: - ``resumable`` — nothing was published; the source is intact, so applying again is safe. - ``forward`` — the archived copy exists and matches its recorded hash, so the remaining steps (manifest, removal, bookkeeping) can be finished deterministically. - ``manual`` — the evidence contradicts the journal (missing archive copy, wrong bytes, source and archive both gone). Nothing is guessed and nothing is removed; the item blocks unrelated mutations until a human decides. """ from __future__ import annotations from datetime import datetime, timezone from pathlib import Path from sqlalchemy import select from sqlalchemy.orm import sessionmaker from photo_pipeline.models import ArchiveOperation, ArchivePlan from photo_pipeline.services.hashing import sha256_file class ArchiveState: PLANNED = "planned" TRANSFERRING = "transferring" VERIFIED = "verified" REMOVING = "removing" COMPLETE = "complete" FAILED = "failed" ALLOWED_TRANSITIONS = { ArchiveState.PLANNED: {ArchiveState.TRANSFERRING, ArchiveState.FAILED}, # From `transferring` the outcome is unknown until evidence is gathered, so it # may resolve forward, back to planned (proven nothing was published), or fail. ArchiveState.TRANSFERRING: { ArchiveState.VERIFIED, ArchiveState.PLANNED, ArchiveState.FAILED, }, ArchiveState.VERIFIED: {ArchiveState.REMOVING, ArchiveState.FAILED}, # No path back: once the source may be gone, only finishing is safe. ArchiveState.REMOVING: {ArchiveState.COMPLETE, ArchiveState.FAILED}, ArchiveState.COMPLETE: set(), # A retry re-enters `transferring`, which rechecks every precondition from # scratch; recovery may also reset a failed item to `planned`. ArchiveState.FAILED: {ArchiveState.PLANNED, ArchiveState.TRANSFERRING}, } # A restore removes nothing, so it has no ``removing`` step: a verified published # copy is the whole job (US06-04). Keeping this as a separate table means the # archive direction still cannot reach ``complete`` without going through removal. RESTORE_TRANSITIONS = { **ALLOWED_TRANSITIONS, ArchiveState.VERIFIED: {ArchiveState.COMPLETE, ArchiveState.FAILED}, } TERMINAL_STATES = frozenset({ArchiveState.COMPLETE}) # States where this item may already have touched the filesystem. UNSAFE_STATES = frozenset({ArchiveState.TRANSFERRING, ArchiveState.VERIFIED, ArchiveState.REMOVING}) # Which way the bytes move. Same rows, same evidence table, opposite direction. ARCHIVE = "archive" RESTORE = "restore" RESUMABLE = "resumable" FORWARD = "forward" MANUAL = "manual" class JournalError(RuntimeError): pass class InvalidTransition(JournalError): pass class JournalConflict(JournalError): """Fencing check failed; a newer owner has taken over this operation.""" def can_transition(current: str, target: str, direction: str = ARCHIVE) -> bool: table = RESTORE_TRANSITIONS if direction == RESTORE else ALLOWED_TRANSITIONS return target in table.get(current, set()) def _now() -> datetime: return datetime.now(timezone.utc) class ArchiveJournal: def __init__(self, session_factory: sessionmaker) -> None: self._session_factory = session_factory # ── intent ──────────────────────────────────────────────────────────────── def begin(self, operation_id: str, *, worker_id: str, fencing_token: int) -> dict: """Record the intent to transfer **before** touching the filesystem.""" with self._session_factory() as session: row = self._require(session, operation_id) if row.fencing_token is not None and fencing_token < row.fencing_token: raise JournalConflict( f"stale fencing token {fencing_token} (current {row.fencing_token})" ) if row.journal_state in TERMINAL_STATES: raise InvalidTransition(f"{row.journal_state} is terminal") if row.journal_state != ArchiveState.TRANSFERRING and not can_transition( row.journal_state, ArchiveState.TRANSFERRING, row.direction ): raise InvalidTransition(f"{row.journal_state} -> {ArchiveState.TRANSFERRING}") if row.journal_state != ArchiveState.TRANSFERRING: row.attempt_count += 1 row.journal_state = ArchiveState.TRANSFERRING row.worker_id = worker_id row.fencing_token = fencing_token row.error_code = row.error_message = None row.updated_at = _now() session.commit() return _operation_dict(row) # ── transitions ─────────────────────────────────────────────────────────── def transition( self, operation_id: str, target: str, *, fencing_token: int | None = None, error: tuple[str, str] | None = None, same_filesystem: bool | None = None, ) -> dict: """Move one operation to ``target``, enforcing the state machine. Re-entering the state an operation already holds is a no-op, which is what makes recovery idempotent across repeated restarts. """ with self._session_factory() as session: row = self._require(session, operation_id) if fencing_token is not None and row.fencing_token is not None: if fencing_token < row.fencing_token: raise JournalConflict( f"stale fencing token {fencing_token} (current {row.fencing_token})" ) if same_filesystem is not None: row.same_filesystem = same_filesystem if row.journal_state == target: session.commit() return _operation_dict(row) # idempotent if not can_transition(row.journal_state, target, row.direction): raise InvalidTransition(f"{row.journal_state} -> {target}") row.journal_state = target row.updated_at = _now() if target == ArchiveState.VERIFIED: row.verified_at = _now() if target == ArchiveState.COMPLETE: row.removed_at = _now() if error: row.error_code, row.error_message = error[0], error[1][:500] elif target != ArchiveState.FAILED: row.error_code = row.error_message = None session.commit() return _operation_dict(row) # ── reads ───────────────────────────────────────────────────────────────── def get(self, operation_id: str) -> dict | None: with self._session_factory() as session: row = session.get(ArchiveOperation, operation_id) return _operation_dict(row) if row else None def operations(self, plan_id: str) -> list[dict]: with self._session_factory() as session: rows = session.scalars( select(ArchiveOperation) .where(ArchiveOperation.plan_id == plan_id) .order_by(ArchiveOperation.sequence) ) return [_operation_dict(row) for row in rows] def incomplete(self, *, direction: str | None = None) -> list[dict]: """Every operation left in a non-terminal, non-planned state — the work a restart has to reason about. Without ``direction`` this spans archives and restores, because either one half-done blocks the other.""" with self._session_factory() as session: stmt = select(ArchiveOperation).where( ArchiveOperation.journal_state.not_in([*TERMINAL_STATES, ArchiveState.PLANNED]) ) if direction is not None: stmt = stmt.where(ArchiveOperation.direction == direction) rows = session.scalars( stmt.order_by(ArchiveOperation.plan_id, ArchiveOperation.sequence) ) return [_operation_dict(row) for row in rows] # ── startup classification ──────────────────────────────────────────────── def classify(self, operation_id: str) -> dict: """Classify one incomplete operation from the journal plus disk evidence. Hashes the archived copy when one exists: "a file is at the destination" is not evidence that the *right* bytes are, and only the right bytes justify removing an original. Never mutates anything. """ row = self.get(operation_id) if row is None: raise JournalError(f"unknown archive operation {operation_id!r}") source = Path(row["source_path"]) destination = Path(row["destination_path"]) source_exists = source.exists() destination_exists = destination.exists() destination_matches = ( destination_exists and sha256_file(destination) == row["expected_sha256"] ) classification, reason = _classify( row["journal_state"], source_exists, destination_exists, destination_matches ) return { "operation_id": operation_id, "plan_id": row["plan_id"], "direction": row["direction"], "album": row["album"], "asset_id": row["asset_id"], "source_path": row["source_path"], "destination_path": row["destination_path"], "journal_state": row["journal_state"], "classification": classification, "reason": reason, "source_exists": source_exists, "destination_exists": destination_exists, "destination_matches": destination_matches, } def classify_all(self, *, direction: str | None = None) -> list[dict]: return [self.classify(row["id"]) for row in self.incomplete(direction=direction)] def blocks_mutation(self) -> bool: """True when any item may have the library half-archived.""" return any(row["journal_state"] in UNSAFE_STATES for row in self.incomplete()) # ── plan-level ──────────────────────────────────────────────────────────── def plan_state(self, plan_id: str) -> str: """Derive the plan's state from its items, so the summary can never disagree with the journal.""" states = {row["journal_state"] for row in self.operations(plan_id)} if not states: return "planned" if states <= {ArchiveState.COMPLETE}: return "complete" if states & {ArchiveState.FAILED}: return "failed" if states & UNSAFE_STATES: return "applying" return "planned" def sync_plan_state(self, plan_id: str) -> str: state = self.plan_state(plan_id) with self._session_factory() as session: plan = session.get(ArchivePlan, plan_id) if plan is None: raise JournalError(f"unknown archive plan {plan_id!r}") if plan.state != state: plan.state = state plan.version += 1 plan.updated_at = _now() if state == "complete" and plan.completed_at is None: plan.completed_at = _now() session.commit() return state @staticmethod def _require(session, operation_id: str) -> ArchiveOperation: row = session.get(ArchiveOperation, operation_id) if row is None: raise JournalError(f"unknown archive operation {operation_id!r}") return row def _classify( state: str, source_exists: bool, destination_exists: bool, destination_matches: bool ) -> tuple[str, str]: """The evidence table. Kept a pure function so every combination is testable.""" if destination_exists and not destination_matches and state != ArchiveState.PLANNED: # Someone else's file, or a partial/edited copy: never overwrite it, and # never treat it as the durable archive that justifies a deletion. return MANUAL, "the archived path holds bytes that are not the recorded ones" if state in (ArchiveState.TRANSFERRING, ArchiveState.FAILED): if destination_matches: return FORWARD, "the archived copy is durable; finish the remaining steps" if source_exists: return RESUMABLE, "nothing was published; the source is intact" return MANUAL, "neither the source nor a verified archive copy is present" if state in (ArchiveState.VERIFIED, ArchiveState.REMOVING): if destination_matches: return FORWARD, "the archived copy is durable; finish the remaining steps" return MANUAL, f"journal says {state} but the archived copy is missing" return MANUAL, f"unhandled journal state {state}" def _operation_dict(row: ArchiveOperation) -> dict: return { "id": row.id, "plan_id": row.plan_id, "direction": row.direction, "sequence": row.sequence, "album": row.album, "asset_id": row.asset_id, "source_path": row.source_path, "destination_path": row.destination_path, "archive_path": row.archive_path, "expected_sha256": row.expected_sha256, "byte_size": row.byte_size, "same_filesystem": row.same_filesystem, "journal_state": row.journal_state, "attempt_count": row.attempt_count, "fencing_token": row.fencing_token, "worker_id": row.worker_id, "verified_at": row.verified_at.isoformat() if row.verified_at else None, "removed_at": row.removed_at.isoformat() if row.removed_at else None, "error_code": row.error_code, "error_message": row.error_message, }