US07-04: Prove Concurrency and Crash Recovery (#86)

This commit was merged in pull request #86.
This commit is contained in:
2026-08-17 11:09:29 +02:00
parent 3fa35fe21e
commit d08d19c03c
21 changed files with 1740 additions and 40 deletions

View File

@@ -46,9 +46,11 @@ from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from photo_pipeline.faults import maybe_fault
from photo_pipeline.models import Asset, AssetPath, RenamePlan
from photo_pipeline.services import hashing
from photo_pipeline.services.rename_journal import (
ALLOWED_TRANSITIONS,
MANUAL,
RESUMABLE,
JournalState,
@@ -83,18 +85,6 @@ def _now() -> datetime:
return datetime.now(timezone.utc)
def maybe_fault(state: str) -> None:
"""Test-only crash barrier (concept §18 fault injection).
When ``PHOTO_PIPELINE_FAULT_AFTER`` names a journal state, the process dies
abruptly the moment that state has been persisted — modelling a real kill at
exactly that transition. Never set outside tests. Shared with the archive
transfer journal (US06-02), which uses the same env var and its own state names.
"""
if os.environ.get("PHOTO_PIPELINE_FAULT_AFTER") == state:
os._exit(9)
class RenameApplyService:
def __init__(self, session_factory: sessionmaker, *, library_roots: tuple = ()) -> None:
self._session_factory = session_factory
@@ -143,20 +133,10 @@ class RenameApplyService:
self._apply_one(operation, token=token, worker_id=worker_id)
applied += 1
except PreconditionFailed as error:
self.journal.transition(
operation["id"],
JournalState.FAILED,
fencing_token=token,
error=(error.code, str(error)),
)
self._record_failure(operation["id"], token, error.code, str(error))
failed += 1
except Exception as error: # unexpected: record and stop touching disk
self.journal.transition(
operation["id"],
JournalState.FAILED,
fencing_token=token,
error=("apply_error", str(error)),
)
self._record_failure(operation["id"], token, "apply_error", str(error))
failed += 1
state = self.journal.sync_plan_state(plan_id)
return {
@@ -167,6 +147,26 @@ class RenameApplyService:
"state": state,
}
def _record_failure(self, operation_id: str, token: int, code: str, message: str) -> None:
"""Record a failed operation in a state its journal can actually reach.
``failed`` only makes sense while nothing has moved. Once the folder is at
its destination — a postcondition failure such as bytes edited during the
move — the operation is not "failed and forgotten": the disk changed and
the database followed, so it becomes ``rollback_required`` and waits for a
human (US07-04). Guessing an unreachable transition used to raise out of
``apply`` and lose the record entirely.
"""
current = self.journal.get(operation_id)["journal_state"]
target = (
JournalState.FAILED
if JournalState.FAILED in ALLOWED_TRANSITIONS.get(current, set())
else JournalState.ROLLBACK_REQUIRED
)
self.journal.transition(
operation_id, target, fencing_token=token, error=(code, message)
)
def _apply_one(self, operation: dict, *, token: int, worker_id: str) -> None:
source = Path(operation["source_path"])
destination = Path(operation["destination_path"])