test_stranded_printing_recovery_2829.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. """A queue row that was never closed must not block the printer forever (#2829).
  2. ``on_print_complete`` refuses to close a row when the completion's subtask name
  3. disagrees with the file it was dispatched with, so another print's completion
  4. cannot end someone's job early. Nothing took the refusal back, though, and
  5. ``check_queue`` counts every ``printing`` row as a busy printer -- so one bad
  6. comparison wedged that printer's queue until a human pressed cancel.
  7. The name comparison is fixed separately; this is the net under it, for the next
  8. name format nobody predicted.
  9. """
  10. import types
  11. from unittest.mock import patch
  12. import pytest
  13. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  14. from backend.app.services.print_scheduler import _STRANDED_PRINTING_GRACE_SECONDS, _terminal_queue_status
  15. pytestmark = pytest.mark.integration
  16. def _state(state="FINISH", connected=True):
  17. return types.SimpleNamespace(state=state, connected=connected)
  18. async def _noop():
  19. return None
  20. async def _noop_arg(*_args, **_kwargs):
  21. return None
  22. class TestTerminalStatusMapping:
  23. @pytest.mark.parametrize(
  24. "printer_state,expected",
  25. [("FINISH", "completed"), ("FAILED", "failed"), ("IDLE", "cancelled")],
  26. )
  27. def test_terminal_states_imply_a_queue_status(self, printer_state, expected):
  28. """Mirrors what the MQTT completion path would have set, so a recovered
  29. row cannot disagree with one closed normally."""
  30. assert _terminal_queue_status(_state(printer_state)) == expected
  31. @pytest.mark.parametrize("printer_state", ["RUNNING", "PREPARE", "PAUSE", "SLICING", None])
  32. def test_a_busy_printer_implies_nothing(self, printer_state):
  33. assert _terminal_queue_status(_state(printer_state)) is None
  34. def test_a_disconnected_printer_implies_nothing(self):
  35. """Its `state` is whatever we last heard, which proves nothing about
  36. what the printer is doing now -- closing on it would be a guess."""
  37. assert _terminal_queue_status(_state("FINISH", connected=False)) is None
  38. def test_no_state_at_all_implies_nothing(self):
  39. assert _terminal_queue_status(None) is None
  40. @pytest.mark.asyncio
  41. class TestTheSweep:
  42. """Drives the real sweep against a real database."""
  43. @pytest.fixture
  44. def scheduler(self, test_engine):
  45. """The sweep opens its own session from the scheduler module, which the
  46. widespread `patch("backend.app.main.async_session")` does not reach --
  47. the same trap b5a34b7ba's own commit message describes. Patch it at the
  48. module, as the other scheduler integration tests do."""
  49. import backend.app.services.print_scheduler as scheduler_module
  50. from backend.app.services.print_scheduler import PrintScheduler
  51. maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  52. with patch.object(scheduler_module, "async_session", maker):
  53. yield PrintScheduler()
  54. async def _item(self, db_session, printer, status="printing"):
  55. from backend.app.models.print_queue import PrintQueueItem
  56. item = PrintQueueItem(printer_id=printer.id, status=status)
  57. db_session.add(item)
  58. await db_session.commit()
  59. await db_session.refresh(item)
  60. return item
  61. async def _status_of(self, db_session, item_id):
  62. from backend.app.models.print_queue import PrintQueueItem
  63. db_session.expire_all()
  64. return (await db_session.get(PrintQueueItem, item_id)).status
  65. async def test_a_row_is_left_alone_inside_the_grace_period(
  66. self, scheduler, db_session, printer_factory, monkeypatch
  67. ):
  68. """A real completion arrives seconds after the printer goes terminal.
  69. Closing early would race the normal path and beat it to the row."""
  70. printer = await printer_factory()
  71. item = await self._item(db_session, printer)
  72. monkeypatch.setattr(
  73. "backend.app.services.print_scheduler.printer_manager.get_status", lambda _pid: _state("FINISH")
  74. )
  75. await scheduler._close_stranded_printing_items()
  76. assert await self._status_of(db_session, item.id) == "printing"
  77. async def test_a_row_is_closed_once_the_grace_period_passes(
  78. self, scheduler, db_session, printer_factory, monkeypatch
  79. ):
  80. printer = await printer_factory()
  81. item = await self._item(db_session, printer)
  82. monkeypatch.setattr(
  83. "backend.app.services.print_scheduler.printer_manager.get_status", lambda _pid: _state("FINISH")
  84. )
  85. await scheduler._close_stranded_printing_items()
  86. # Age the clock rather than sleeping five minutes.
  87. scheduler._terminal_since[printer.id] -= _STRANDED_PRINTING_GRACE_SECONDS + 1
  88. await scheduler._close_stranded_printing_items()
  89. assert await self._status_of(db_session, item.id) == "completed"
  90. async def test_the_clock_restarts_when_the_printer_goes_busy_again(
  91. self, scheduler, db_session, printer_factory, monkeypatch
  92. ):
  93. """The grace period has to measure one unbroken terminal run. A printer
  94. that finished, started something else, and finished again must not have
  95. the two stretches added together."""
  96. printer = await printer_factory()
  97. item = await self._item(db_session, printer)
  98. state = _state("FINISH")
  99. monkeypatch.setattr("backend.app.services.print_scheduler.printer_manager.get_status", lambda _pid: state)
  100. await scheduler._close_stranded_printing_items()
  101. scheduler._terminal_since[printer.id] -= _STRANDED_PRINTING_GRACE_SECONDS + 1
  102. state.state = "RUNNING"
  103. await scheduler._close_stranded_printing_items()
  104. assert printer.id not in scheduler._terminal_since
  105. state.state = "FINISH"
  106. await scheduler._close_stranded_printing_items()
  107. assert await self._status_of(db_session, item.id) == "printing"
  108. async def test_a_disconnected_printer_is_never_closed_on(self, scheduler, db_session, printer_factory, monkeypatch):
  109. printer = await printer_factory()
  110. item = await self._item(db_session, printer)
  111. monkeypatch.setattr(
  112. "backend.app.services.print_scheduler.printer_manager.get_status",
  113. lambda _pid: _state("FINISH", connected=False),
  114. )
  115. await scheduler._close_stranded_printing_items()
  116. scheduler._terminal_since[printer.id] = 0.0 # as if it had been ages
  117. await scheduler._close_stranded_printing_items()
  118. assert await self._status_of(db_session, item.id) == "printing"
  119. async def test_the_failure_status_is_carried_over(self, scheduler, db_session, printer_factory, monkeypatch):
  120. printer = await printer_factory()
  121. item = await self._item(db_session, printer)
  122. monkeypatch.setattr(
  123. "backend.app.services.print_scheduler.printer_manager.get_status", lambda _pid: _state("FAILED")
  124. )
  125. await scheduler._close_stranded_printing_items()
  126. scheduler._terminal_since[printer.id] -= _STRANDED_PRINTING_GRACE_SECONDS + 1
  127. await scheduler._close_stranded_printing_items()
  128. assert await self._status_of(db_session, item.id) == "failed"
  129. async def test_rows_that_are_not_printing_are_ignored(self, scheduler, db_session, printer_factory, monkeypatch):
  130. printer = await printer_factory()
  131. pending = await self._item(db_session, printer, status="pending")
  132. monkeypatch.setattr(
  133. "backend.app.services.print_scheduler.printer_manager.get_status", lambda _pid: _state("FINISH")
  134. )
  135. await scheduler._close_stranded_printing_items()
  136. scheduler._terminal_since[printer.id] = 0.0
  137. await scheduler._close_stranded_printing_items()
  138. assert await self._status_of(db_session, pending.id) == "pending"
  139. async def test_a_completed_row_clears_the_clock(self, scheduler, db_session, printer_factory, monkeypatch):
  140. """Nothing printing means nothing to time, and a stale entry would give
  141. the next print a head start on its own grace period."""
  142. printer = await printer_factory()
  143. monkeypatch.setattr(
  144. "backend.app.services.print_scheduler.printer_manager.get_status", lambda _pid: _state("FINISH")
  145. )
  146. scheduler._terminal_since[printer.id] = 0.0
  147. await scheduler._close_stranded_printing_items()
  148. assert scheduler._terminal_since == {}
  149. async def test_the_scheduler_loop_actually_runs_the_sweep(self, scheduler, monkeypatch):
  150. """The sweep is only worth anything if the loop calls it.
  151. Without this, every test above passes against a build where the call
  152. was never wired in -- which is exactly what a mutation check caught.
  153. """
  154. called = []
  155. monkeypatch.setattr(scheduler, "_close_stranded_printing_items", lambda: called.append(True) or _noop())
  156. monkeypatch.setattr(scheduler, "_clear_stale_dispatch_claims", lambda **_kw: _noop())
  157. monkeypatch.setattr(scheduler, "_sample_chamber_temps", lambda: None)
  158. async def stop_after_one_pass():
  159. scheduler._running = False
  160. return False
  161. monkeypatch.setattr(scheduler, "check_queue", stop_after_one_pass)
  162. monkeypatch.setattr("backend.app.services.print_scheduler.asyncio.sleep", _noop_arg)
  163. await scheduler.run()
  164. assert called, "the scheduler loop never called the stranded-item sweep"
  165. async def test_a_broken_sweep_does_not_break_the_scheduler_loop(self, scheduler, monkeypatch):
  166. """It runs beside the dispatch-claim sweep on every tick. A recovery
  167. path that can take the loop down is worse than the strand it fixes."""
  168. monkeypatch.setattr(
  169. "backend.app.services.print_scheduler.printer_manager.get_status",
  170. lambda _pid: (_ for _ in ()).throw(RuntimeError("boom")),
  171. )
  172. await scheduler._close_stranded_printing_items() # must not raise