"""Randomized concurrency and race tests (US07-04, concept §16 and §18). Every test here runs several times with a *recorded* seed: the seed decides the jitter injected around each racing operation, it is attached to the test result (``race_seed``), and a failing run can be replayed exactly with PHOTO_PIPELINE_RACE_SEED= pytest tests/integration/test_concurrency_races.py ``PHOTO_PIPELINE_RACE_REPEATS`` raises the repeat count for a soak run; the default is small enough to belong in the ordinary suite. The assertions are invariants, not schedules — a race whose interleaving decides the *outcome* is fine, one whose interleaving decides whether the database still makes sense is not: - no work is claimed, executed, or completed twice; - no commit from a stale fencing token lands; - no file is lost, overwritten, or left with foreign content; - a decision that changed mid-flight is never overwritten by the older answer; - the database passes ``PRAGMA integrity_check`` afterwards. """ from __future__ import annotations import os import random import subprocess import sys import threading import time import uuid from collections import Counter from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta, timezone from pathlib import Path import numpy as np import pytest from PIL import Image from sqlalchemy import select, text from photo_pipeline.config import Config from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations from photo_pipeline.faults import JOB_ITEM_DONE from photo_pipeline.jobs.worker import Worker from photo_pipeline.models import ( AlbumProposal, AnalysisResult, Asset, ExifProjection, SafetyReview, ) from photo_pipeline.services import hashing from photo_pipeline.services.analysis import AnalysisService from photo_pipeline.services.jobs import ( InvalidTransition, ItemState, JobConflict, JobService, JobState, _now, ) from photo_pipeline.services.rename_apply import RenameApplyService from photo_pipeline.services.renames import RenameService from photo_pipeline.services.thumbnails import ThumbnailError, ThumbnailService REPO = Path(__file__).resolve().parents[2] NOW = datetime(2026, 1, 1, tzinfo=timezone.utc) REPEATS = int(os.environ.get("PHOTO_PIPELINE_RACE_REPEATS", "3")) MAX_JITTER_SECONDS = 0.01 # ── seeded randomness ──────────────────────────────────────────────────────── @pytest.fixture(params=range(REPEATS), ids=lambda run: f"run{run}") def rng(request, record_property): """A seeded RNG whose seed is recorded on the test result. Without a pinned seed the run is genuinely random — which is the point, a fixed schedule stops finding new interleavings after the first green run — so the seed is reported for replay instead. """ pinned = os.environ.get("PHOTO_PIPELINE_RACE_SEED") seed = int(pinned) + request.param if pinned else random.SystemRandom().randrange(2**32) record_property("race_seed", seed) print(f"race seed: {seed}") # visible with -s and in the failure report return random.Random(seed) def jitter(rng: random.Random) -> None: """Sleep a random sliver so racing threads interleave differently each run.""" time.sleep(rng.uniform(0, MAX_JITTER_SECONDS)) # ── fixtures ───────────────────────────────────────────────────────────────── def _config(tmp_path) -> tuple[Config, Path]: (tmp_path / "data").mkdir(exist_ok=True) lib = tmp_path / "lib" lib.mkdir(exist_ok=True) config = Config.from_env( { "PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "data"), "PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib), } ) run_migrations(config.database_url) return config, lib @pytest.fixture def stack(tmp_path): """Config, library root, and a factory for *independent* session factories — each thread must own its session (concept §16 database rule 1).""" config, lib = _config(tmp_path) engines = [] def factory(): engine = create_db_engine(config.database_url) engines.append(engine) return create_session_factory(engine) yield config, lib, factory for engine in engines: engine.dispose() def image(path: Path, seed: int) -> None: path.parent.mkdir(parents=True, exist_ok=True) pixels = np.random.default_rng(seed).integers(0, 256, (64, 96, 3), dtype=np.uint8) Image.fromarray(pixels).save(path, quality=90) def register(sf, path: Path) -> str: asset_id = str(uuid.uuid4()) with sf() as session: session.add( Asset( id=asset_id, original_path=str(path), current_path=str(path), discovered_at=NOW, hash_version=1, byte_size=path.stat().st_size, current_sha256=hashing.sha256_file(path), ) ) session.commit() return asset_id def album(sf, lib: Path, name: str, *, approved_name: str, count: int = 2) -> list[str]: """A real folder of real images with an approved proposal renaming it.""" folder = lib / name ids = [] for index in range(count): path = folder / f"{name}-{index}.jpg" image(path, seed=abs(hash((name, index))) % 10_000) ids.append(register(sf, path)) with sf() as session: session.add( AlbumProposal( id=str(uuid.uuid4()), album=name, proposed_name=approved_name, final_name=approved_name, status="approved", version=2, ) ) session.commit() return ids def contents(lib: Path) -> Counter: """Every file body under the library — what may never be lost or duplicated.""" return Counter( path.read_bytes() for path in lib.rglob("*") if path.is_file() and ".rename-" not in path.name ) def integrity_ok(sf) -> bool: with sf() as session: return session.execute(text("PRAGMA integrity_check")).scalar() == "ok" # ── database writer pressure ───────────────────────────────────────────────── def test_many_writers_finish_without_losing_a_row(stack, rng): """Eight lanes committing at once: SQLite has one writer, so this either works through the busy timeout or loses data. Nothing may be lost.""" _, _, factory = stack service = JobService(factory()) job_ids = [service.enqueue("scan", items=[f"i{n}" for n in range(4)])["id"] for n in range(8)] def write(index: int) -> None: own = JobService(factory()) job_id = job_ids[index] token = own.claim(["scan"], f"w{index}") if token is None: return for item in [f"i{n}" for n in range(4)]: jitter(rng) own.set_item(token["id"], item, ItemState.RUNNING, fencing_token=token["fencing_token"]) own.set_item( token["id"], item, ItemState.SUCCEEDED, fencing_token=token["fencing_token"] ) assert job_id # the claim order is racy; every job is claimed by someone with ThreadPoolExecutor(max_workers=8) as pool: list(pool.map(write, range(8))) for job_id in job_ids: progress = service.progress(job_id) assert progress["by_state"].get(ItemState.SUCCEEDED) == 4, progress assert integrity_ok(factory()) # ── worker claim races ─────────────────────────────────────────────────────── def test_a_job_is_claimed_by_exactly_one_worker(stack, rng): _, _, factory = stack service = JobService(factory()) job_ids = {service.enqueue("scan", items=["a"])["id"] for _ in range(10)} claimed: list[str] = [] lock = threading.Lock() def claim_all(index: int) -> None: own = JobService(factory()) while True: jitter(rng) job = own.claim(["scan"], f"w{index}") if job is None: return with lock: claimed.append(job["id"]) with ThreadPoolExecutor(max_workers=4) as pool: list(pool.map(claim_all, range(4))) assert sorted(claimed) == sorted(job_ids), "every job claimed exactly once" assert len(set(claimed)) == len(claimed) def test_a_late_commit_from_an_expired_lease_is_refused(stack, rng): """Lease expiry then a late write from the old owner: the fencing token, not timing, decides who may commit.""" _, _, factory = stack service = JobService(factory()) job = service.enqueue("scan", items=["a"]) old = service.claim(["scan"], "old") service.recover_stale(now=_now() + timedelta(hours=1)) new = service.claim(["scan"], "new") jitter(rng) with pytest.raises(JobConflict): service.set_item(job["id"], "a", ItemState.RUNNING, fencing_token=old["fencing_token"]) with pytest.raises(JobConflict): service.transition(job["id"], JobState.SUCCEEDED, fencing_token=old["fencing_token"]) # The current owner finishes the same work without interference. service.set_item(job["id"], "a", ItemState.RUNNING, fencing_token=new["fencing_token"]) service.set_item(job["id"], "a", ItemState.SUCCEEDED, fencing_token=new["fencing_token"]) service.transition(job["id"], JobState.SUCCEEDED, fencing_token=new["fencing_token"]) assert service.get(job["id"])["state"] == JobState.SUCCEEDED assert integrity_ok(factory()) def test_cancellation_arriving_at_a_random_moment_leaves_a_consistent_job(stack, rng): _, _, factory = stack service = JobService(factory()) job = service.enqueue("scan", items=[f"i{n}" for n in range(6)]) handled: list[str] = [] def handler(item, ctx): jitter(rng) handled.append(item) def cancel() -> None: try: service.cancel(job["id"]) except (JobConflict, InvalidTransition): # The request lost its race with the worker's own transition; the job # simply finishes. What must never happen is a *silent* overwrite. pass canceller = threading.Timer(rng.uniform(0, 0.03), cancel) canceller.start() Worker(factory(), {"scan": handler}, "w1").run_once() canceller.join() state = service.get(job["id"])["state"] assert state in {JobState.SUCCEEDED, JobState.CANCELLED, JobState.CANCELLING} by_state = service.progress(job["id"])["by_state"] assert by_state.get(ItemState.RUNNING, 0) == 0, "no item left mid-flight" assert len(handled) == len(set(handled)), "no item ran twice" if state == JobState.CANCELLED: # Whatever did not run is resumable, never silently dropped. assert by_state.get(ItemState.SUCCEEDED, 0) + by_state.get(ItemState.QUEUED, 0) == 6 assert integrity_ok(factory()) KILLABLE_WORKER = """ import sys sys.path.insert(0, {repo!r}) from pathlib import Path from photo_pipeline.db import create_db_engine, create_session_factory from photo_pipeline.jobs.worker import Worker db_url, log = sys.argv[1], Path(sys.argv[2]) def handler(item_key, ctx): with log.open("a") as handle: handle.write(item_key + "\\n") sf = create_session_factory(create_db_engine(db_url)) Worker(sf, {{"scan": handler}}, sys.argv[3], lease_seconds=1).run_once() """ def test_a_worker_killed_at_a_random_item_resumes_exactly_once(stack, rng, tmp_path): """Kill a real worker mid-batch, restart it, and assert the invariant across the restart: every item ran exactly once and the job ends succeeded.""" config, _, factory = stack service = JobService(factory()) items = [f"i{n}" for n in range(rng.randint(2, 5))] job = service.enqueue("scan", items=items) log = tmp_path / "handled.log" script = tmp_path / "killable_worker.py" script.write_text(KILLABLE_WORKER.format(repo=str(REPO))) def run_worker(worker_id: str, barrier: str | None) -> None: env = dict(os.environ) env.pop("PHOTO_PIPELINE_FAULT_AFTER", None) if barrier: env["PHOTO_PIPELINE_FAULT_AFTER"] = barrier subprocess.run( [sys.executable, str(script), config.database_url, str(log), worker_id], env=env, capture_output=True, ) run_worker("killable", JOB_ITEM_DONE) # dies after its first completed item assert log.read_text().split() == items[:1] time.sleep(1.1) # let the dead worker's one-second lease expire jitter(rng) run_worker("survivor", None) assert service.get(job["id"])["state"] == JobState.SUCCEEDED handled = log.read_text().split() assert sorted(handled) == sorted(items), f"an item ran twice or not at all: {handled}" assert integrity_ok(factory()) # ── filesystem races ───────────────────────────────────────────────────────── def test_a_file_changed_during_apply_never_loses_content(stack, rng): """The user edits a photo while its folder is being renamed. The rename may win or be refused, but no body may disappear or be overwritten.""" config, lib, factory = stack sf = factory() album(sf, lib, "rome", approved_name="2019 Rome") plan = RenameService(sf, library_roots=(lib,)).build_plan() before = contents(lib) edited = b"the user saved over this file" def edit() -> None: jitter(rng) target = next((lib / "rome").glob("*.jpg"), None) if target is None: return # the rename won the race; the folder already moved try: target.write_bytes(edited) except OSError: pass # ...or it moved between the glob and the write thread = threading.Thread(target=edit) thread.start() RenameApplyService(sf, library_roots=(lib,)).apply(plan["id"], expected_version=plan["version"]) thread.join() after = contents(lib) assert sum(after.values()) == sum(before.values()), "a file was lost or duplicated" survived = (before - Counter({edited: 1})) & after assert sum(survived.values()) >= sum(before.values()) - 1, "unrelated content was destroyed" assert integrity_ok(sf) def test_two_folders_claiming_one_destination_never_merge(stack, rng): """Two approved albums want the same name. The plan must refuse rather than move one folder into the other.""" config, lib, factory = stack sf = factory() album(sf, lib, "rome-a", approved_name="2019 Rome") album(sf, lib, "rome-b", approved_name="2019 Rome") jitter(rng) plan = RenameService(sf, library_roots=(lib,)).build_plan() codes = {issue["code"] for op in plan["operations"] for issue in op["issues"]} assert "duplicate_target" in codes assert plan["applicable"] is False assert (lib / "rome-a").is_dir() and (lib / "rome-b").is_dir() def test_thumbnail_requests_racing_a_rename_never_serve_a_wrong_file(stack, rng): """Previews are keyed by pixels, not paths, so a rename must not make a request fail loudly *or* return another asset's picture.""" config, lib, factory = stack sf = factory() asset_ids = album(sf, lib, "rome", approved_name="2019 Rome") plan = RenameService(sf, library_roots=(lib,)).build_plan() thumbnails = ThumbnailService(factory(), config) expected = {aid: thumbnails.generate(aid, 256).read_bytes() for aid in asset_ids} served: dict[str, set[bytes]] = {aid: set() for aid in asset_ids} errors: list[str] = [] stop = threading.Event() def serve() -> None: own = ThumbnailService(factory(), config) while not stop.is_set(): for asset_id in asset_ids: jitter(rng) try: served[asset_id].add(own.generate(asset_id, 256).read_bytes()) except ThumbnailError as error: errors.append(error.code) # precise, never an unhandled crash reader = threading.Thread(target=serve) reader.start() try: RenameApplyService(sf, library_roots=(lib,)).apply( plan["id"], expected_version=plan["version"] ) finally: stop.set() reader.join() for asset_id, bodies in served.items(): assert bodies <= {expected[asset_id]}, "a request served another asset's picture" assert integrity_ok(sf) # ── stage races ────────────────────────────────────────────────────────────── def test_an_analysis_result_racing_a_safety_flip_is_discarded(stack, rng): """Concept §18 scenario 7: the reviewer marks an asset NSFW while the provider call is in flight. The answer that comes back describes an asset that may no longer be analysed, so it is dropped — and no analysis EXIF is written.""" _, lib, factory = stack sf = factory() path = lib / "beach.jpg" image(path, seed=7) asset_id = register(sf, path) with sf() as session: session.add( SafetyReview(id=str(uuid.uuid4()), asset_id=asset_id, decision="sfw", created_at=NOW) ) session.commit() before_sha = hashing.sha256_file(path) class FlippingProvider: """Records the call, then the reviewer's decision lands mid-flight.""" def __init__(self) -> None: self.calls: list[str] = [] def analyze(self, path, *, album_hint): self.calls.append(path) jitter(rng) with sf() as session: session.add( SafetyReview( id=str(uuid.uuid4()), asset_id=asset_id, decision="nsfw", prior_decision="sfw", created_at=NOW + timedelta(minutes=1), ) ) session.commit() return {"description": "a beach", "tags": ["beach", "sand"]} provider = FlippingProvider() result = AnalysisService(sf, provider=provider, library_roots=(lib,)).run([asset_id]) assert provider.calls == [str(path)], "the call was legitimate when it started" assert result == {"analyzed": 0, "skipped": 1, "errors": 0} with sf() as session: row = session.get(AnalysisResult, asset_id) assert row.status == "skipped_nsfw" and row.description is None assert session.get(ExifProjection, (asset_id, "analysis")) is None assert hashing.sha256_file(path) == before_sha, "the file was written after the flip" def test_a_safety_decision_taken_twice_at_once_keeps_one_history(stack, rng): """Two windows, one asset, the same decision: the audit trail may record both attempts, but the effective state must be a single coherent decision.""" _, lib, factory = stack sf = factory() path = lib / "twice.jpg" image(path, seed=11) asset_id = register(sf, path) from photo_pipeline.services.safety import SafetyService def decide(decision: str) -> None: jitter(rng) SafetyService(factory()).decide(asset_id, decision, write_exif=False) with ThreadPoolExecutor(max_workers=2) as pool: list(pool.map(decide, ["sfw", "sfw"])) with sf() as session: decisions = [ review.decision for review in session.scalars( select(SafetyReview) .where(SafetyReview.asset_id == asset_id) .order_by(SafetyReview.created_at) ) ] assert decisions and set(decisions) == {"sfw"} assert integrity_ok(sf)