Skip to content

Commit b45a68b

Browse files
Add recoverable recognition mutation journal
1 parent 1d56729 commit b45a68b

5 files changed

Lines changed: 1021 additions & 0 deletions

File tree

.github/workflows/test.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,3 +223,39 @@ jobs:
223223
tests/ui/controllers/test_player_view_controller_adjustments.py
224224
tests/ui/controllers/test_player_view_init_cover.py
225225
tests/ui/test_window_manager_geometry.py
226+
227+
recognition-contracts:
228+
strategy:
229+
fail-fast: false
230+
matrix:
231+
os: [ubuntu-latest, macos-latest, windows-latest]
232+
runs-on: ${{ matrix.os }}
233+
timeout-minutes: 20
234+
235+
steps:
236+
- uses: actions/checkout@v4
237+
238+
- uses: actions/setup-python@v5
239+
with:
240+
python-version: "3.12"
241+
cache: pip
242+
cache-dependency-path: pyproject.toml
243+
244+
- name: Install Linux system dependencies
245+
if: runner.os == 'Linux'
246+
run: |
247+
sudo apt-get update
248+
sudo apt-get install -y --no-install-recommends libegl1 libgl1 libxkbcommon0 libpulse0
249+
250+
- name: Install Python dependencies
251+
run: |
252+
python -m pip install --upgrade pip
253+
pip install -e ".[test]"
254+
255+
- name: Run Recognition recovery and concurrency contracts
256+
env:
257+
QT_QPA_PLATFORM: offscreen
258+
NUMBA_DISABLE_JIT: "1"
259+
run: >-
260+
python -m pytest -q
261+
tests/test_recognition_operation_journal.py

