fix(vp): close #1780 race — bump slicer-MQTT wait to 5s + retroactive stamp

Round 2 (166e9f9e) fixed the stash-key mismatch, but @mkoreen's
      2026-06-23 bundle showed BS's MQTT project_file arrived 85 ms past the
      2.0 s wait timeout (FTP done 00:42:02.509, "No slicer options cached"
      00:42:04.509, MQTT 00:42:04.594). Queue item was committed with
      settings defaults; nozzle_mapping never made it onto the wire.

      Three pieces:

      1. _SLICER_OPTIONS_WAIT_TIMEOUT module constant, 2.0 -> 5.0 s. Covers
         wireless / loaded-Pi jitter; one-time +3 s cost only for legacy
         slicers that never send MQTT.

      2. _RECENT_QUEUE_ITEM_TTL fallback: on_print_command retroactively
         UPDATEs slicer-driven fields on a recently-committed queue item
         when the event wait already gave up. Tracked via
         _recent_queue_items dict (30 s TTL, evicted on every queue-add).
         Gated on status='pending' so we never race the dispatcher.
         Multi-plate covered via WHERE id IN (...).

      3. Post-commit last-chance pop. Audit caught a race in (2): MQTT could
         arrive during any await inside _add_to_print_queue (wait_for,
         archive_print, db.flush, db.commit), and on_print_command would
         stash data with no event consumer AND no _recent_queue_items entry
         yet. After populating _recent_queue_items, _add_to_print_queue now
         pops _slicer_print_options[file_path.name] one last time and
         routes any hit through _restamp inline.
