Skip to content

Commit 002ef54

Browse files
committed
fix(queue): stop() persistiert PRINTING-Jobs als FAILED in DB
Schließt Spec-Errata C2 und Code-Quality-Finding C-1. Plus Header-Docstring Phase-2-aktualisiert und __init__ Docstring zur store-Optional-Entscheidung ergänzt. Refs #93
1 parent 7aa9027 commit 002ef54

2 files changed

Lines changed: 73 additions & 3 deletions

File tree

backend/app/services/print_queue.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
"""Per-printer async work queue.
1+
"""Per-printer async work queue mit Persistierungs-Boundary.
22
33
Brother PT/QL printers expose TCP/9100 as a single-stream channel — there is
44
no on-device multi-job queue. The hub serialises jobs per printer by running
55
one asyncio worker task per printer and feeding it from an asyncio.Queue.
66
7-
Jobs live in-memory (MVP). Phase 5 will add SQLite persistence behind a
8-
JobStore protocol that this module will accept by dependency injection.
7+
In-Memory dataclass `Job` Instanzen (mit image_payload bytes und
8+
asyncio.Event) leben in `_jobs` während des Worker-Loops. Parallel
9+
persistiert der `JobStore` die SQLModel-Job-Rows in SQLite — siehe
10+
`app/services/job_store.py` (Phase 2).
911
1012
Internal dependency note: the worker reads `job._done_event` (a private field
1113
on `Job`) to signal completion to `wait_for_job`. `PrintQueue` and
@@ -137,6 +139,19 @@ def __init__(
137139
on_state_change: _StateChangeCallback | None = None,
138140
store: JobStore | None = None,
139141
) -> None:
142+
"""Konstruktor.
143+
144+
Args:
145+
printers: Liste der Drucker-Objekte (jeder mit ``id`` UUID und
146+
``print_image`` Coroutine-Methode).
147+
on_state_change: Optionaler Callback für SSE-Events. Wird nach
148+
jeder Job-State-Transition aufgerufen; None deaktiviert das
149+
Callback ohne sonstige Seiteneffekte.
150+
store: JobStore für DB-Persistierung der Job-Transitionen.
151+
Default ist ``MemoryJobStore()`` für Backward-Compat mit
152+
Pre-Phase-2-Tests — Production-Code wired in Lifespan
153+
explizit ``SQLiteJobStore`` ein (Task 9).
154+
"""
140155
self._on_state_change = on_state_change
141156
self._store: JobStore = store if store is not None else MemoryJobStore()
142157
self._printers: dict[UUID, _PrinterLike] = {p.id: p for p in printers}
@@ -222,6 +237,7 @@ async def stop(self, timeout_s: float = 30.0) -> None:
222237
job.error_msg = job.error_message # keep legacy field in sync (see line 466)
223238
try:
224239
JobStateMachine.transition(job, JobState.FAILED)
240+
await self._store.mark_failed(UUID(job.id), "shutdown")
225241
except InvalidStateTransitionError:
226242
# Defensive: job already moved to a terminal state by the
227243
# worker — just ensure _done_event is set.

backend/tests/unit/services/test_print_queue_persistence.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,57 @@ async def test_printqueue_calls_mark_failed_on_printer_error() -> None:
9393
actual_uuid = store.mark_failed.call_args.args[0]
9494
assert actual_uuid == expected_uuid
9595
store.mark_done.assert_not_awaited()
96+
97+
98+
@pytest.mark.asyncio
99+
async def test_stop_marks_inflight_jobs_as_failed_in_db() -> None:
100+
"""stop() muss PRINTING-Jobs per store.mark_failed(id, 'shutdown') in DB persistieren.
101+
102+
Spec-Errata C2: Der In-Memory-Zustand wurde bereits vor diesem Fix korrekt
103+
auf FAILED gesetzt — aber der DB-Store-Aufruf fehlte (C-1 Fix).
104+
Dieser Test verifiziert dass stop() await self._store.mark_failed(UUID(job.id), 'shutdown')
105+
aufruft wenn ein Job beim Shutdown noch in PRINTING war.
106+
"""
107+
import asyncio
108+
109+
store = AsyncMock(spec=MemoryJobStore)
110+
111+
# Printer der nie fertig wird — blockiert den Worker im print_image-Aufruf
112+
# bis der Test stop() aufruft und der Task gecancelled wird.
113+
printer = _make_printer()
114+
115+
async def _blocking_print(*_args: object, **_kwargs: object) -> None:
116+
await asyncio.sleep(60) # blockiert bis CancelledError via stop()
117+
118+
printer.print_image = _blocking_print # type: ignore[assignment]
119+
120+
queue = PrintQueue(printers=[printer], store=store)
121+
await queue.start()
122+
123+
job_id = await queue.submit(_PRINTER_ID, _sample_image(), tape_mm=12)
124+
125+
# Kurz warten bis der Worker den Job in PRINTING übernommen hat.
126+
from app.services.job_lifecycle import JobState as InMemState
127+
for _ in range(50):
128+
job = await queue.get(job_id)
129+
if job.state == InMemState.PRINTING:
130+
break
131+
await asyncio.sleep(0.05)
132+
else:
133+
pytest.fail("Job erreichte PRINTING-State nicht innerhalb der Wartezeit")
134+
135+
# stop() soll den Worker cancellen und dann PRINTING→FAILED + mark_failed aufrufen.
136+
await queue.stop(timeout_s=0.1)
137+
138+
# Verifizierung: mark_failed muss mit der richtigen UUID und error='shutdown' aufgerufen worden sein.
139+
expected_uuid = UUID(job_id)
140+
# mark_failed kann mehrfach aufgerufen werden (einmal durch Worker-CancelledError-Pfad
141+
# und einmal durch stop()-Cleanup) — mindestens ein Call muss (uuid, 'shutdown') sein.
142+
shutdown_calls = [
143+
call for call in store.mark_failed.call_args_list
144+
if call.args == (expected_uuid, "shutdown")
145+
]
146+
assert shutdown_calls, (
147+
f"store.mark_failed(UUID(job_id), 'shutdown') wurde nicht aufgerufen. "
148+
f"Tatsächliche Calls: {store.mark_failed.call_args_list}"
149+
)

0 commit comments

Comments
 (0)