fix(queue): claim a queue item before dispatch so it can't be reassigned mid-upload (#2615)

A queue row stays status='pending' for the whole FTP upload; status only flips
to 'printing' at the end. The edit routes only blocked non-pending rows, so a
PATCH during the upload window was accepted while the in-flight dispatch kept
using its snapshotted printer -- splitting the queue row from the archive /
expected-print / physical command across two printers, and enabling a duplicate
dispatch on restart. The #1853 CAS guards cancellation, not reassignment.

Add a dispatching_at claim, stamped atomically (WHERE status='pending' AND
dispatching_at IS NULL) before any slow I/O and cleared on every exit. While
held, the single-item PATCH returns 409 (re-checked just before the write),
bulk edits skip the row, and the scheduler won't re-select it. Startup
reconciliation clears claims orphaned by a crash mid-dispatch. The row stays
pending throughout, so no status/UI/completion/reconciliation path changes.

New column print_queue.dispatching_at (nullable, dialect-safe DDL). Covered by
scheduler tests (claim exclusivity, non-pending rejection, release-on-exit,
skip-already-claimed, startup stale-clear) and API tests (reassign 409,
printer_id unchanged, bulk skip, unclaimed row still edits).
This commit is contained in:
maziggy
2026-07-20 12:30:39 +02:00
parent 4436349a03
commit 64f9d04c80
7 changed files with 315 additions and 5 deletions
@@ -333,6 +333,57 @@ class TestPrintQueueAPI:
assert result["bed_levelling"] is False
assert result["timelapse"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_reassign_rejected_while_dispatching(
self, async_client: AsyncClient, queue_item_factory, printer_factory, db_session
):
"""#2615: a claimed (in-flight) row rejects edits with 409, so its printer
can't be reassigned out from under the running FTP upload."""
from datetime import datetime, timezone
item = await queue_item_factory(dispatching_at=datetime.now(timezone.utc))
other = await printer_factory()
original_printer_id = item.printer_id
response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"printer_id": other.id})
assert response.status_code == 409
await db_session.refresh(item)
assert item.printer_id == original_printer_id, "printer_id must not change on a dispatching row"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bulk_update_skips_dispatching_item(
self, async_client: AsyncClient, queue_item_factory, printer_factory, db_session
):
"""#2615: bulk edits skip a claimed row rather than splitting it."""
from datetime import datetime, timezone
item = await queue_item_factory(dispatching_at=datetime.now(timezone.utc))
other = await printer_factory()
original_printer_id = item.printer_id
response = await async_client.patch("/api/v1/queue/bulk", json={"item_ids": [item.id], "printer_id": other.id})
assert response.status_code == 200
body = response.json()
assert body["skipped_count"] == 1
assert body["updated_count"] == 0
await db_session.refresh(item)
assert item.printer_id == original_printer_id
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_allowed_on_unclaimed_pending_item(
self, async_client: AsyncClient, queue_item_factory, db_session
):
"""Regression guard: a normal pending row (no claim) still edits fine."""
item = await queue_item_factory()
response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"plate_id": 7})
assert response.status_code == 200
assert response.json()["plate_id"] == 7
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):