mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 15:35:39 +02:00
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:
@@ -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(...),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Bulk inventory endpoint coverage for the batch-edit feature (#1795).
|
||||
|
||||
Endpoints under test:
|
||||
- POST /api/v1/inventory/spools/bulk-update
|
||||
- POST /api/v1/inventory/spools/bulk-delete
|
||||
- POST /api/v1/inventory/spools/bulk-archive
|
||||
- POST /api/v1/inventory/spools/bulk-restore
|
||||
|
||||
The Spoolman-mode equivalents live in test_spoolman_inventory_api.py.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.spool import Spool
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def spool_factory(db_session: AsyncSession):
|
||||
async def _create(**kwargs):
|
||||
defaults = {
|
||||
"material": "PLA",
|
||||
"subtype": "Basic",
|
||||
"brand": "Bambu",
|
||||
"color_name": "Red",
|
||||
"rgba": "FF0000FF",
|
||||
"label_weight": 1000,
|
||||
"core_weight": 250,
|
||||
"weight_used": 0,
|
||||
"weight_used_baseline": 0,
|
||||
"weight_locked": False,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
spool = Spool(**defaults)
|
||||
db_session.add(spool)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(spool)
|
||||
return spool
|
||||
|
||||
return _create
|
||||
|
||||
|
||||
class TestBulkUpdate:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_applies_patch_to_all_listed_spools(self, async_client: AsyncClient, spool_factory, db_session):
|
||||
a = await spool_factory(brand="Bambu", note=None)
|
||||
b = await spool_factory(brand="Bambu", note=None)
|
||||
c = await spool_factory(brand="Bambu", note=None)
|
||||
|
||||
resp = await async_client.post(
|
||||
"/api/v1/inventory/spools/bulk-update",
|
||||
json={"ids": [a.id, b.id, c.id], "update": {"brand": "Sunlu", "note": "From bulk edit"}},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["updated"] == 3
|
||||
assert body["not_found"] == []
|
||||
|
||||
for spool in (a, b, c):
|
||||
await db_session.refresh(spool)
|
||||
assert spool.brand == "Sunlu"
|
||||
assert spool.note == "From bulk edit"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_reports_unknown_ids_in_not_found(self, async_client: AsyncClient, spool_factory, db_session):
|
||||
real = await spool_factory(brand="Bambu")
|
||||
resp = await async_client.post(
|
||||
"/api/v1/inventory/spools/bulk-update",
|
||||
json={"ids": [real.id, 999_999], "update": {"brand": "Sunlu"}},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["updated"] == 1
|
||||
assert body["not_found"] == [999_999]
|
||||
|
||||
await db_session.refresh(real)
|
||||
assert real.brand == "Sunlu"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_empty_update_rejected(self, async_client: AsyncClient, spool_factory):
|
||||
a = await spool_factory()
|
||||
resp = await async_client.post(
|
||||
"/api/v1/inventory/spools/bulk-update",
|
||||
json={"ids": [a.id], "update": {}},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_setting_weight_used_auto_locks(self, async_client: AsyncClient, spool_factory, db_session):
|
||||
a = await spool_factory(weight_locked=False, weight_used=0.0)
|
||||
resp = await async_client.post(
|
||||
"/api/v1/inventory/spools/bulk-update",
|
||||
json={"ids": [a.id], "update": {"weight_used": 250.5}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
await db_session.refresh(a)
|
||||
assert a.weight_used == 250.5
|
||||
assert a.weight_locked is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_empty_ids_list_rejected(self, async_client: AsyncClient):
|
||||
resp = await async_client.post(
|
||||
"/api/v1/inventory/spools/bulk-update",
|
||||
json={"ids": [], "update": {"brand": "X"}},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
class TestBulkDelete:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_deletes_listed_spools(self, async_client: AsyncClient, spool_factory, db_session):
|
||||
a = await spool_factory()
|
||||
b = await spool_factory()
|
||||
kept = await spool_factory()
|
||||
|
||||
resp = await async_client.post(
|
||||
"/api/v1/inventory/spools/bulk-delete",
|
||||
json={"ids": [a.id, b.id]},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["deleted"] == 2
|
||||
assert body["not_found"] == []
|
||||
|
||||
remaining = (await db_session.execute(select(Spool.id))).scalars().all()
|
||||
assert kept.id in remaining
|
||||
assert a.id not in remaining
|
||||
assert b.id not in remaining
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_reports_unknown_ids(self, async_client: AsyncClient, spool_factory):
|
||||
a = await spool_factory()
|
||||
resp = await async_client.post(
|
||||
"/api/v1/inventory/spools/bulk-delete",
|
||||
json={"ids": [a.id, 999_999]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["deleted"] == 1
|
||||
assert body["not_found"] == [999_999]
|
||||
|
||||
|
||||
class TestBulkArchiveRestore:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_bulk_archive_sets_archived_at(self, async_client: AsyncClient, spool_factory, db_session):
|
||||
a = await spool_factory()
|
||||
b = await spool_factory()
|
||||
|
||||
resp = await async_client.post(
|
||||
"/api/v1/inventory/spools/bulk-archive",
|
||||
json={"ids": [a.id, b.id]},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["archived"] == 2
|
||||
assert body["already_archived"] == []
|
||||
assert body["not_found"] == []
|
||||
|
||||
for s in (a, b):
|
||||
await db_session.refresh(s)
|
||||
assert s.archived_at is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_bulk_archive_skips_already_archived(self, async_client: AsyncClient, spool_factory, db_session):
|
||||
active = await spool_factory()
|
||||
already = await spool_factory(archived_at=datetime.now(timezone.utc))
|
||||
|
||||
resp = await async_client.post(
|
||||
"/api/v1/inventory/spools/bulk-archive",
|
||||
json={"ids": [active.id, already.id]},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["archived"] == 1
|
||||
assert body["already_archived"] == [already.id]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_bulk_restore_clears_archived_at(self, async_client: AsyncClient, spool_factory, db_session):
|
||||
archived = await spool_factory(archived_at=datetime.now(timezone.utc))
|
||||
active = await spool_factory(archived_at=None)
|
||||
|
||||
resp = await async_client.post(
|
||||
"/api/v1/inventory/spools/bulk-restore",
|
||||
json={"ids": [archived.id, active.id]},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["restored"] == 1
|
||||
assert body["already_active"] == [active.id]
|
||||
|
||||
await db_session.refresh(archived)
|
||||
assert archived.archived_at is None
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Bulk Spoolman inventory endpoint coverage for the batch-edit feature (#1795).
|
||||
|
||||
Endpoints under test:
|
||||
- POST /api/v1/spoolman/inventory/spools/bulk-update
|
||||
- POST /api/v1/spoolman/inventory/spools/bulk-delete
|
||||
- POST /api/v1/spoolman/inventory/spools/bulk-archive
|
||||
- POST /api/v1/spoolman/inventory/spools/bulk-restore
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from httpx import AsyncClient
|
||||
|
||||
SAMPLE_SPOOLMAN_SPOOL = {
|
||||
"id": 42,
|
||||
"filament": {
|
||||
"id": 7,
|
||||
"name": "PLA Basic",
|
||||
"material": "PLA",
|
||||
"color_hex": "FF0000",
|
||||
"weight": 1000,
|
||||
"vendor": {"id": 3, "name": "Bambu Lab"},
|
||||
},
|
||||
"remaining_weight": 750.0,
|
||||
"used_weight": 250.0,
|
||||
"location": "Printer1 - AMS A1",
|
||||
"comment": "test note",
|
||||
"first_used": "2024-01-01T00:00:00+00:00",
|
||||
"last_used": "2024-02-01T00:00:00+00:00",
|
||||
"registered": "2024-01-01T00:00:00+00:00",
|
||||
"archived": False,
|
||||
"price": None,
|
||||
"extra": {},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def spoolman_settings(db_session):
|
||||
from backend.app.models.settings import Settings
|
||||
|
||||
db_session.add(Settings(key="spoolman_enabled", value="true"))
|
||||
db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_spoolman_client():
|
||||
mock = MagicMock()
|
||||
mock.base_url = "http://localhost:7912"
|
||||
mock.health_check = AsyncMock(return_value=True)
|
||||
mock.get_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
|
||||
mock.delete_spool = AsyncMock(return_value=True)
|
||||
mock.set_spool_archived = AsyncMock(
|
||||
side_effect=lambda spool_id, archived: {**SAMPLE_SPOOLMAN_SPOOL, "archived": archived}
|
||||
)
|
||||
mock.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
|
||||
mock.merge_spool_extra = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
|
||||
mock.is_filament_shared = AsyncMock(return_value=False)
|
||||
mock.patch_filament = AsyncMock(return_value={"id": 7})
|
||||
mock.find_or_create_filament = AsyncMock(return_value=7)
|
||||
mock.find_or_create_vendor = AsyncMock(return_value=3)
|
||||
mock.ensure_extra_field = AsyncMock(return_value=True)
|
||||
mock.get_distinct_locations = AsyncMock(return_value=[])
|
||||
|
||||
class _Lock:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
return False
|
||||
|
||||
mock.extra_lock = lambda spool_id: _Lock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.api.routes.spoolman_inventory.get_spoolman_client",
|
||||
AsyncMock(return_value=mock),
|
||||
),
|
||||
patch(
|
||||
"backend.app.api.routes.spoolman_inventory.init_spoolman_client",
|
||||
AsyncMock(return_value=mock),
|
||||
),
|
||||
):
|
||||
yield mock
|
||||
|
||||
|
||||
class TestSpoolmanBulkUpdate:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_calls_per_spool_update_for_each_id(
|
||||
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
|
||||
):
|
||||
resp = await async_client.post(
|
||||
"/api/v1/spoolman/inventory/spools/bulk-update",
|
||||
json={"ids": [42, 43, 44], "update": {"note": "From bulk edit"}},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["updated"] == 3
|
||||
assert body["errors"] == []
|
||||
# update_spool route loops through each, which calls update_spool_full once per ID
|
||||
assert mock_spoolman_client.update_spool_full.await_count == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_collects_per_spool_errors_without_aborting_batch(
|
||||
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
|
||||
):
|
||||
# First two succeed, third raises
|
||||
mock_spoolman_client.update_spool_full.side_effect = [
|
||||
SAMPLE_SPOOLMAN_SPOOL,
|
||||
SAMPLE_SPOOLMAN_SPOOL,
|
||||
HTTPException(status_code=404, detail="Spool 999 not found"),
|
||||
]
|
||||
|
||||
resp = await async_client.post(
|
||||
"/api/v1/spoolman/inventory/spools/bulk-update",
|
||||
json={"ids": [42, 43, 999], "update": {"note": "Batched"}},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["updated"] == 2
|
||||
assert len(body["errors"]) == 1
|
||||
assert body["errors"][0]["id"] == 999
|
||||
assert body["errors"][0]["status"] == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_empty_update_rejected(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
|
||||
resp = await async_client.post(
|
||||
"/api/v1/spoolman/inventory/spools/bulk-update",
|
||||
json={"ids": [42], "update": {}},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_empty_ids_rejected(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
|
||||
resp = await async_client.post(
|
||||
"/api/v1/spoolman/inventory/spools/bulk-update",
|
||||
json={"ids": [], "update": {"note": "X"}},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
class TestSpoolmanBulkDelete:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_deletes_listed_spools(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
|
||||
resp = await async_client.post(
|
||||
"/api/v1/spoolman/inventory/spools/bulk-delete",
|
||||
json={"ids": [42, 43, 44]},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["deleted"] == 3
|
||||
assert body["errors"] == []
|
||||
assert mock_spoolman_client.delete_spool.await_count == 3
|
||||
|
||||
|
||||
class TestSpoolmanBulkArchiveRestore:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_bulk_archive_calls_per_spool(
|
||||
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
|
||||
):
|
||||
resp = await async_client.post(
|
||||
"/api/v1/spoolman/inventory/spools/bulk-archive",
|
||||
json={"ids": [42, 43]},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["archived"] == 2
|
||||
# set_spool_archived(spool_id, archived=True) called for each id
|
||||
assert mock_spoolman_client.set_spool_archived.await_count == 2
|
||||
for call in mock_spoolman_client.set_spool_archived.call_args_list:
|
||||
assert call.kwargs.get("archived") is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_bulk_restore_calls_per_spool(
|
||||
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
|
||||
):
|
||||
resp = await async_client.post(
|
||||
"/api/v1/spoolman/inventory/spools/bulk-restore",
|
||||
json={"ids": [42, 43]},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["restored"] == 2
|
||||
assert mock_spoolman_client.set_spool_archived.await_count == 2
|
||||
for call in mock_spoolman_client.set_spool_archived.call_args_list:
|
||||
assert call.kwargs.get("archived") is False
|
||||
@@ -1785,6 +1785,272 @@ class TestVirtualPrinterInstance:
|
||||
for item in added_items:
|
||||
assert _json.loads(item.nozzle_mapping) == [16, 0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_print_command_late_mqtt_retroactively_stamps_queue_item(self, tmp_path):
|
||||
"""#1780 round 3: Bambu Studio's MQTT project_file can arrive AFTER
|
||||
`_add_to_print_queue` already gave up waiting (observed at 2.085 s
|
||||
on H2C wireless setups). The queue item was committed with settings
|
||||
defaults; the slicer's nozzle_mapping + workflow flags must be
|
||||
patched onto it when MQTT lands, otherwise the H2C firmware falls
|
||||
back to auto-pick.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
|
||||
|
||||
added_items: list = []
|
||||
mock_db = AsyncMock()
|
||||
mock_db.add = MagicMock(
|
||||
side_effect=lambda item: (added_items.append(item), setattr(item, "id", 100 + len(added_items)))[0]
|
||||
)
|
||||
|
||||
async def _flush():
|
||||
# added_items[-1].id was set by `add`; nothing else to do.
|
||||
return None
|
||||
|
||||
mock_db.flush = AsyncMock(side_effect=_flush)
|
||||
mock_db.commit = AsyncMock()
|
||||
|
||||
# First execute() call (the position-max SELECT inside _add_to_print_queue)
|
||||
# returns None; second (the eligible-pending SELECT in
|
||||
# _restamp_recent_queue_item) returns the committed queue id; third
|
||||
# (the UPDATE) is fire-and-forget.
|
||||
position_max_result = MagicMock()
|
||||
position_max_result.scalar = MagicMock(return_value=None)
|
||||
select_pending_result = MagicMock()
|
||||
select_pending_result.all = MagicMock(return_value=[(101,)])
|
||||
update_result = MagicMock()
|
||||
mock_db.execute = AsyncMock(side_effect=[position_max_result, select_pending_result, update_result])
|
||||
|
||||
mock_session_factory = MagicMock()
|
||||
mock_session_ctx = AsyncMock()
|
||||
mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
|
||||
mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_session_factory.return_value = mock_session_ctx
|
||||
|
||||
inst = VirtualPrinterInstance(
|
||||
vp_id=99,
|
||||
name="LateMQTT",
|
||||
mode="queue",
|
||||
model="O1C2",
|
||||
access_code="12345678",
|
||||
serial_suffix="391800099",
|
||||
base_dir=tmp_path,
|
||||
session_factory=mock_session_factory,
|
||||
)
|
||||
# MQTT server presence enables the wait_for path; we don't actually
|
||||
# use any methods on it.
|
||||
inst._mqtt = MagicMock()
|
||||
|
||||
file_path = tmp_path / "test.3mf"
|
||||
file_path.write_bytes(b"fake3mf")
|
||||
|
||||
mock_archive = MagicMock()
|
||||
mock_archive.id = 1
|
||||
mock_archive.print_name = "test"
|
||||
|
||||
# 1. _add_to_print_queue runs WITHOUT a prior on_print_command —
|
||||
# the wait_for times out (settings-default fallback) and the
|
||||
# queue item is committed.
|
||||
with (
|
||||
patch(
|
||||
"backend.app.api.routes.settings.get_setting",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"backend.app.services.archive.ArchiveService.archive_print",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_archive,
|
||||
),
|
||||
# Shorten the wait so the test isn't slow.
|
||||
patch(
|
||||
"backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT",
|
||||
0.05,
|
||||
),
|
||||
):
|
||||
await inst._add_to_print_queue(file_path, "192.168.1.100")
|
||||
|
||||
assert len(added_items) == 1
|
||||
assert added_items[0].nozzle_mapping is None # MQTT was never received
|
||||
assert file_path.name in inst._recent_queue_items
|
||||
|
||||
# 2. MQTT project_file arrives AFTER the wait expired — must
|
||||
# retroactively patch the queue item.
|
||||
await inst.on_print_command(
|
||||
file_path.name,
|
||||
{
|
||||
"command": "project_file",
|
||||
"file": file_path.name,
|
||||
"nozzle_mapping": [16, -1, -1, 1],
|
||||
"timelapse": True,
|
||||
"bed_leveling": False,
|
||||
},
|
||||
)
|
||||
|
||||
# The UPDATE call is the third execute. Inspect its values.
|
||||
update_call = mock_db.execute.await_args_list[2]
|
||||
update_stmt = update_call.args[0]
|
||||
compiled = update_stmt.compile(compile_kwargs={"literal_binds": False})
|
||||
params = dict(compiled.params)
|
||||
assert _json.loads(params["nozzle_mapping"]) == [16, -1, -1, 1]
|
||||
assert params["timelapse"] is True
|
||||
assert params["bed_levelling"] is False # MQTT bed_leveling → column bed_levelling
|
||||
# Recent-queue tracking dict is cleared after the patch.
|
||||
assert file_path.name not in inst._recent_queue_items
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_to_print_queue_catches_mqtt_stashed_post_wait_timeout(self, tmp_path):
|
||||
"""The actual race-window scenario: wait_for times out, then MQTT
|
||||
arrives and stashes options AFTER the wait but BEFORE the
|
||||
post-commit re-check. The post-commit pop must catch it.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
|
||||
|
||||
added_items: list = []
|
||||
mock_db = AsyncMock()
|
||||
mock_db.add = MagicMock(
|
||||
side_effect=lambda item: (added_items.append(item), setattr(item, "id", 300 + len(added_items)))[0]
|
||||
)
|
||||
mock_db.flush = AsyncMock()
|
||||
mock_db.commit = AsyncMock()
|
||||
|
||||
position_max_result = MagicMock()
|
||||
position_max_result.scalar = MagicMock(return_value=None)
|
||||
select_pending_result = MagicMock()
|
||||
select_pending_result.all = MagicMock(return_value=[(301,)])
|
||||
update_result = MagicMock()
|
||||
mock_db.execute = AsyncMock(side_effect=[position_max_result, select_pending_result, update_result])
|
||||
|
||||
mock_session_factory = MagicMock()
|
||||
mock_session_ctx = AsyncMock()
|
||||
mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
|
||||
mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_session_factory.return_value = mock_session_ctx
|
||||
|
||||
inst = VirtualPrinterInstance(
|
||||
vp_id=96,
|
||||
name="RaceCommitYield",
|
||||
mode="queue",
|
||||
model="O1C2",
|
||||
access_code="12345678",
|
||||
serial_suffix="391800096",
|
||||
base_dir=tmp_path,
|
||||
session_factory=mock_session_factory,
|
||||
)
|
||||
inst._mqtt = MagicMock()
|
||||
|
||||
file_path = tmp_path / "test.3mf"
|
||||
file_path.write_bytes(b"fake3mf")
|
||||
|
||||
# Stash MQTT data on the FIRST commit (simulating MQTT arrival
|
||||
# during _add_to_print_queue's commit yield); _restamp also calls
|
||||
# db.commit later, so we one-shot the side effect.
|
||||
commit_calls = {"n": 0}
|
||||
|
||||
async def _delayed_stash(*_args, **_kwargs):
|
||||
commit_calls["n"] += 1
|
||||
if commit_calls["n"] == 1:
|
||||
inst._slicer_print_options[file_path.name] = {
|
||||
"command": "project_file",
|
||||
"file": file_path.name,
|
||||
"nozzle_mapping": [0, 16, -1, -1],
|
||||
"timelapse": False,
|
||||
}
|
||||
return None
|
||||
|
||||
mock_db.commit = AsyncMock(side_effect=_delayed_stash)
|
||||
|
||||
mock_archive = MagicMock()
|
||||
mock_archive.id = 1
|
||||
mock_archive.print_name = "test"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.api.routes.settings.get_setting",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"backend.app.services.archive.ArchiveService.archive_print",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_archive,
|
||||
),
|
||||
patch(
|
||||
"backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT",
|
||||
0.05,
|
||||
),
|
||||
):
|
||||
await inst._add_to_print_queue(file_path, "192.168.1.100")
|
||||
|
||||
# Queue item INSERTed with defaults (wait timed out, no slicer_opts).
|
||||
assert len(added_items) == 1
|
||||
# But the post-commit pop caught the late stash and applied the
|
||||
# slicer nozzle_mapping via _restamp's UPDATE.
|
||||
update_call = mock_db.execute.await_args_list[2]
|
||||
update_stmt = update_call.args[0]
|
||||
compiled = update_stmt.compile(compile_kwargs={"literal_binds": False})
|
||||
params = dict(compiled.params)
|
||||
assert _json.loads(params["nozzle_mapping"]) == [0, 16, -1, -1]
|
||||
assert params["timelapse"] is False
|
||||
# _recent_queue_items entry was consumed by the post-commit
|
||||
# _restamp call.
|
||||
assert file_path.name not in inst._recent_queue_items
|
||||
# And the stash is empty.
|
||||
assert file_path.name not in inst._slicer_print_options
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_print_command_late_mqtt_skips_already_dispatched_item(self, tmp_path):
|
||||
"""Once the scheduler has picked the queue item up (status != pending),
|
||||
the retroactive patch is a no-op — racing the dispatcher would be
|
||||
unsafe.
|
||||
"""
|
||||
from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
|
||||
|
||||
mock_db = AsyncMock()
|
||||
# The eligible-pending SELECT returns nothing — item is no longer pending.
|
||||
empty_result = MagicMock()
|
||||
empty_result.all = MagicMock(return_value=[])
|
||||
mock_db.execute = AsyncMock(return_value=empty_result)
|
||||
mock_db.commit = AsyncMock()
|
||||
|
||||
mock_session_factory = MagicMock()
|
||||
mock_session_ctx = AsyncMock()
|
||||
mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
|
||||
mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_session_factory.return_value = mock_session_ctx
|
||||
|
||||
inst = VirtualPrinterInstance(
|
||||
vp_id=98,
|
||||
name="LateMQTTDispatched",
|
||||
mode="queue",
|
||||
model="O1C2",
|
||||
access_code="12345678",
|
||||
serial_suffix="391800098",
|
||||
base_dir=tmp_path,
|
||||
session_factory=mock_session_factory,
|
||||
)
|
||||
inst._mqtt = MagicMock()
|
||||
# Pre-seed the recent-queue dict — pretend _add_to_print_queue just
|
||||
# committed item id 42.
|
||||
inst._recent_queue_items["test.3mf"] = ([42], 1_000_000.0)
|
||||
# Drive _restamp via on_print_command on the late-MQTT path.
|
||||
with patch("backend.app.services.virtual_printer.manager.time.monotonic", return_value=1_000_001.0):
|
||||
await inst.on_print_command(
|
||||
"test.3mf",
|
||||
{
|
||||
"command": "project_file",
|
||||
"file": "test.3mf",
|
||||
"nozzle_mapping": [16, -1],
|
||||
},
|
||||
)
|
||||
# No UPDATE was issued — only the eligibility SELECT ran.
|
||||
assert mock_db.execute.await_count == 1
|
||||
mock_db.commit.assert_not_awaited()
|
||||
assert "test.3mf" not in inst._recent_queue_items
|
||||
|
||||
|
||||
class TestVirtualPrinterManager:
|
||||
"""Tests for VirtualPrinterManager orchestrator."""
|
||||
|
||||
Reference in New Issue
Block a user