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
@@ -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."""