Skip to content

Commit ca88c52

Browse files
committed
fix(service): submit_print_job rollback + paused-jobs nicht persistieren
Adressiert C-1 (PAUSED als QUEUED in DB) und I-1 (kein Rollback). C-1: on_tape_mismatch=queue Pfad ruft save_queued nicht mehr auf — PAUSED-Jobs bleiben in-memory-only (Trade-off: Hub-Restart löscht sie, Phase 3 wird PAUSED in JobState enum aufnehmen). I-1: happy-path try/except um queue.submit_with_id ergänzt — bei failure wird DB-Job via mark_failed auf FAILED gesetzt damit keine stale QUEUED-Rows ohne Worker bleiben. Refs #93
1 parent af20365 commit ca88c52

2 files changed

Lines changed: 129 additions & 18 deletions

File tree

backend/app/services/print_service.py

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,16 @@ async def _resolve_label_data(self, request: PrintRequest) -> LabelData:
7272
)
7373

7474
async def submit_print_job(self, request: PrintRequest) -> UUID:
75+
"""Orchestrate template-load → preflight → render → persist → queue.submit.
76+
77+
Phase-2-Limitationen:
78+
- on_tape_mismatch=queue: PAUSED-Jobs bleiben in-memory-only bis resume.
79+
Hub-Restart während PAUSED löscht den Job — Phase-2-Trade-off.
80+
Phase 3 (Issue #95 wenn erstellt) wird PAUSED in JobState enum aufnehmen
81+
+ DB-Migration für persistenten paused-state.
82+
- tape_mismatch Metadaten (error_code, error_message, error_detail) werden
83+
nur in-memory gehalten; keine DB-Row im PAUSED-Pfad.
84+
"""
7585
# 1. Load template — fail fast before any I/O if template is unknown.
7686
template = self._loader.get(request.template_id)
7787

@@ -88,14 +98,17 @@ async def submit_print_job(self, request: PrintRequest) -> UUID:
8898
if request.on_tape_mismatch == "fail":
8999
raise mismatch
90100

