Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions backend/app/services/print_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,12 +162,12 @@ async def _prepare_one(
return image, label_data.model_dump()

prepared = await asyncio.gather(*[_prepare_one(r) for r in requests])
images = [img for img, _ in prepared]
label_data_dumps = [dump for _, dump in prepared]

# 3. Pre-allocate job UUIDs + persist in JobStore
# Pro Request 1 Job persistieren (DB-Tracking bleibt 1:1 zur Hangar-Bestellung).
# Image-Replication für copies > 1 kommt in Schritt 4 (Batch-Enqueue).
job_ids: list[UUID] = []
for request, ld_dump in zip(requests, label_data_dumps, strict=True):
for request, (_img, ld_dump) in zip(requests, prepared, strict=True):
job_id = uuid4()
db_job = Job(
id=job_id,
Expand All @@ -186,12 +186,29 @@ async def _prepare_one(
await self._store.save_queued(db_job)
job_ids.append(job_id)

# 3b. Image-Liste für die Hardware aufbauen — Bug 2026-06-14:
# `PrintOptions.copies > 1` ist im DB-Schema vorgesehen, wurde aber
# bisher nicht in die Hardware-Druck-Liste übersetzt. Effekt: ein Request
# mit copies=3 schickte nur 1 Image an print_multi() → 1 Etikett statt 3.
# Fix: pro Request das Image so oft replizieren wie options.copies sagt.
# job_ids werden parallel repliziert damit die Längen-Validierung in
# enqueue_batch passt; duplizierte UUIDs landen einmal im self._jobs-Dict
# (Dict-Set überschreibt). Der Batch-Worker markiert je Job-ID einmal
# done — Hangar-Bestellung bleibt 1:N mit den replizierten Etiketten.
images: list[Image.Image] = []
expanded_job_ids: list[UUID] = []
for req, (img, _ld), jid in zip(requests, prepared, job_ids, strict=True):
n = max(1, int(req.options.copies))
for _ in range(n):
images.append(img)
expanded_job_ids.append(jid)
Comment on lines +200 to +204

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Critical Bug: Duplicate job_ids will be skipped by the queue worker

While this change correctly replicates the images and job IDs to pass the length validation in enqueue_batch, it introduces a critical bug during the actual printing process in PrintQueue._process_batch (defined in backend/app/services/print_queue.py).

In _process_batch, the worker iterates over batch.job_ids and attempts to transition each job to the PRINTING state:

for idx, jid in enumerate(batch.job_ids):
    job = self._jobs.get(str(jid))
    ...
    try:
        JobStateMachine.transition(job, JobState.PRINTING)
        ...
        active_indices.append(idx)
    except InvalidStateTransitionError:
        logger.warning("Batch %s: job %s skipped — state already %s", ...)

Because expanded_job_ids contains duplicate UUIDs for copies, self._jobs.get(str(jid)) retrieves the exact same in-memory Job instance for all copies of a request.

  1. On the first copy (idx = 0), the job transitions from QUEUED to PRINTING successfully, and 0 is added to active_indices.
  2. On subsequent copies (idx > 0), the job is already in the PRINTING state. Calling JobStateMachine.transition(job, JobState.PRINTING) raises an InvalidStateTransitionError.
  3. These subsequent copies are caught by the except block and skipped, meaning their indices are never added to active_indices.

As a result, active_payloads will only contain the first copy's image, and the printer will still only print one single label instead of $N$ copies.

Why did the tests pass?

The unit tests in test_print_service.py mock queue.enqueue_batch and only assert that the arguments passed to it are correct. They do not run the actual queue worker loop (_process_batch), which is why this integration bug went unnoticed.

Recommended Solution

To fix this, we need to update _process_batch in backend/app/services/print_queue.py to allow processing duplicate job IDs without raising state transition errors, and deduplicate them during final state transitions (like COMPLETED or FAILED).

  1. In backend/app/services/print_queue.py (_process_batch):
    Allow duplicate jobs to bypass the transition if they are already PRINTING:

    for idx, jid in enumerate(batch.job_ids):
        job = self._jobs.get(str(jid))
        if job is None:
            continue
        try:
            if job.state != JobState.PRINTING:
                JobStateMachine.transition(job, JobState.PRINTING)
                self._notify_state_change(job, JobState.QUEUED, JobState.PRINTING, ...)
                await self._store.mark_printing(UUID(job.id))
            active_jobs.append(job)
            active_indices.append(idx)
        except InvalidStateTransitionError:
            ...
  2. Deduplicate transitions to terminal states (COMPLETED / FAILED):

    # Success: transition unique active jobs to COMPLETED
    unique_active_jobs = []
    seen = set()
    for job in active_jobs:
        if job.id not in seen:
            unique_active_jobs.append(job)
            seen.add(job.id)
    
    for job in unique_active_jobs:
        # Transition to COMPLETED


# 4. Enqueue as BatchJob
first_options = requests[0].options
await self._queue.enqueue_batch(
printer_id=self._printer_id,
images=images,
job_ids=job_ids,
job_ids=expanded_job_ids,
tape_mm=tape_mm,
options={
"auto_cut": first_options.auto_cut,
Expand Down
68 changes: 68 additions & 0 deletions backend/tests/unit/services/test_print_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,74 @@ async def test_options_forwarded_to_queue(self, make_service) -> None:
assert "copies" not in kwargs


# ---------------------------------------------------------------------------
# Copies-Replikation in submit_batch_job (Bug 2026-06-14)
# ---------------------------------------------------------------------------


class TestSubmitBatchJobCopies:
"""Verifiziert dass PrintOptions.copies > 1 in enqueue_batch zu N Images
repliziert wird. Bug 2026-06-14: zuvor wurde copies in der DB gespeichert
aber nie an print_multi() weitergereicht → User-Report nur 1 Etikett
statt N. Refs Hangar Issue #109."""

@pytest.mark.asyncio
async def test_copies_3_yields_three_images(self, make_service) -> None:
svc, queue, _store, _backend = make_service(loaded_tape_mm=12)
queue.enqueue_batch = AsyncMock()
request = PrintRequest(
content_type=ContentType.QR_TWO_LINES,
data=RawLabelData(
primary_id="SMA-022-003",
title="Samla 22L Box 3",
qr_payload="https://example.com/loc/SMA-022-003",
),
options=PrintOptions(copies=3),
)
await svc.submit_batch_job([request], half_cut=True)
queue.enqueue_batch.assert_awaited_once()
kwargs = queue.enqueue_batch.await_args.kwargs
assert len(kwargs["images"]) == 3, "copies=3 must yield 3 images"
assert len(kwargs["job_ids"]) == 3, "job_ids must match images length"
# Alle drei job_ids zeigen auf den gleichen Hangar-Request-Job
assert len(set(kwargs["job_ids"])) == 1, "same job_id replicated 3x"

@pytest.mark.asyncio
async def test_copies_1_yields_single_image(self, make_service) -> None:
svc, queue, _store, _backend = make_service(loaded_tape_mm=12)
queue.enqueue_batch = AsyncMock()
request = PrintRequest(
content_type=ContentType.QR_ONLY,
data=RawLabelData(qr_payload="https://example.com/x"),
options=PrintOptions(copies=1),
)
await svc.submit_batch_job([request], half_cut=False)
kwargs = queue.enqueue_batch.await_args.kwargs
assert len(kwargs["images"]) == 1
assert len(kwargs["job_ids"]) == 1

@pytest.mark.asyncio
async def test_mixed_copies_per_request(self, make_service) -> None:
"""Mehrere Requests mit unterschiedlichem copies-Wert: total images = Summe."""
svc, queue, _store, _backend = make_service(loaded_tape_mm=12)
queue.enqueue_batch = AsyncMock()
req1 = PrintRequest(
content_type=ContentType.QR_ONLY,
data=RawLabelData(qr_payload="https://example.com/a"),
options=PrintOptions(copies=2),
)
req2 = PrintRequest(
content_type=ContentType.QR_ONLY,
data=RawLabelData(qr_payload="https://example.com/b"),
options=PrintOptions(copies=1),
)
await svc.submit_batch_job([req1, req2], half_cut=False)
kwargs = queue.enqueue_batch.await_args.kwargs
assert len(kwargs["images"]) == 3, "copies=2 + copies=1 = 3 images"
assert len(kwargs["job_ids"]) == 3
assert len(set(kwargs["job_ids"])) == 2, "two distinct request-jobs"


# ---------------------------------------------------------------------------
# NoTapeLoadedError when preflight returns loaded_tape_mm=None
# ---------------------------------------------------------------------------
Expand Down
Loading