"""One store composition has one lock identity (R4 wave 3, item A). Ancestor containment is not authority: a lock on a parent directory is an independent OS lock from the composition's own lock, so both can be held simultaneously. Guards require the held lock's frozen identity to equal the composition's canonical lock identity (:func:`canonical_lock_root`), and the acquired identity is frozen so a later root mutation cannot redirect a held handle. """ from __future__ import annotations import os import subprocess import sys from datetime import UTC, datetime from pathlib import Path import pytest from wf_artifacts.runs.store import FileRunStore from wf_scheduling import recovery as sched_recovery from wf_scheduling.ownership import ( SchedulerOwnership, SecondOwnerError, canonical_lock_root, canonical_store_path, ) from wf_scheduling.store import FileScheduleStore def _stores(root: Path) -> tuple[FileScheduleStore, FileRunStore]: return FileScheduleStore(root / "sched"), FileRunStore(root / "runs") def test_canonical_lock_identity_is_deterministic(tmp_path: Path) -> None: comp = tmp_path / "composition" sched_store, run_store = _stores(comp) assert canonical_lock_root( sched_store.root, run_store.root ) == canonical_store_path(comp) # Aliased spellings of the same composition share the identity. dotted = tmp_path / "sub" / ".." / "composition" assert canonical_lock_root( dotted / "sched", dotted / "runs" ) == canonical_store_path(comp) # A different composition has a different identity; missing roots have none. assert canonical_lock_root(tmp_path / "other") != canonical_store_path(comp) assert canonical_lock_root(None, run_store.root) is None # type: ignore[arg-type] assert canonical_lock_root() is None def test_parent_and_child_locks_cannot_both_authorize(tmp_path: Path) -> None: comp = tmp_path / "composition" sched_store, run_store = _stores(comp) parent_lock = SchedulerOwnership(tmp_path, owner="parent").acquire() try: # A different lock file: simultaneous hold proves independence. child_lock = SchedulerOwnership(comp, owner="child").acquire() try: assert parent_lock.held and child_lock.held assert child_lock.covers(sched_store.root, run_store.root) is True assert parent_lock.covers(sched_store.root, run_store.root) is False now = datetime.now(UTC) sched_recovery.recover( schedule_store=sched_store, run_store=run_store, now=now, ownership=child_lock, ) before_runs = run_store.list_runs() before_admissions = run_store.list_admissions() with pytest.raises(SecondOwnerError): sched_recovery.recover( schedule_store=FileScheduleStore(comp / "sched"), run_store=FileRunStore(comp / "runs"), now=now, ownership=parent_lock, ) # Wrong-root rejection happens before any write. assert FileRunStore(comp / "runs").list_runs() == before_runs assert FileRunStore(comp / "runs").list_admissions() == before_admissions finally: child_lock.release() finally: parent_lock.release() def test_strict_ancestor_lock_rejected_for_nested_composition(tmp_path: Path) -> None: comp = tmp_path / "composition" sched_store, run_store = _stores(comp) grandparent = SchedulerOwnership(tmp_path.parent, owner="grandparent").acquire() try: assert grandparent.covers(sched_store.root, run_store.root) is False finally: grandparent.release() def test_competing_processes_share_the_canonical_lock(tmp_path: Path) -> None: comp = tmp_path / "composition" comp.mkdir(parents=True) sched_store, run_store = _stores(comp) identity = Path(canonical_lock_root(sched_store.root, run_store.root) or "") holder = SchedulerOwnership(identity, owner="parent").acquire() try: script_path = tmp_path / "compete_canonical.py" script_path.write_text( "import sys\n" "sys.path.insert(0, '.')\n" "from pathlib import Path\n" "from wf_scheduling.ownership import canonical_lock_root, SchedulerOwnership, SecondOwnerError\n" f"sched_root = Path({str(sched_store.root)!r})\n" f"runs_root = Path({str(run_store.root)!r})\n" "identity = Path(canonical_lock_root(sched_root, runs_root))\n" "try:\n" " SchedulerOwnership(identity, owner='child').acquire()\n" "except SecondOwnerError:\n" " print('second-owner-rejected')\n" " raise SystemExit(0)\n" "print('unexpectedly-acquired')\n" "raise SystemExit(1)\n", encoding="utf-8", ) proc = subprocess.run( [sys.executable, str(script_path)], capture_output=True, text=True, timeout=60, ) assert proc.returncode == 0, proc.stderr assert "second-owner-rejected" in proc.stdout # A lock on the parent is an independent file: it acquires, but it # cannot authorize the composition while the canonical lock is held. outsider = SchedulerOwnership(tmp_path, owner="outsider").acquire() try: assert outsider.held assert outsider.covers(sched_store.root, run_store.root) is False with pytest.raises(SecondOwnerError): sched_recovery.recover( schedule_store=FileScheduleStore(comp / "sched"), run_store=FileRunStore(comp / "runs"), now=datetime.now(UTC), ownership=outsider, ) finally: outsider.release() finally: holder.release() def test_released_or_missing_ownership_rejected(tmp_path: Path) -> None: comp = tmp_path / "composition" sched_store, run_store = _stores(comp) never = SchedulerOwnership(comp, owner="never-acquired") assert never.covers(sched_store.root, run_store.root) is False with pytest.raises(SecondOwnerError): sched_recovery.recover( schedule_store=sched_store, run_store=run_store, now=datetime.now(UTC), ownership=never, ) held = SchedulerOwnership(comp, owner="test").acquire() assert held.covers(sched_store.root, run_store.root) is True held.release() assert held.covers(sched_store.root, run_store.root) is False with pytest.raises(SecondOwnerError): sched_recovery.recover( schedule_store=sched_store, run_store=run_store, now=datetime.now(UTC), ownership=held, ) def test_root_mutation_cannot_redirect_held_authority(tmp_path: Path) -> None: comp = tmp_path / "composition" sched_store, run_store = _stores(comp) other_sched, other_runs = _stores(tmp_path / "elsewhere") ownership = SchedulerOwnership(comp, owner="test").acquire() try: assert ownership.covers(sched_store.root, run_store.root) is True ownership.root = tmp_path / "elsewhere" # Frozen identity: still proves the locked composition, never the # mutated one. assert ownership.canonical_root() == canonical_store_path(comp) assert ownership.covers(sched_store.root, run_store.root) is True assert ownership.covers(other_sched.root, other_runs.root) is False with pytest.raises(SecondOwnerError): sched_recovery.recover( schedule_store=other_sched, run_store=other_runs, now=datetime.now(UTC), ownership=ownership, ) finally: ownership.release() def test_canonical_windows_path_handling_retained(tmp_path: Path) -> None: base = tmp_path / "sched" dotted = tmp_path / "sub" / ".." / "sched" assert canonical_store_path(dotted) == canonical_store_path(base) if os.name == "nt": assert canonical_store_path("C:\\Temp\\SCHED") == canonical_store_path( "c:/temp/sched" )