mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-05 13:41:36 +02:00
Bambuddy has no run identifier to tie a completion to a queue row, so it
finds the row by printer and status='printing' alone. b5a34b7ba added a
check that the completion's subtask name agrees with the file the row was
dispatched with, so the printer's own calibration runs cannot close
someone's job early. It compared the two names verbatim.
The printer does not echo them verbatim. It substitutes underscores for
spaces, so 'H2D_Carbon_Filter_(V2)_Body & Solid Lid' came back as
'H2D_Carbon_Filter_(V2)_Body_&_Solid_Lid', the check refused it, and the
row stayed printing. check_queue counts every printing row as a busy
printer and nothing else ever closes one, so the printer's queue stopped
until someone cancelled by hand. It also truncates long names and marks
the cut with '...', which would have done the same to any long title.
Compare on the canonical form instead -- case, spaces and underscores --
which is the rule the 3MF lookup in this module has always used for the
same names, and treat a truncation marker as a prefix match. The check
keeps its purpose: the same printer the same day correctly refused a
completion for auto_pa_line_calib_mode.
One comparison being stricter than reality should not be able to stop a
queue indefinitely, so the scheduler now closes a row itself when it has
been printing for five minutes after its printer went terminal, with the
status that state implies. A real completion arrives within seconds, so
this only sees rows that were already stranded, and a disconnected
printer never qualifies. It restores the queue only -- notifications,
billing and auto-off are not replayed minutes late.
229 lines
9.8 KiB
Python
229 lines
9.8 KiB
Python
"""A queue row that was never closed must not block the printer forever (#2829).
|
|
|
|
``on_print_complete`` refuses to close a row when the completion's subtask name
|
|
disagrees with the file it was dispatched with, so another print's completion
|
|
cannot end someone's job early. Nothing took the refusal back, though, and
|
|
``check_queue`` counts every ``printing`` row as a busy printer -- so one bad
|
|
comparison wedged that printer's queue until a human pressed cancel.
|
|
|
|
The name comparison is fixed separately; this is the net under it, for the next
|
|
name format nobody predicted.
|
|
"""
|
|
|
|
import types
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
|
from backend.app.services.print_scheduler import _STRANDED_PRINTING_GRACE_SECONDS, _terminal_queue_status
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
def _state(state="FINISH", connected=True):
|
|
return types.SimpleNamespace(state=state, connected=connected)
|
|
|
|
|
|
async def _noop():
|
|
return None
|
|
|
|
|
|
async def _noop_arg(*_args, **_kwargs):
|
|
return None
|
|
|
|
|
|
class TestTerminalStatusMapping:
|
|
@pytest.mark.parametrize(
|
|
"printer_state,expected",
|
|
[("FINISH", "completed"), ("FAILED", "failed"), ("IDLE", "cancelled")],
|
|
)
|
|
def test_terminal_states_imply_a_queue_status(self, printer_state, expected):
|
|
"""Mirrors what the MQTT completion path would have set, so a recovered
|
|
row cannot disagree with one closed normally."""
|
|
assert _terminal_queue_status(_state(printer_state)) == expected
|
|
|
|
@pytest.mark.parametrize("printer_state", ["RUNNING", "PREPARE", "PAUSE", "SLICING", None])
|
|
def test_a_busy_printer_implies_nothing(self, printer_state):
|
|
assert _terminal_queue_status(_state(printer_state)) is None
|
|
|
|
def test_a_disconnected_printer_implies_nothing(self):
|
|
"""Its `state` is whatever we last heard, which proves nothing about
|
|
what the printer is doing now -- closing on it would be a guess."""
|
|
assert _terminal_queue_status(_state("FINISH", connected=False)) is None
|
|
|
|
def test_no_state_at_all_implies_nothing(self):
|
|
assert _terminal_queue_status(None) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestTheSweep:
|
|
"""Drives the real sweep against a real database."""
|
|
|
|
@pytest.fixture
|
|
def scheduler(self, test_engine):
|
|
"""The sweep opens its own session from the scheduler module, which the
|
|
widespread `patch("backend.app.main.async_session")` does not reach --
|
|
the same trap b5a34b7ba's own commit message describes. Patch it at the
|
|
module, as the other scheduler integration tests do."""
|
|
import backend.app.services.print_scheduler as scheduler_module
|
|
from backend.app.services.print_scheduler import PrintScheduler
|
|
|
|
maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
|
with patch.object(scheduler_module, "async_session", maker):
|
|
yield PrintScheduler()
|
|
|
|
async def _item(self, db_session, printer, status="printing"):
|
|
from backend.app.models.print_queue import PrintQueueItem
|
|
|
|
item = PrintQueueItem(printer_id=printer.id, status=status)
|
|
db_session.add(item)
|
|
await db_session.commit()
|
|
await db_session.refresh(item)
|
|
return item
|
|
|
|
async def _status_of(self, db_session, item_id):
|
|
from backend.app.models.print_queue import PrintQueueItem
|
|
|
|
db_session.expire_all()
|
|
return (await db_session.get(PrintQueueItem, item_id)).status
|
|
|
|
async def test_a_row_is_left_alone_inside_the_grace_period(
|
|
self, scheduler, db_session, printer_factory, monkeypatch
|
|
):
|
|
"""A real completion arrives seconds after the printer goes terminal.
|
|
Closing early would race the normal path and beat it to the row."""
|
|
printer = await printer_factory()
|
|
item = await self._item(db_session, printer)
|
|
monkeypatch.setattr(
|
|
"backend.app.services.print_scheduler.printer_manager.get_status", lambda _pid: _state("FINISH")
|
|
)
|
|
|
|
await scheduler._close_stranded_printing_items()
|
|
|
|
assert await self._status_of(db_session, item.id) == "printing"
|
|
|
|
async def test_a_row_is_closed_once_the_grace_period_passes(
|
|
self, scheduler, db_session, printer_factory, monkeypatch
|
|
):
|
|
printer = await printer_factory()
|
|
item = await self._item(db_session, printer)
|
|
monkeypatch.setattr(
|
|
"backend.app.services.print_scheduler.printer_manager.get_status", lambda _pid: _state("FINISH")
|
|
)
|
|
|
|
await scheduler._close_stranded_printing_items()
|
|
# Age the clock rather than sleeping five minutes.
|
|
scheduler._terminal_since[printer.id] -= _STRANDED_PRINTING_GRACE_SECONDS + 1
|
|
await scheduler._close_stranded_printing_items()
|
|
|
|
assert await self._status_of(db_session, item.id) == "completed"
|
|
|
|
async def test_the_clock_restarts_when_the_printer_goes_busy_again(
|
|
self, scheduler, db_session, printer_factory, monkeypatch
|
|
):
|
|
"""The grace period has to measure one unbroken terminal run. A printer
|
|
that finished, started something else, and finished again must not have
|
|
the two stretches added together."""
|
|
printer = await printer_factory()
|
|
item = await self._item(db_session, printer)
|
|
state = _state("FINISH")
|
|
monkeypatch.setattr("backend.app.services.print_scheduler.printer_manager.get_status", lambda _pid: state)
|
|
|
|
await scheduler._close_stranded_printing_items()
|
|
scheduler._terminal_since[printer.id] -= _STRANDED_PRINTING_GRACE_SECONDS + 1
|
|
|
|
state.state = "RUNNING"
|
|
await scheduler._close_stranded_printing_items()
|
|
assert printer.id not in scheduler._terminal_since
|
|
|
|
state.state = "FINISH"
|
|
await scheduler._close_stranded_printing_items()
|
|
|
|
assert await self._status_of(db_session, item.id) == "printing"
|
|
|
|
async def test_a_disconnected_printer_is_never_closed_on(self, scheduler, db_session, printer_factory, monkeypatch):
|
|
printer = await printer_factory()
|
|
item = await self._item(db_session, printer)
|
|
monkeypatch.setattr(
|
|
"backend.app.services.print_scheduler.printer_manager.get_status",
|
|
lambda _pid: _state("FINISH", connected=False),
|
|
)
|
|
|
|
await scheduler._close_stranded_printing_items()
|
|
scheduler._terminal_since[printer.id] = 0.0 # as if it had been ages
|
|
await scheduler._close_stranded_printing_items()
|
|
|
|
assert await self._status_of(db_session, item.id) == "printing"
|
|
|
|
async def test_the_failure_status_is_carried_over(self, scheduler, db_session, printer_factory, monkeypatch):
|
|
printer = await printer_factory()
|
|
item = await self._item(db_session, printer)
|
|
monkeypatch.setattr(
|
|
"backend.app.services.print_scheduler.printer_manager.get_status", lambda _pid: _state("FAILED")
|
|
)
|
|
|
|
await scheduler._close_stranded_printing_items()
|
|
scheduler._terminal_since[printer.id] -= _STRANDED_PRINTING_GRACE_SECONDS + 1
|
|
await scheduler._close_stranded_printing_items()
|
|
|
|
assert await self._status_of(db_session, item.id) == "failed"
|
|
|
|
async def test_rows_that_are_not_printing_are_ignored(self, scheduler, db_session, printer_factory, monkeypatch):
|
|
printer = await printer_factory()
|
|
pending = await self._item(db_session, printer, status="pending")
|
|
monkeypatch.setattr(
|
|
"backend.app.services.print_scheduler.printer_manager.get_status", lambda _pid: _state("FINISH")
|
|
)
|
|
|
|
await scheduler._close_stranded_printing_items()
|
|
scheduler._terminal_since[printer.id] = 0.0
|
|
await scheduler._close_stranded_printing_items()
|
|
|
|
assert await self._status_of(db_session, pending.id) == "pending"
|
|
|
|
async def test_a_completed_row_clears_the_clock(self, scheduler, db_session, printer_factory, monkeypatch):
|
|
"""Nothing printing means nothing to time, and a stale entry would give
|
|
the next print a head start on its own grace period."""
|
|
printer = await printer_factory()
|
|
monkeypatch.setattr(
|
|
"backend.app.services.print_scheduler.printer_manager.get_status", lambda _pid: _state("FINISH")
|
|
)
|
|
scheduler._terminal_since[printer.id] = 0.0
|
|
|
|
await scheduler._close_stranded_printing_items()
|
|
|
|
assert scheduler._terminal_since == {}
|
|
|
|
async def test_the_scheduler_loop_actually_runs_the_sweep(self, scheduler, monkeypatch):
|
|
"""The sweep is only worth anything if the loop calls it.
|
|
|
|
Without this, every test above passes against a build where the call
|
|
was never wired in -- which is exactly what a mutation check caught.
|
|
"""
|
|
called = []
|
|
monkeypatch.setattr(scheduler, "_close_stranded_printing_items", lambda: called.append(True) or _noop())
|
|
monkeypatch.setattr(scheduler, "_clear_stale_dispatch_claims", lambda **_kw: _noop())
|
|
monkeypatch.setattr(scheduler, "_sample_chamber_temps", lambda: None)
|
|
|
|
async def stop_after_one_pass():
|
|
scheduler._running = False
|
|
return False
|
|
|
|
monkeypatch.setattr(scheduler, "check_queue", stop_after_one_pass)
|
|
monkeypatch.setattr("backend.app.services.print_scheduler.asyncio.sleep", _noop_arg)
|
|
|
|
await scheduler.run()
|
|
|
|
assert called, "the scheduler loop never called the stranded-item sweep"
|
|
|
|
async def test_a_broken_sweep_does_not_break_the_scheduler_loop(self, scheduler, monkeypatch):
|
|
"""It runs beside the dispatch-claim sweep on every tick. A recovery
|
|
path that can take the loop down is worse than the strand it fixes."""
|
|
monkeypatch.setattr(
|
|
"backend.app.services.print_scheduler.printer_manager.get_status",
|
|
lambda _pid: (_ for _ in ()).throw(RuntimeError("boom")),
|
|
)
|
|
|
|
await scheduler._close_stranded_printing_items() # must not raise
|