mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user