src/iPhoto/recognition/__init__.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Shared recognition coordination infrastructure."""
2+
3+
from .mutation_coordinator import (
4+
RecognitionMutationCoordinator,
5+
RecognitionMutationFailure,
6+
RecognitionMutationOutcome,
7+
get_recognition_mutation_coordinator,
8+
)
9+
from .operation_journal import (
10+
RecognitionOperation,
11+
RecognitionOperationJournal,
12+
RecognitionOperationKind,
13+
RecognitionOperationState,
14+
RecognitionOutboxEvent,
15+
)
16+
17+
__all__ = [
18+
"RecognitionMutationCoordinator",
19+
"RecognitionMutationFailure",
20+
"RecognitionMutationOutcome",
21+
"RecognitionOperation",
22+
"RecognitionOperationJournal",
23+
"RecognitionOperationKind",
24+
"RecognitionOperationState",
25+
"RecognitionOutboxEvent",
26+
"get_recognition_mutation_coordinator",
27+
]
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
"""Library-scoped admission, recovery, and event dispatch for recognition writes."""
2+
3+
from __future__ import annotations
4+
5+
import threading
6+
from collections.abc import Callable, Iterator
7+
from contextlib import contextmanager
8+
from dataclasses import dataclass
9+
from enum import StrEnum
10+
from pathlib import Path
11+
from typing import Any
12+
13+
from iPhoto.utils.pathutils import ensure_work_dir
14+
15+
from .operation_journal import (
16+
RecognitionOperation,
17+
RecognitionOperationJournal,
18+
RecognitionOperationKind,
19+
RecognitionOperationState,
20+
RecognitionOutboxEvent,
21+
)
22+
23+
24+
class RecognitionMutationFailure(StrEnum):
25+
REJECTED = "rejected"
26+
RECOVERY_PENDING = "recovery_pending"
27+
SHUTTING_DOWN = "shutting_down"
28+
29+
30+
@dataclass(frozen=True, slots=True)
31+
class RecognitionMutationOutcome[T]:
32+
succeeded: bool
33+
value: T | None = None
34+
failure: RecognitionMutationFailure | None = None
35+
operation_id: str | None = None
36+
37+
38+
@dataclass(slots=True)
39+
class _ExecutionLease:
40+
lock: threading.RLock
41+
references: int = 0
42+
43+
44+
RecoveryHandler = Callable[[RecognitionOperation], bool]
45+
EventSubscriber = Callable[[RecognitionOutboxEvent], None]
46+
47+
48+
_EXECUTION_LOCKS: dict[Path, _ExecutionLease] = {}
49+
_EXECUTION_LOCKS_GUARD = threading.Lock()
50+
51+
52+
def _acquire_execution_lease(library_root: Path) -> threading.RLock:
53+
"""Return the process-wide lifecycle lease for one recognition library."""
54+
55+
resolved = Path(library_root).resolve()
56+
with _EXECUTION_LOCKS_GUARD:
57+
lease = _EXECUTION_LOCKS.get(resolved)
58+
if lease is None:
59+
lease = _ExecutionLease(threading.RLock())
60+
_EXECUTION_LOCKS[resolved] = lease
61+
lease.references += 1
62+
return lease.lock
63+
64+
65+
def _release_execution_lease(library_root: Path) -> None:
66+
resolved = Path(library_root).resolve()
67+
with _EXECUTION_LOCKS_GUARD:
68+
lease = _EXECUTION_LOCKS.get(resolved)
69+
if lease is None:
70+
return
71+
lease.references -= 1
72+
if lease.references <= 0:
73+
_EXECUTION_LOCKS.pop(resolved, None)
74+
75+
76+
class RecognitionMutationCoordinator:
77+
"""The only owner of a library's global recognition operation journal.
78+
79+
Domain coordinators register typed recovery handlers, while normal writes use
80+
this object for global admission. Event delivery is at-least-once: a crash
81+
after publishing but before the dispatched CAS may replay the same event id.
82+
"""
83+
84+
def __init__(self, library_root: Path) -> None:
85+
self._library_root = Path(library_root).resolve()
86+
self._journal = RecognitionOperationJournal(
87+
ensure_work_dir(self._library_root) / "recognition" / "operations.db"
88+
)
89+
self._execution_lock = _acquire_execution_lease(self._library_root)
90+
self._closed = False
91+
self._lock = threading.RLock()
92+
self._handlers: dict[str, list[RecoveryHandler]] = {}
93+
self._subscribers: list[EventSubscriber] = []
94+
self._recovery_error: Exception | None = None
95+
96+
@property
97+
def library_root(self) -> Path:
98+
return self._library_root
99+
100+
@property
101+
def recovery_pending(self) -> bool:
102+
return bool(self._journal.unfinished()) or self._recovery_error is not None
103+
104+
@property
105+
def recovery_error(self) -> Exception | None:
106+
return self._recovery_error
107+
108+
@property
109+
def execution_lock(self) -> threading.RLock:
110+
"""The shared lease that covers a mutation's complete apply lifecycle."""
111+
112+
return self._execution_lock
113+
114+
@contextmanager
115+
def mutation_scope(self) -> Iterator[None]:
116+
"""Prevent live work from being mistaken for crash recovery.
117+
118+
The lease is shared by every coordinator instance for this library and
119+
intentionally remains held from admission through commit/finalize. A
120+
process crash releases it, allowing the next process to recover the
121+
durable journal head.
122+
"""
123+
124+
with self._execution_lock:
125+
if self._closed:
126+
raise RuntimeError("Recognition mutation coordinator is closed.")
127+
yield
128+
129+
def register_recovery_handler(
130+
self,
131+
kinds: set[str | RecognitionOperationKind],
132+
handler: RecoveryHandler,
133+
) -> None:
134+
with self._lock:
135+
if self._closed:
136+
raise RuntimeError("Recognition mutation coordinator is closed.")
137+
for kind in kinds:
138+
normalized = str(kind)
139+
handlers = self._handlers.setdefault(normalized, [])
140+
if handler not in handlers:
141+
handlers.insert(0, handler)
142+
143+
def subscribe(self, subscriber: EventSubscriber) -> None:
144+
with self._lock:
145+
if self._closed:
146+
raise RuntimeError("Recognition mutation coordinator is closed.")
147+
if subscriber not in self._subscribers:
148+
self._subscribers.append(subscriber)
149+
self.dispatch_pending()
150+
151+
def unsubscribe(self, subscriber: EventSubscriber) -> None:
152+
with self._lock:
153+
if subscriber in self._subscribers:
154+
self._subscribers.remove(subscriber)
155+
156+
def close(self) -> None:
157+
"""Release session-owned handlers, subscribers, and the root lease."""
158+
159+
if self._closed:
160+
return
161+
with self._execution_lock, self._lock:
162+
if self._closed:
163+
return
164+
self._handlers.clear()
165+
self._subscribers.clear()
166+
self._closed = True
167+
_release_execution_lease(self._library_root)
168+
169+
def recover_pending(self) -> bool:
170+
with self.mutation_scope(), self._lock:
171+
try:
172+
while (operation := self._journal.unfinished_head()) is not None:
173+
if operation.state == RecognitionOperationState.COMMITTED and self._subscribers:
174+
if not self.dispatch_pending():
175+
return False
176+
continue
177+
handlers = self._handlers.get(operation.kind, ())
178+
if not handlers:
179+
self._recovery_error = RuntimeError(
180+
"No recovery handler is registered for recognition operation "
181+
f"{operation.kind}/{operation.operation_id}."
182+
)
183+
return False
184+
if not any(handler(operation) for handler in tuple(handlers)):
185+
self._recovery_error = RuntimeError(
186+
"Recognition operation recovery is incomplete for "
187+
f"{operation.kind}/{operation.operation_id}."
188+
)
189+
return False
190+
self._recovery_error = None
191+
return True
192+
except Exception as exc: # noqa: BLE001
193+
self._recovery_error = exc
194+
return False
195+
196+
def try_prepare(
197+
self,
198+
kind: str | RecognitionOperationKind,
199+
payload: dict[str, Any],
200+
) -> str | None:
201+
with self.mutation_scope(), self._lock:
202+
operation_id = self._journal.try_prepare(kind, payload)
203+
if operation_id is None:
204+
return None
205+
if not self._journal.transition(
206+
operation_id,
207+
RecognitionOperationState.APPLYING,
208+
expected_state=RecognitionOperationState.PREPARED,
209+
):
210+
raise RuntimeError(f"Recognition operation lost its prepared lease: {operation_id}")
211+
return operation_id
212+
213+
def prepare(self, kind: str, payload: dict[str, Any]) -> str:
214+
"""Compatibility primitive for recovery fixtures and legacy tests."""
215+
216+
with self.mutation_scope():
217+
return self._journal.prepare(kind, payload)
218+
219+
def transition(self, *args, **kwargs) -> bool:
220+
with self.mutation_scope():
221+
operation_id = str(args[0] if args else kwargs.get("operation_id") or "")
222+
if kwargs.get("expected_state") is None:
223+
current = next(
224+
(
225+
operation.state
226+
for operation in self._journal.unfinished()
227+
if operation.operation_id == operation_id
228+
),
229+
None,
230+
)
231+
if current is None:
232+
raise RuntimeError(
233+
f"Recognition operation has no active state lease: {operation_id}"
234+
)
235+
kwargs["expected_state"] = current
236+
succeeded = self._journal.transition(*args, **kwargs)
237+
if not succeeded:
238+
raise RuntimeError(f"Recognition operation state CAS failed: {operation_id}")
239+
return True
240+
241+
def commit_outbox(self, *args, **kwargs) -> str:
242+
with self.mutation_scope():
243+
return self._journal.commit_outbox(*args, **kwargs)
244+
245+
def commit_and_dispatch(
246+
self,
247+
operation_id: str,
248+
event: dict[str, Any],
249+
dispatch: Callable[[], None],
250+
) -> str:
251+
"""Persist an event before delivery, then finalize its CAS acknowledgment."""
252+
253+
with self.mutation_scope(), self._lock:
254+
event_id = self._journal.commit_outbox(operation_id, event)
255+
dispatch()
256+
if not self._journal.mark_dispatched(operation_id):
257+
raise RuntimeError(f"Recognition event dispatch CAS failed: {event_id}")
258+
return event_id
259+
260+
def mark_dispatched(self, operation_id: str) -> bool:
261+
with self.mutation_scope():
262+
return self._journal.mark_dispatched(operation_id)
263+
264+
def mark_published(self, operation_id: str) -> None:
265+
with self.mutation_scope():
266+
self._journal.mark_published(operation_id)
267+
268+
def unfinished(self) -> tuple[RecognitionOperation, ...]:
269+
with self.mutation_scope():
270+
return self._journal.unfinished()
271+
272+
def unfinished_head(self) -> RecognitionOperation | None:
273+
with self.mutation_scope():
274+
return self._journal.unfinished_head()
275+
276+
def pending_events(self) -> tuple[RecognitionOutboxEvent, ...]:
277+
with self.mutation_scope():
278+
return self._journal.pending_events()
279+
280+
def dispatch_pending(self) -> bool:
281+
with self.mutation_scope(), self._lock:
282+
for event in self._journal.pending_events():
283+
try:
284+
for subscriber in tuple(self._subscribers):
285+
subscriber(event)
286+
except Exception as exc: # noqa: BLE001
287+
self._recovery_error = exc
288+
return False
289+
if not self._journal.mark_dispatched(event.operation_id):
290+
self._recovery_error = RuntimeError(
291+
f"Recognition event dispatch CAS failed: {event.event_id}"
292+
)
293+
return False
294+
return True
295+
296+
297+
def get_recognition_mutation_coordinator(
298+
library_root: Path,
299+
) -> RecognitionMutationCoordinator:
300+
"""Compatibility factory; production sessions inject their owned instance."""
301+
302+
return RecognitionMutationCoordinator(Path(library_root).resolve())
303+
304+
305+
def reset_recognition_mutation_coordinators() -> None:
306+
"""Compatibility no-op retained for older tests."""
307+
308+
309+
__all__ = [
310+
"RecognitionMutationCoordinator",
311+
"RecognitionMutationFailure",
312+
"RecognitionMutationOutcome",
313+
"get_recognition_mutation_coordinator",
314+
"reset_recognition_mutation_coordinators",
315+
]

0 commit comments

Comments
 (0)