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
+1
View File
@@ -8,6 +8,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Orca Cloud profile sync now connects by approving a code instead of the copy-paste sign-in** — Connecting Bambuddy to Orca Cloud used to mean opening an OAuth sign-in in a new tab, watching it redirect to a `localhost` URL that fails to load, then copying that dead URL out of the address bar and pasting it back into Bambuddy. That dance existed only because Orca's auth backend (Supabase) accepts no redirect target other than `localhost`, and the deliberately-broken redirect page confused nearly everyone who reached it. OrcaSlicer has since shipped a first-class external-app pairing API (the OAuth 2.0 Device Authorization Grant, RFC 8628), so the flow is now: click **Connect**, approve a short code on your Orca Cloud settings page, and Bambuddy pairs itself — no redirect, no paste, no client secret, and it behaves identically from a LAN IP, `localhost`, or behind a reverse proxy. Bambuddy requests **read-only** access (it only lists and views your Orca Cloud profiles), keeps the pairing alive with the API's rotating refresh tokens (validated end-to-end against Orca's staging and production servers), and stores nothing beyond the issued token pair. The profile list and detail views are unchanged, so nothing downstream of the connect step looks different. The old paste-based sign-in and the email/password fallback are removed. Points at production Orca Cloud by default; `ORCA_CLOUD_API_BASE` overrides the endpoint for testing.
### Fixed
- **Reassigning a queue item while it was dispatching split it across two printers (#2615, reporter @Jostxxl)** — Editing a queue item's printer while its FTP upload was already in flight left the queue row pointing at one printer while the archive, expected-print registration, and the physical `project_file` command had gone to another. On a farm this made the reassigned-to printer look broken (marked `printing` but never sent the job), left the row permanently inconsistent, and could trigger a duplicate dispatch after a restart. **Root cause.** A queue row stays `status='pending'` for the entire (multi-minute) FTP upload — status only flips to `printing` at the very end. The edit route only blocked non-`pending` rows, so a `PATCH` during the upload window was accepted; the in-flight dispatch kept using the printer it had snapshotted at the start, while the DB row's `printer_id` changed underneath it. The existing #1853 CAS guards *cancellation* mid-dispatch, not *reassignment*. **Fix.** A `dispatching_at` claim is stamped atomically on the row (`WHERE status='pending' AND dispatching_at IS NULL`) the moment the scheduler begins dispatching, before any slow I/O, and cleared when dispatch ends. While it's held, both edit routes reject changes — the single-item `PATCH` returns **409** (re-checked immediately before the write to close the read-modify-write gap), and bulk edits skip the row — and the scheduler's selection query won't re-pick it. Startup reconciliation clears any claim orphaned by a crash mid-dispatch (no dispatch coroutine survives a restart, so every claim present at boot is stale), so a stale token can never wedge an item out of the queue. The row stays `pending` throughout, so no status-consumer, UI, completion, or reconciliation path had to change. To move a dispatching item, cancel it first (the coordinated escape) and re-queue. New column `print_queue.dispatching_at` (nullable timestamp, dialect-safe DDL — SQLite `DATETIME` / Postgres `TIMESTAMP`). Covered by scheduler tests (claim is exclusive, fails on non-pending rows, releases on every exit, skips an already-claimed row, startup clears stale claims) and API tests (reassign returns 409 with `printer_id` unchanged, bulk skips the claimed row, an unclaimed pending row still edits normally).
- **A single plate printed from a multi-plate 3MF recorded the whole file's filament in statistics (#2614, reporter @Jostxxl)** — Dispatching one selected plate of a sliced multi-plate 3MF through the queue could log the **entire file's** filament against that one plate. The reporter's `heart 3.gcode.3mf` has 22 plates totalling ~12.0 kg; every completed plate recorded `12006.49 g`, so 13 runs inflated lifetime/user/project/filament stats by ~156 kg from one file. **Root cause.** The per-run value written to `PrintLogEntry.filament_used_grams` prefers the AMS-tracked spool delta, but when the tracker measured nothing (no inventory assignment on the printer) a *completed* run fell back to `PrintArchive.filament_used_grams` — which is deliberately the **sum over every plate** of the source 3MF (correct for the archive card and project rollup, #1593). The archive's `plate_id` (persisted by #2603) was never consulted on this path, so the whole-file total was copied verbatim; `cost` had the same defect, falling back to the whole-file `archive.cost`. **Forward fix.** When the archive carries a `plate_id` and its 3MF is on disk, the completed-run fallback now uses that plate's own slicer estimate (`extract_plate_metadata_from_3mf`, the same plate-scoped parse the inventory tracker uses), and scales cost by the plate's share of the whole. The tracker-measured path is unchanged (measured spool deltas still win) and single-plate archives are unaffected (plate value equals the whole-file value). **Backfill.** A startup migration repairs rows already written: for completed print-log entries whose stored grams **exactly equal** the linked archive's whole-file value (the mis-copy signature) and whose archive has a `plate_id` and an on-disk 3MF, it recomputes the plate-scoped grams + cost. The exact-match guard means tracker-measured rows (a rounded spool-delta sum) and partial-progress rows (scaled to progress) are never touched; it's idempotent (a corrected row no longer matches) and data-only, identical on SQLite and Postgres. Logs how many rows and how many grams of over-count it removed. Covered by unit tests for the forward helper (plate scoping, cost scaling, fallbacks when there's no plate_id / no file / unreadable estimate) and the backfill (mis-copy repaired, tracker/partial rows untouched, missing-3MF skipped, single-plate not relabelled, idempotent).
- **Progress notification ran off the left edge of the screen in the installed iPhone PWA (#2612)** — On an iPhone 13 Pro with Bambuddy added to the Home Screen, the print-dispatch progress toast was clipped off the left side of the display — text like "prints", "plate_6", and "MB (21.0%)" bled past the edge. **Root cause.** The toast viewport is anchored `right-20` (80 px from the right, to clear the bug-report bubble) and the dispatch toast has a fixed `w-[420px]`. On a phone that's 390 CSS px wide, 420 + 80 overflows the left edge by ~110 px — the toast simply didn't fit. **Fix.** Every toast now carries a viewport-relative `max-width` (`calc(100vw - 6rem - safe-area insets)`) so it can never exceed the screen; on desktop the 420 px still wins. The viewport's position is also now safe-area-aware (`env(safe-area-inset-*)` on bottom/right) so an installed PWA clears the home indicator and a landscape notch, and the per-job filename row gets `min-w-0`/`shrink-0` so long names truncate instead of pushing the toast wide at the narrower phone width. Frontend-only; no backend, schema, or i18n change. Covered by a test pinning the viewport-relative width cap.
- **Multi-plate queue prints lost the selected plate in Print History and a stopped-while-offline print stayed "printing" (#2603, reporter @Jostxxl)** — Cancelling a print queued from a specific plate of a multi-plate 3MF showed it in Print History as **Plate 1**, so you couldn't tell which plate to requeue. **Root cause.** The archive derives its plate from the *filename*, but a whole multi-plate 3MF uploads under one name with no plate suffix, so the parser defaulted to plate 1 and `extra_data` held all-plates aggregate metadata; the queue row kept the correct plate but nothing copied it onto the archive, which had no plate field at all. **Fix.** `print_archives` gains a nullable `plate_id`, copied from the queue item at dispatch (both the archive-based and library-file paths), exposed in the archive API, and rendered in Print History (falling back to no plate label only when genuinely unknown). A startup backfill copies the plate onto existing archives from their linked queue rows, so already-cancelled prints recover their plate. Additionally, **stopping a printing item while the printer was offline left the linked archive stuck at "printing"** — the queue row was cancelled but, with no printer to send an MQTT completion, nothing ever reconciled the archive. The offline-stop path now closes the archive out directly (status `cancelled`, `failure_reason` "Stopped by user (printer was offline)"); the online path is unchanged and still leaves the archive to the MQTT completion event. Column add + backfill are identical on SQLite and Postgres. Covered by tests for plate persistence, the backfill (including no-clobber/idempotency), and the offline vs online stop reconcile.
+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)
+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)")
+10
View File
@@ -111,6 +111,16 @@ class PrintQueueItem(Base):
# Status: pending, printing, completed, failed, skipped, cancelled
status: Mapped[str] = mapped_column(String(20), default="pending")
# Dispatch claim (#2615). Set atomically by the scheduler the moment it
# begins dispatching this row and cleared when dispatch ends. The row stays
# `status='pending'` throughout the (slow) FTP upload, which left a window
# where a concurrent PATCH could reassign printer_id mid-upload and split the
# queue row from the archive/expected-print/physical command. While this is
# set the edit routes reject changes (409) and the scheduler won't re-select
# the row. Startup reconciliation clears any left over by a crash mid-dispatch
# (no coroutine survives a restart), so a stale claim never wedges an item.
dispatching_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Cleared by the per-printer "Resume after failure" action (#1818) so the
# scheduler's `_check_previous_success` lookback skips this row. Without
# this, a single `failed` or `aborted` print poisoned every later
+77 -4
View File
@@ -286,6 +286,8 @@ class PrintScheduler:
self._running = True
logger.info("Print scheduler started")
await self._clear_stale_dispatch_claims()
while self._running:
dispatched = False
try:
@@ -297,6 +299,25 @@ class PrintScheduler:
# not stall behind the idle interval; otherwise sleep normally (#2555).
await asyncio.sleep(self._fast_check_interval if dispatched else self._check_interval)
async def _clear_stale_dispatch_claims(self) -> None:
"""Clear dispatch claims left behind by a crash/restart mid-upload (#2615).
A claim is only ever held by a live dispatch coroutine, and no coroutine
survives a process restart — so every ``dispatching_at`` present at startup
is stale. Clearing them lets those still-pending rows be re-selected for a
fresh, consistent dispatch instead of being wedged out of the selection
query forever. Called once at the top of ``run()``."""
try:
async with async_session() as db:
res = await db.execute(
update(PrintQueueItem).where(PrintQueueItem.dispatching_at.is_not(None)).values(dispatching_at=None)
)
await db.commit()
if res.rowcount:
logger.info("Cleared %d stale dispatch claim(s) at startup (#2615)", res.rowcount)
except Exception as exc:
logger.error("Failed to clear stale dispatch claims at startup: %s", exc)
def stop(self):
"""Stop the scheduler."""
self._running = False
@@ -320,6 +341,11 @@ class PrintScheduler:
result = await db.execute(
select(PrintQueueItem)
.where(PrintQueueItem.status == "pending")
# Never re-select a row a dispatch worker has already claimed
# (#2615) — belt-and-suspenders with the _inflight exclusion
# below, and the guard that lets an orphaned claim be ignored
# until startup reconciliation clears it.
.where(PrintQueueItem.dispatching_at.is_(None))
# archive/library_file are read by the cross-model gate
# (#2578); eager-load once per pass instead of a lazy-load
# (which would raise in async) per item.
@@ -339,6 +365,8 @@ class PrintScheduler:
result = await db.execute(
select(PrintQueueItem)
.where(PrintQueueItem.status == "pending")
# Skip rows already claimed by a dispatch worker (#2615).
.where(PrintQueueItem.dispatching_at.is_(None))
.options(
selectinload(PrintQueueItem.archive),
selectinload(PrintQueueItem.library_file),
@@ -880,11 +908,56 @@ class PrintScheduler:
transfer's duration.
"""
async with async_session() as item_db:
item = await item_db.get(PrintQueueItem, item_id)
if not item:
logger.info("Queue item %s vanished before dispatch — skipping", item_id)
# Claim the row for dispatch BEFORE reading the printer snapshot or
# touching any slow I/O (#2615). The claim is an atomic CAS on
# (status='pending', dispatching_at IS NULL); while it's held the edit
# routes reject reassignment (409), so printer_id can't change out from
# under the in-flight upload and split the queue row from the
# archive/expected-print/physical command.
if not await self._claim_for_dispatch(item_db, item_id):
logger.info(
"Queue item %s not claimable for dispatch (cancelled, removed, or already claimed) — skipping",
item_id,
)
return
await self._start_print(item_db, item)
try:
item = await item_db.get(PrintQueueItem, item_id)
if not item:
logger.info("Queue item %s vanished after claim — skipping", item_id)
return
await self._start_print(item_db, item)
finally:
# Release the claim on every exit. Once dispatch has finished the
# row's status carries the lock (printing/failed/cancelled are all
# != pending), so the token is only needed for the duration of the
# upload. A row left pending (e.g. busy-printer deferral) becomes
# dispatchable again on the next tick.
await self._clear_dispatch_claim(item_db, item_id)
async def _claim_for_dispatch(self, db: AsyncSession, item_id: int) -> bool:
"""Atomically stamp ``dispatching_at`` on a still-pending, unclaimed row.
Returns True if this call won the claim, False if the row was already
claimed, no longer pending (cancelled mid-tick), or removed. The CAS is
the load-bearing guard against reassign-during-dispatch (#2615)."""
res = await db.execute(
update(PrintQueueItem)
.where(PrintQueueItem.id == item_id)
.where(PrintQueueItem.status == "pending")
.where(PrintQueueItem.dispatching_at.is_(None))
.values(dispatching_at=datetime.now(timezone.utc))
)
await db.commit()
return res.rowcount > 0
async def _clear_dispatch_claim(self, db: AsyncSession, item_id: int) -> None:
"""Clear the dispatch claim (#2615). Best-effort: a failure here must not
mask the dispatch outcome, and startup reconciliation clears any leftover."""
try:
await db.execute(update(PrintQueueItem).where(PrintQueueItem.id == item_id).values(dispatching_at=None))
await db.commit()
except Exception as exc:
logger.warning("Queue item %s: failed to clear dispatch claim: %s", item_id, exc)
async def _find_idle_printer_for_model(
self,
@@ -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):
@@ -0,0 +1,138 @@
"""Reassign-during-dispatch race regression (#2615).
A queue row stays ``status='pending'`` for the whole (slow) FTP upload — status
only flips to ``printing`` at the very end. That left a window where a PATCH
could reassign ``printer_id`` mid-upload while the in-flight dispatch kept using
the old printer, splitting the queue row from the archive / expected-print /
physical command. The fix is a ``dispatching_at`` claim, stamped atomically
before any slow I/O, that the edit routes reject on and the scheduler won't
re-select. These tests cover the claim primitives, the guaranteed release, and
the startup reconciliation that clears a claim orphaned by a crash mid-dispatch.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import backend.app.models # noqa: F401 - populate Base.metadata
import backend.app.services.print_scheduler as scheduler_module
from backend.app.core.database import Base
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.printer import Printer
from backend.app.services.print_scheduler import PrintScheduler
@pytest.fixture
async def ctx():
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
sm = async_sessionmaker(engine, expire_on_commit=False)
async with sm() as db:
printer = Printer(name="P", serial_number="S", ip_address="127.0.0.1", access_code="c", model="X1C")
db.add(printer)
await db.flush()
item = PrintQueueItem(printer_id=printer.id, status="pending")
db.add(item)
await db.commit()
item_id = item.id
try:
yield SimpleNamespace(sm=sm, item_id=item_id, printer_id=printer.id)
finally:
await engine.dispose()
async def _get(ctx, item_id=None):
async with ctx.sm() as db:
return await db.get(PrintQueueItem, item_id or ctx.item_id)
@pytest.mark.asyncio
async def test_claim_stamps_pending_row_and_is_exclusive(ctx):
sched = PrintScheduler()
async with ctx.sm() as db:
assert await sched._claim_for_dispatch(db, ctx.item_id) is True
assert (await _get(ctx)).dispatching_at is not None
# A second claim on an already-claimed row loses.
async with ctx.sm() as db:
assert await sched._claim_for_dispatch(db, ctx.item_id) is False
@pytest.mark.asyncio
async def test_claim_fails_on_non_pending_row(ctx):
sched = PrintScheduler()
async with ctx.sm() as db:
item = await db.get(PrintQueueItem, ctx.item_id)
item.status = "printing"
await db.commit()
async with ctx.sm() as db:
assert await sched._claim_for_dispatch(db, ctx.item_id) is False
assert (await _get(ctx)).dispatching_at is None
@pytest.mark.asyncio
async def test_clear_releases_the_claim(ctx):
sched = PrintScheduler()
async with ctx.sm() as db:
await sched._claim_for_dispatch(db, ctx.item_id)
async with ctx.sm() as db:
await sched._clear_dispatch_claim(db, ctx.item_id)
assert (await _get(ctx)).dispatching_at is None
@pytest.mark.asyncio
async def test_dispatch_one_claims_then_releases_around_start_print(ctx):
sched = PrintScheduler()
seen = {}
async def fake_start_print(db, item):
# Observe the claim is held while dispatch runs.
row = await db.get(PrintQueueItem, item.id)
seen["claimed_during"] = row.dispatching_at is not None
with (
patch.object(scheduler_module, "async_session", ctx.sm),
patch.object(sched, "_start_print", side_effect=fake_start_print) as sp,
):
await sched._dispatch_one(ctx.item_id)
assert seen["claimed_during"] is True, "claim must be held while dispatch runs"
sp.assert_awaited_once()
# Released on exit so a deferred (still-pending) row can re-dispatch.
assert (await _get(ctx)).dispatching_at is None
@pytest.mark.asyncio
async def test_dispatch_one_skips_an_already_claimed_row(ctx):
sched = PrintScheduler()
# Pre-claim the row (as if another worker owns it).
async with ctx.sm() as db:
await sched._claim_for_dispatch(db, ctx.item_id)
with (
patch.object(scheduler_module, "async_session", ctx.sm),
patch.object(sched, "_start_print", new=AsyncMock()) as sp,
):
await sched._dispatch_one(ctx.item_id)
sp.assert_not_called() # claim lost → no dispatch
# And it must NOT clear the other worker's claim.
assert (await _get(ctx)).dispatching_at is not None
@pytest.mark.asyncio
async def test_startup_reconciliation_clears_stale_claims(ctx):
sched = PrintScheduler()
async with ctx.sm() as db:
await sched._claim_for_dispatch(db, ctx.item_id)
assert (await _get(ctx)).dispatching_at is not None
with patch.object(scheduler_module, "async_session", ctx.sm):
await sched._clear_stale_dispatch_claims()
assert (await _get(ctx)).dispatching_at is None, "a claim orphaned by a restart must be cleared"