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
+16
View File
@@ -1521,6 +1521,22 @@ async def run_migrations(conn):
except (OperationalError, ProgrammingError):
pass # Already applied
# Migration: Add dispatching_at claim column to print_queue (#2615). Nullable
# timestamp; the type differs by dialect (SQLite DATETIME vs Postgres
# TIMESTAMP) so an existing-DB upgrade doesn't hit "type datetime does not
# exist" on Postgres. On a fresh DB create_all() already built the column, so
# the ALTER is swallowed as "already exists".
#
# Placed AFTER the print_queue_new2 table-recreate above: that recreate
# (SQLite-only, and only on ancient DBs whose archive_id is still NOT NULL)
# rebuilds print_queue from an explicit column list that doesn't carry this
# column, so adding it earlier would let the recreate silently drop it. Adding
# it here means it survives that path.
if is_sqlite():
await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN dispatching_at DATETIME")
else:
await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN dispatching_at TIMESTAMP")
# Migration: Add HA energy sensor entity columns to smart_plugs
await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN ha_power_entity VARCHAR(100)")
await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN ha_energy_today_entity VARCHAR(100)")