91-
# "queue" path: DB-Row anlegen BEVOR an Queue übergeben.
92-
# Phase 2: Persist BEFORE queue.submit_paused_with_id so the job
93-
# is durable even if the queue crashes before the worker picks it up.
101+
# "queue" path: PAUSED-Jobs NICHT in DB persistieren.
102+
# C-1-Fix: save_queued würde den Job als QUEUED in DB ablegen, aber
103+
# PAUSED ist kein gültiger JobState-Wert. Nach Hub-Restart würde
104+
# list_pending() den Job als QUEUED finden und sofort drucken —
105+
# obwohl der User noch den Tape wechseln muss (Doppel-Druck-Risiko).
106+
# Trade-off: Job geht bei Hub-Restart verloren, nichts wurde gedruckt.
94107
# R2-M3: PrintRequest hat KEINE api_key_id/source_ip Felder.
95108
# AuthContext-Integration folgt in einem späteren Task.
96109
label_data = await self._resolve_label_data(request)
97110
image = self._renderer.render(template, label_data)
98-
db_job = Job(
111+
paused_job_id = Job( # noqa: F841 — id wird aus submit_paused_with_id geholt
99112
printer_id=self._printer_id,
100113
template_key=request.template_id,
101114
payload={
@@ -109,24 +122,24 @@ async def submit_print_job(self, request: PrintRequest) -> UUID:
109122
api_key_id=None, # TODO: aus AuthContext wenn Endpoint-Layer angepasst
110123
source_ip=None, # TODO: aus AuthContext wenn Endpoint-Layer angepasst
111124
)
112-
await self._store.save_queued(db_job)
125+
# Keine save_queued() — Job bleibt in-memory-only bis resume.
113126
await self._queue.submit_paused_with_id(
114-
db_job.id,
127+
paused_job_id.id,
115128
self._printer_id,
116129
image,
117130
tape_mm=template.tape_mm,
118131
auto_cut=request.options.auto_cut,
119132
high_resolution=request.options.high_resolution,
120133
)
121134
# Tape-mismatch Metadaten an den in-memory Job anhängen
122-
in_memory_job = await self._queue.get(str(db_job.id))
135+
in_memory_job = await self._queue.get(str(paused_job_id.id))
123136
in_memory_job.error_code = "tape_mismatch"
124137
in_memory_job.error_message = str(mismatch)
125138
in_memory_job.error_detail = {
126139
"expected_mm": template.tape_mm,
127140
"loaded_mm": preflight.loaded_tape_mm,
128141
}
129-
return db_job.id
142+
return paused_job_id.id
130143

131144
# 4. Happy path: resolve label data, render, submit.
132145
# Phase 2: DB-Row anlegen BEVOR an Queue übergeben (Durability-Garantie).
@@ -149,12 +162,23 @@ async def submit_print_job(self, request: PrintRequest) -> UUID:
149162
source_ip=None, # TODO: aus AuthContext wenn Endpoint-Layer angepasst
150163
)
151164
await self._store.save_queued(db_job)
152-
await self._queue.submit_with_id(
153-
db_job.id,
154-
self._printer_id,
155-
image,
156-
tape_mm=template.tape_mm,
157-
auto_cut=request.options.auto_cut,
158-
high_resolution=request.options.high_resolution,
159-
)
165+
try:
166+
await self._queue.submit_with_id(
167+
db_job.id,
168+
self._printer_id,
169+
image,
170+
tape_mm=template.tape_mm,
171+
auto_cut=request.options.auto_cut,
172+
high_resolution=request.options.high_resolution,
173+
)
174+
except Exception as exc:
175+
# I-1-Fix: in-memory Submit fehlgeschlagen nach DB-Persist — Rollback.
176+
# Ohne diesen Rollback bliebe eine stale QUEUED-Row in der DB ohne
177+
# Worker-Gegenstück, die nach Hub-Restart fälschlicherweise re-enqueued
178+
# würde. mark_failed markiert die Row als FAILED und verhindert das.
179+
await self._store.mark_failed(
180+
db_job.id,
181+
f"submit_failed: {exc.__class__.__name__}: {exc}",
182+
)
183+
raise
160184
return db_job.id

backend/tests/unit/services/test_print_service.py

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,15 +95,18 @@ def backend():
9595
_PRINTER_ID = UUID("bbbbbbbb-0000-0000-0000-000000000001")
9696

9797

98-
def _service(loader, renderer, queue, lookup_service, backend):
99-
return PrintService(
98+
def _service(loader, renderer, queue, lookup_service, backend, store=None):
99+
kwargs = dict(
100100
template_loader=loader,
101101
renderer=renderer,
102102
print_queue=queue,
103103
lookup_service=lookup_service,
104104
printer_id=_PRINTER_ID,
105105
backend=backend,
106106
)
107+
if store is not None:
108+
kwargs["store"] = store
109+
return PrintService(**kwargs)
107110

108111

109112
# ---------------------------------------------------------------------------
@@ -470,3 +473,87 @@ async def print_image(self, image, *, tape_mm, **kw):
470473
assert job.state == JobState.PAUSED, f"Expected PAUSED, got {job.state}"
471474
assert job.error_code == "tape_mismatch"
472475
assert job.error_detail == {"expected_mm": 24, "loaded_mm": 12}
476+
477+
478+
# ---------------------------------------------------------------------------
479+
# Fix C-1: PAUSED-Pfad ruft save_queued NICHT auf
480+
# ---------------------------------------------------------------------------
481+
482+
483+
async def test_tape_mismatch_queue_path_does_not_persist_db_job(
484+
loader, renderer, queue, lookup_service, backend
485+
) -> None:
486+
"""C-1-Fix: on_tape_mismatch=queue → save_queued wird NICHT aufgerufen.
487+
488+
Vorher wurde der Job als QUEUED persistiert, obwohl er in PAUSED-State
489+
versetzt wurde. Nach Hub-Restart würde list_pending() ihn als QUEUED
490+
finden und sofort drucken — Doppel-Druck-Risiko.
491+
Fix: PAUSED-Jobs bleiben in-memory-only, kein DB-Persist.
492+
"""
493+
backend.preflight_check.return_value = PreflightStatus(
494+
hr_printer_status="idle",
495+
loaded_tape_mm=12, # mismatch: template wants 24mm
496+
error_flags=[],
497+
)
498+
job = Job(id="job-1", printer_id=_PRINTER_ID, image_payload=b"", tape_mm=24, options={})
499+
from app.services.job_lifecycle import JobStateMachine
500+
501+
JobStateMachine.transition(job, JobState.PAUSED)
502+
queue.submit_paused_with_id.return_value = _FAKE_JOB_UUID
503+
queue.get.return_value = job
504+
505+
mock_store = AsyncMock()
506+
507+
svc = _service(loader, renderer, queue, lookup_service, backend, store=mock_store)
508+
req = PrintRequest(
509+
template_id="qr-only-24mm",
510+
data=RawLabelData(title="T", primary_id="P", qr_payload="Q"),
511+
on_tape_mismatch="queue",
512+
)
513+
514+
job_id = await svc.submit_print_job(req)
515+
516+
assert isinstance(job_id, UUID)
517+
# save_queued darf NICHT aufgerufen worden sein — kein DB-Persist für PAUSED
518+
mock_store.save_queued.assert_not_awaited()
519+
# submit_paused_with_id muss aber aufgerufen worden sein
520+
queue.submit_paused_with_id.assert_awaited_once()
521+
522+
523+
# ---------------------------------------------------------------------------
524+
# Fix I-1: Rollback bei queue.submit_with_id Fehler
525+
# ---------------------------------------------------------------------------
526+
527+
528+
async def test_submit_with_failing_queue_marks_db_job_failed(
529+
loader, renderer, queue, lookup_service, backend
530+
) -> None:
531+
"""I-1-Fix: Wenn queue.submit_with_id wirft, muss der DB-Job auf FAILED gesetzt werden.
532+
533+
Ohne Rollback bliebe eine stale QUEUED-Row in der DB ohne Worker-Gegenstück.
534+
Nach Hub-Restart würde list_pending() sie finden und re-enqueuen — aber der
535+
Job hat keinen gültigen Zustand mehr.
536+
Fix: try/except um submit_with_id, bei Exception → mark_failed + re-raise.
537+
"""
538+
submit_error = RuntimeError("asyncio.Queue voll oder andere Fehlerursache")
539+
queue.submit_with_id.side_effect = submit_error
540+
541+
mock_store = AsyncMock()
542+
543+
svc = _service(loader, renderer, queue, lookup_service, backend, store=mock_store)
544+
req = PrintRequest(
545+
template_id="qr-only-24mm",
546+
data=RawLabelData(title="T", primary_id="P", qr_payload="Q"),
547+
)
548+
549+
with pytest.raises(RuntimeError):
550+
await svc.submit_print_job(req)
551+
552+
# save_queued muss aufgerufen worden sein (DB-Persist vor submit)
553+
mock_store.save_queued.assert_awaited_once()
554+
# mark_failed muss aufgerufen worden sein mit passendem error-String
555+
mock_store.mark_failed.assert_awaited_once()
556+
call_args = mock_store.mark_failed.call_args
557+
error_msg: str = call_args.args[1] if call_args.args else call_args.kwargs["error"]
558+
assert "submit_failed" in error_msg
559+
assert "RuntimeError" in error_msg

0 commit comments

Comments
 (0)