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
+22 -1
View File
@@ -774,7 +774,10 @@ async def bulk_update_queue_items(
skipped_count = 0
for item in items:
if item.status != "pending":
# Skip non-pending rows and rows a dispatch worker has claimed (#2615) —
# editing a claimed row mid-upload would split it from the in-flight
# dispatch, so it's excluded from the bulk change (cancel to move it).
if item.status != "pending" or item.dispatching_at is not None:
skipped_count += 1
continue
@@ -1082,6 +1085,14 @@ async def update_queue_item(
if item.status != "pending":
raise HTTPException(400, "Can only update pending items")
# Dispatch claim (#2615): the row is pending but a scheduler worker has
# already claimed it and is uploading to its printer. Editing now (e.g.
# reassigning printer_id) would split the queue row from the in-flight
# archive/expected-print/physical command. Reject until dispatch finishes;
# to move it, cancel first (the coordinated escape) and re-queue.
if item.dispatching_at is not None:
raise HTTPException(409, "Item is being dispatched — cancel it first to make changes")
update_data = data.model_dump(exclude_unset=True)
# Normalize target_model if being updated
@@ -1153,6 +1164,16 @@ async def update_queue_item(
json.dumps(update_data["nozzle_mapping"]) if update_data["nozzle_mapping"] else None
)
# Re-check the dispatch claim right before mutating (#2615). Several awaited
# validations ran since the guard above, and a scheduler worker may have
# claimed the row in that gap. A fresh read (item isn't dirty yet, so no
# autoflush races the check) narrows the window to effectively nothing.
claimed = (
await db.execute(select(PrintQueueItem.dispatching_at).where(PrintQueueItem.id == item_id))
).scalar_one_or_none()
if claimed is not None:
raise HTTPException(409, "Item is being dispatched — cancel it first to make changes")
for field, value in update_data.items():
setattr(item, field, value)