Skip to content

Commit 7aa9027

Browse files
committed
feat(queue): PrintQueue ruft JobStore bei jeder State-Transition (Phase 2)
QUEUED -> PRINTING -> DONE/FAILED wird synchron in der DB persistiert. Konstruktor bekommt store: JobStore als optionalen Parameter (Default: MemoryJobStore für Rückwärtskompatibilität). Worker bridget dataclass.id (str) via UUID(job.id) an den Store. Skip-Check bleibt vor mark_printing. _jobs dict bleibt für laufende Drucke mit image_payload erhalten. Refs #93
1 parent 223a0cb commit 7aa9027

2 files changed

Lines changed: 106 additions & 0 deletions

File tree

backend/app/services/print_queue.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
JobState,
4141
JobStateMachine,
4242
)
43+
from app.services.job_store import JobStore, MemoryJobStore
4344

4445

4546
# Callback type: called after each state transition. The optional queue_depth
@@ -134,8 +135,10 @@ def __init__(
134135
self,
135136
printers: list[_PrinterLike],
136137
on_state_change: _StateChangeCallback | None = None,
138+
store: JobStore | None = None,
137139
) -> None:
138140
self._on_state_change = on_state_change
141+
self._store: JobStore = store if store is not None else MemoryJobStore()
139142
self._printers: dict[UUID, _PrinterLike] = {p.id: p for p in printers}
140143
# Queue type is Job | None — None is the sentinel used by stop() to wake
141144
# workers that are blocked at queue.get().
@@ -513,6 +516,9 @@ async def _worker(self, printer_id: UUID) -> None:
513516
if job.state != JobState.QUEUED:
514517
continue
515518

519+
# Phase 2: DB-State QUEUED->PRINTING persistieren (bridge: dataclass.id ist str)
520+
await self._store.mark_printing(UUID(job.id))
521+
516522
try:
517523
_from = job.state
518524
JobStateMachine.transition(job, JobState.PRINTING)
@@ -532,6 +538,7 @@ async def _worker(self, printer_id: UUID) -> None:
532538
await printer.print_image(image, tape_mm=job.tape_mm, **job.options)
533539
_from = job.state
534540
JobStateMachine.transition(job, JobState.COMPLETED)
541+
await self._store.mark_done(UUID(job.id)) # Phase 2: DB-State persistieren
535542
self._notify_state_change(
536543
job,
537544
_from,
@@ -564,6 +571,8 @@ async def _worker(self, printer_id: UUID) -> None:
564571
job.state,
565572
exc,
566573
)
574+
# Phase 2: DB-State persistieren (auch wenn Transition fehlschlug)
575+
await self._store.mark_failed(UUID(job.id), f"{code}: {msg}")
567576
logger.exception("Job %s failed on %s (printer error)", job.id, printer_id)
568577
if isinstance(exc, _RECOVERABLE_PRINTER_ERRORS):
569578
# Halt the whole printer queue — user must change tape /
@@ -589,4 +598,6 @@ async def _worker(self, printer_id: UUID) -> None:
589598
job.state,
590599
exc,
591600
)
601+
# Phase 2: DB-State persistieren (auch wenn Transition fehlschlug)
602+
await self._store.mark_failed(UUID(job.id), str(exc))
592603
logger.exception("Job %s failed on %s", job.id, printer_id)
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""PrintQueue muss store.mark_* bei jeder State-Transition aufrufen.
2+
3+
Tests verwenden die echte PrintQueue-Signatur:
4+
PrintQueue(printers=[...], on_state_change=None, store=store)
5+
6+
Der Worker bridget dataclass-Job.id (str) via UUID(job.id) an den Store.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from unittest.mock import AsyncMock, MagicMock
12+
from uuid import UUID, uuid4
13+
14+
import pytest
15+
from PIL import Image
16+
17+
from app.services.job_store import MemoryJobStore
18+
from app.services.print_queue import PrintQueue
19+
20+
# Stabile Printer-UUID für alle Tests in diesem Modul
21+
_PRINTER_ID = UUID("cccccccc-0000-0000-0000-000000000001")
22+
23+
24+
def _make_printer(printer_id: UUID = _PRINTER_ID) -> MagicMock:
25+
"""Erstellt einen schnellen Fake-Printer der print_image sofort beendet."""
26+
printer = MagicMock()
27+
printer.id = printer_id
28+
printer.print_image = AsyncMock(return_value=None)
29+
return printer
30+
31+
32+
def _sample_image() -> Image.Image:
33+
return Image.new("1", (300, 76))
34+
35+
36+
@pytest.mark.asyncio
37+
async def test_printqueue_constructor_accepts_store() -> None:
38+
"""PrintQueue.__init__ muss store=JobStore annehmen und in _store ablegen."""
39+
store = MemoryJobStore()
40+
queue = PrintQueue(
41+
printers=[_make_printer()],
42+
store=store,
43+
)
44+
assert queue._store is store
45+
46+
47+
@pytest.mark.asyncio
48+
async def test_printqueue_calls_mark_printing_then_mark_done() -> None:
49+
"""Worker muss store.mark_printing dann store.mark_done bei Erfolg aufrufen."""
50+
store = AsyncMock(spec=MemoryJobStore)
51+
printer = _make_printer()
52+
queue = PrintQueue(
53+
printers=[printer],
54+
store=store,
55+
)
56+
await queue.start()
57+
try:
58+
job_id = await queue.submit(_PRINTER_ID, _sample_image(), tape_mm=12)
59+
await queue.wait_for_job(job_id, timeout_s=5)
60+
finally:
61+
await queue.stop()
62+
63+
# job.id ist str — Store-Calls bekommen UUID(job_id)
64+
expected_uuid = UUID(job_id)
65+
store.mark_printing.assert_awaited_once_with(expected_uuid)
66+
store.mark_done.assert_awaited_once_with(expected_uuid)
67+
store.mark_failed.assert_not_awaited()
68+
69+
70+
@pytest.mark.asyncio
71+
async def test_printqueue_calls_mark_failed_on_printer_error() -> None:
72+
"""Worker muss store.mark_failed aufrufen wenn printer.print_image wirft."""
73+
from app.printer_backends.exceptions import PrinterError
74+
75+
store = AsyncMock(spec=MemoryJobStore)
76+
printer = _make_printer()
77+
printer.print_image = AsyncMock(side_effect=PrinterError("tape_empty"))
78+
queue = PrintQueue(
79+
printers=[printer],
80+
store=store,
81+
)
82+
await queue.start()
83+
try:
84+
job_id = await queue.submit(_PRINTER_ID, _sample_image(), tape_mm=12)
85+
await queue.wait_for_job(job_id, timeout_s=5)
86+
finally:
87+
await queue.stop()
88+
89+
expected_uuid = UUID(job_id)
90+
store.mark_printing.assert_awaited_once_with(expected_uuid)
91+
store.mark_failed.assert_awaited_once()
92+
# erstes Argument des einzigen Calls muss die richtige UUID sein
93+
actual_uuid = store.mark_failed.call_args.args[0]
94+
assert actual_uuid == expected_uuid
95+
store.mark_done.assert_not_awaited()

0 commit comments

Comments
 (0)