This commit is contained in:
maziggy
2026-06-28 12:44:09 +02:00
21 changed files with 2485 additions and 7 deletions
+120
View File
@@ -1431,6 +1431,126 @@ async def bulk_reset_spool_consumed_counter(
return {"reset": len(spools)}
class BulkUpdateRequest(BaseModel):
ids: list[int] = Field(..., min_length=1, max_length=500)
update: SpoolUpdate
class BulkIdsRequest(BaseModel):
ids: list[int] = Field(..., min_length=1, max_length=500)
@router.post("/spools/bulk-update")
async def bulk_update_spools(
payload: BulkUpdateRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Apply the same partial update to every listed spool.
Per-spool errors are collected and returned alongside the success count so
a single bad ID doesn't abort the whole batch. Unknown IDs are reported
in the ``not_found`` list.
"""
update_data = payload.update.model_dump(exclude_unset=True)
fields_set = set(payload.update.model_fields_set)
if not update_data:
raise HTTPException(status_code=400, detail="update must include at least one field")
try:
prepared = await prepare_internal_spool_payload(db, update_data, fields_set)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# Auto-lock weight when the user explicitly sets weight_used — mirrors the
# per-spool PATCH behaviour so bulk edits don't desync the lock state.
if "weight_used" in prepared and "weight_locked" not in prepared:
prepared["weight_locked"] = True
result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
spools = {s.id: s for s in result.scalars().all()}
not_found = [sid for sid in payload.ids if sid not in spools]
updated_ids: list[int] = []
for sid, spool in spools.items():
for field, value in prepared.items():
setattr(spool, field, value)
updated_ids.append(sid)
await db.commit()
if updated_ids:
await ws_manager.broadcast({"type": "inventory_changed"})
return {"updated": len(updated_ids), "not_found": not_found}
@router.post("/spools/bulk-delete")
async def bulk_delete_spools(
payload: BulkIdsRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Hard-delete every listed spool. Unknown IDs are returned in not_found."""
result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
spools = list(result.scalars().all())
found_ids = {s.id for s in spools}
not_found = [sid for sid in payload.ids if sid not in found_ids]
for spool in spools:
await db.delete(spool)
await db.commit()
if spools:
await ws_manager.broadcast({"type": "inventory_changed"})
return {"deleted": len(spools), "not_found": not_found}
@router.post("/spools/bulk-archive")
async def bulk_archive_spools(
payload: BulkIdsRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Soft-archive every listed spool (sets archived_at). Already-archived spools are left alone and counted in already_archived."""
from datetime import datetime, timezone
result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
spools = list(result.scalars().all())
found_ids = {s.id for s in spools}
not_found = [sid for sid in payload.ids if sid not in found_ids]
archived: list[int] = []
already: list[int] = []
now = datetime.now(timezone.utc)
for spool in spools:
if spool.archived_at is not None:
already.append(spool.id)
continue
spool.archived_at = now
archived.append(spool.id)
await db.commit()
if archived:
await ws_manager.broadcast({"type": "inventory_changed"})
return {"archived": len(archived), "already_archived": already, "not_found": not_found}
@router.post("/spools/bulk-restore")
async def bulk_restore_spools(
payload: BulkIdsRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Restore every listed archived spool. Non-archived rows are no-ops counted in already_active."""
result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
spools = list(result.scalars().all())
found_ids = {s.id for s in spools}
not_found = [sid for sid in payload.ids if sid not in found_ids]
restored: list[int] = []
already: list[int] = []
for spool in spools:
if spool.archived_at is None:
already.append(spool.id)
continue
spool.archived_at = None
restored.append(spool.id)
await db.commit()
if restored:
await ws_manager.broadcast({"type": "inventory_changed"})
return {"restored": len(restored), "already_active": already, "not_found": not_found}
# ── K-Profiles ───────────────────────────────────────────────────────────────
@@ -956,6 +956,123 @@ async def reset_spool_consumed_counter(
return mapped
class SpoolmanBulkUpdateRequest(BaseModel):
ids: list[int] = Field(..., min_length=1, max_length=500)
update: SpoolmanInventoryUpdate
class SpoolmanBulkIdsRequest(BaseModel):
ids: list[int] = Field(..., min_length=1, max_length=500)
@router.post("/spools/bulk-update")
async def bulk_update_spools(
payload: SpoolmanBulkUpdateRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Apply the same partial update to every listed Spoolman spool.
Loops the per-spool ``update_spool`` route so the filament re-linking +
extra-dict + location-resolution rules stay in sync with the single-spool
PATCH path. Per-spool errors are collected; one bad ID doesn't abort the
batch.
"""
update_fields = payload.update.model_dump(exclude_unset=True)
if not update_fields:
raise HTTPException(status_code=400, detail="update must include at least one field")
updated = 0
errors: list[dict] = []
for sid in payload.ids:
try:
await update_spool(spool_id=sid, data=payload.update, db=db, _=None)
updated += 1
except HTTPException as exc:
errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
except Exception as exc: # noqa: BLE001 — surface unexpected failures per-row
logger.exception("Spoolman bulk-update failed for spool %s", sid)
errors.append({"id": sid, "status": 500, "detail": str(exc)})
if updated:
await ws_manager.broadcast({"type": "inventory_changed"})
return {"updated": updated, "errors": errors}
@router.post("/spools/bulk-delete")
async def bulk_delete_spools(
payload: SpoolmanBulkIdsRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Hard-delete every listed Spoolman spool. Per-spool failures are collected."""
client = await _get_client(db)
deleted = 0
errors: list[dict] = []
for sid in payload.ids:
try:
async with _translate_spoolman_errors():
await client.delete_spool(sid)
deleted += 1
except HTTPException as exc:
errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
except Exception as exc: # noqa: BLE001 — surface unexpected failures per-row
logger.exception("Spoolman bulk-delete failed for spool %s", sid)
errors.append({"id": sid, "status": 500, "detail": str(exc)})
if deleted:
await ws_manager.broadcast({"type": "inventory_changed"})
return {"deleted": deleted, "errors": errors}
@router.post("/spools/bulk-archive")
async def bulk_archive_spools(
payload: SpoolmanBulkIdsRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Archive every listed Spoolman spool. Per-spool failures are collected."""
client = await _get_client(db)
archived = 0
errors: list[dict] = []
for sid in payload.ids:
try:
async with _translate_spoolman_errors():
await client.set_spool_archived(sid, archived=True)
archived += 1
except HTTPException as exc:
errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
except Exception as exc: # noqa: BLE001 — surface unexpected failures per-row
logger.exception("Spoolman bulk-archive failed for spool %s", sid)
errors.append({"id": sid, "status": 500, "detail": str(exc)})
if archived:
await ws_manager.broadcast({"type": "inventory_changed"})
return {"archived": archived, "errors": errors}
@router.post("/spools/bulk-restore")
async def bulk_restore_spools(
payload: SpoolmanBulkIdsRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
) -> dict:
"""Restore every listed archived Spoolman spool. Per-spool failures are collected."""
client = await _get_client(db)
restored = 0
errors: list[dict] = []
for sid in payload.ids:
try:
async with _translate_spoolman_errors():
await client.set_spool_archived(sid, archived=False)
restored += 1
except HTTPException as exc:
errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
except Exception as exc: # noqa: BLE001 — surface unexpected failures per-row
logger.exception("Spoolman bulk-restore failed for spool %s", sid)
errors.append({"id": sid, "status": 500, "detail": str(exc)})
if restored:
await ws_manager.broadcast({"type": "inventory_changed"})
return {"restored": restored, "errors": errors}
@router.post("/spools/reset-consumed-counter-bulk")
async def bulk_reset_spool_consumed_counter(
payload: dict = Body(...),
+170 -4
View File
@@ -6,6 +6,7 @@ bound to its dedicated IP address, regardless of mode.
import asyncio
import logging
import time
from collections.abc import Callable
from datetime import datetime, timezone
from pathlib import Path
@@ -110,6 +111,23 @@ DEFAULT_VIRTUAL_PRINTER_MODEL = "BL-P001" # X1C
# the dict fills, so a long-running VP can't leak unbounded state.
_SLICER_OPTIONS_CACHE_LIMIT = 128
# How long ``_add_to_print_queue`` waits for the slicer's MQTT
# ``project_file`` after the FTP upload completes (#1780 round 3).
# Bambu Studio sends FTP first, then MQTT immediately after — but on
# wireless / loaded setups the MQTT command can land 2+ s after FTP,
# which used to time the wait out and silently drop ``nozzle_mapping``
# + the other slicer-driven flags. The bumped window covers the
# observed worst case in the field; the late-MQTT fallback in
# ``on_print_command`` covers the rest.
_SLICER_OPTIONS_WAIT_TIMEOUT = 5.0
# How long ``on_print_command`` will retroactively stamp slicer fields
# onto a recently-committed queue item when the MQTT print command
# arrives after ``_SLICER_OPTIONS_WAIT_TIMEOUT`` expired. Covers
# extra-late MQTT (slow wireless slicer, NIC drop+retry) and the
# scheduler tick interval before dispatch picks the item up.
_RECENT_QUEUE_ITEM_TTL = 30.0
def _get_serial_for_model(model: str, serial_suffix: str) -> str:
"""Get serial number for the given model and suffix."""
@@ -200,6 +218,16 @@ class VirtualPrinterInstance:
self._slicer_print_options: dict[str, dict] = {}
self._slicer_print_options_events: dict[str, asyncio.Event] = {}
# Queue items recently committed by `_add_to_print_queue`, keyed by
# FTP filename. Used by `on_print_command` to retroactively stamp the
# slicer's nozzle_mapping (and the other slicer-driven flags) onto a
# queue item when the MQTT `project_file` arrives after the queue-add
# wait timed out — the #1780 round-3 race. Value is
# (queue_item_ids, monotonic_committed_at); entries older than
# `_RECENT_QUEUE_ITEM_TTL` are evicted opportunistically on each
# queue-add.
self._recent_queue_items: dict[str, tuple[list[int], float]] = {}
# Per-instance services
self._proxy: SlicerProxyManager | None = None
self._ftp: VirtualPrinterFTPServer | None = None
@@ -342,6 +370,114 @@ class VirtualPrinterInstance:
event = self._slicer_print_options_events.get(stash_key)
if event:
event.set()
return
# No consumer waiting: `_add_to_print_queue` either already gave up
# (wait_for timed out) or hasn't started yet (FTP still uploading).
# If a queue item was committed within the last
# `_RECENT_QUEUE_ITEM_TTL`, the wait timed out and the row holds
# settings defaults instead of the slicer's pick — retroactively
# stamp the slicer-driven fields so the dispatcher honours the
# user's choice. Covers the #1780 round-3 race where Bambu Studio's
# MQTT lands just past the bumped wait ceiling.
await self._restamp_recent_queue_item(stash_key, data)
async def _restamp_recent_queue_item(self, stash_key: str, data: dict) -> None:
"""Patch slicer-driven fields onto a queue item the MQTT command missed.
``_add_to_print_queue`` waits up to ``_SLICER_OPTIONS_WAIT_TIMEOUT``
for the slicer's MQTT ``project_file`` before committing the queue
item. If the MQTT command arrives after that window — observed in
the field at ~2.1 s on H2C / wireless setups (#1780 round 3) — the
row was already written with settings defaults. This method runs
on the late MQTT path: it looks up the most recent queue items
committed for this filename and patches in the slicer's
``nozzle_mapping`` + workflow flags, but only while the items are
still ``pending`` (scheduler hasn't dispatched them yet).
"""
if not self._session_factory:
return
entry = self._recent_queue_items.get(stash_key)
if entry is None:
return
queue_item_ids, committed_at = entry
if time.monotonic() - committed_at > _RECENT_QUEUE_ITEM_TTL:
self._recent_queue_items.pop(stash_key, None)
return
import json
# Mirror the field set `_add_to_print_queue` reads off slicer_opts.
# MQTT uses `bed_leveling` (single L); the column is `bed_levelling`.
# `nozzles_info` is intentionally not stamped — column kept for
# legacy rows but never written; see PrintQueueItem.nozzles_info.
patch: dict = {}
for mqtt_field, column in (
("bed_leveling", "bed_levelling"),
("flow_cali", "flow_cali"),
("vibration_cali", "vibration_cali"),
("layer_inspect", "layer_inspect"),
("timelapse", "timelapse"),
("use_ams", "use_ams"),
):
if mqtt_field in data:
patch[column] = bool(data[mqtt_field])
raw = data.get("nozzle_mapping")
if raw is not None:
if isinstance(raw, str):
try:
raw = json.loads(raw)
except json.JSONDecodeError:
logger.warning(
"[VP %s] Late MQTT nozzle_mapping is unparseable JSON, dropping: %r",
self.name,
raw,
)
raw = None
if raw is not None:
patch["nozzle_mapping"] = json.dumps(raw)
if not patch:
self._recent_queue_items.pop(stash_key, None)
return
from sqlalchemy import select, update
from backend.app.models.print_queue import PrintQueueItem
try:
async with self._session_factory() as db:
# Only stamp items still pending; once the scheduler has
# picked the row up we can't safely race the dispatcher.
result = await db.execute(
select(PrintQueueItem.id).where(
PrintQueueItem.id.in_(queue_item_ids),
PrintQueueItem.status == "pending",
)
)
eligible_ids = [row[0] for row in result.all()]
if not eligible_ids:
self._recent_queue_items.pop(stash_key, None)
return
await db.execute(update(PrintQueueItem).where(PrintQueueItem.id.in_(eligible_ids)).values(**patch))
await db.commit()
logger.info(
"[VP %s] Late slicer MQTT for %s — retroactively stamped %s onto queue item(s) %s",
self.name,
stash_key,
sorted(patch.keys()),
eligible_ids,
)
except Exception as e:
logger.error(
"[VP %s] Failed to retroactively stamp queue item(s) %s for %s: %s",
self.name,
queue_item_ids,
stash_key,
e,
)
finally:
self._recent_queue_items.pop(stash_key, None)
def _schedule_finish_release(self, filename: str, delay: float = 1.5) -> None:
"""Re-set gcode_state=FINISH on the VP after the project_file ack.
@@ -524,19 +660,21 @@ class VirtualPrinterInstance:
# queue item can inherit the slicer-side print options the user
# picked (timelapse, bed_leveling, etc). Slicers send the FTP upload
# first and the MQTT command immediately after, so the typical lag
# is a few hundred ms; 2 s is conservative without making every
# VP-queue add visibly slow. Falls back to the global default_*
# is a few hundred ms. The window is generous enough to absorb
# wireless / loaded-Pi jitter without making every VP-queue add
# visibly slow — observed worst case in #1780 round 3 was 2.085 s,
# the previous 2.0 s ceiling. Falls back to the global default_*
# settings if MQTT doesn't arrive in time (legacy behaviour for
# users on a slicer that doesn't send a print command). #1403.
# The wait is skipped when there's no MQTT server attached — covers
# unit tests that invoke `_add_to_print_queue` directly without
# going through `on_print_command`, so they don't pay the 2 s tax.
# going through `on_print_command`, so they don't pay the wait tax.
slicer_opts = self._slicer_print_options.pop(file_path.name, None)
if slicer_opts is None and self._mqtt is not None:
event = asyncio.Event()
self._slicer_print_options_events[file_path.name] = event
try:
await asyncio.wait_for(event.wait(), timeout=2.0)
await asyncio.wait_for(event.wait(), timeout=_SLICER_OPTIONS_WAIT_TIMEOUT)
slicer_opts = self._slicer_print_options.pop(file_path.name, None)
except asyncio.TimeoutError:
slicer_opts = None
@@ -749,6 +887,34 @@ class VirtualPrinterInstance:
await db.flush() # populate queue_item.id before logging
queue_item_ids.append(queue_item.id)
await db.commit()
# Track the freshly-committed queue items so
# `on_print_command` can retroactively stamp slicer-side
# fields if the MQTT `project_file` lands AFTER the
# `_SLICER_OPTIONS_WAIT_TIMEOUT` window expired — the
# #1780 round-3 race. Eviction of stale entries here
# keeps the dict bounded; the queue path is the only
# writer, so doing it on commit is enough.
now = time.monotonic()
cutoff = now - _RECENT_QUEUE_ITEM_TTL
self._recent_queue_items = {k: v for k, v in self._recent_queue_items.items() if v[1] > cutoff}
self._recent_queue_items[file_path.name] = (list(queue_item_ids), now)
# Last-chance check: MQTT for this filename could have
# arrived during ANY await between the initial pop and
# now — wait_for itself, archive_print, db.flush,
# db.commit. In all those cases `on_print_command`
# stashed its data but neither the event-signal path nor
# the retroactive `_recent_queue_items` path was in
# place to consume it. Pop any late stash and apply
# inline so the late MQTT never leaks past the queue-add.
late_opts = self._slicer_print_options.pop(file_path.name, None)
if late_opts is not None:
logger.info(
"[VP %s] Late slicer MQTT detected for %s during queue-add — "
"applying inline (race vs commit/archive/flush yield)",
self.name,
file_path.name,
)
await self._restamp_recent_queue_item(file_path.name, late_opts)
if len(queue_item_ids) == 1:
logger.info("[VP %s] Added to queue: %s", self.name, queue_item_ids[0])
else: