test_scheduler_model_filament_short_3137.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. """An "any model" job goes to a printer that has the filament for it (#3137).
  2. The reporter's farm queues jobs for "any H2D Pro". The matcher took the first
  3. idle H2D Pro with the right filament *type*, and only then did the deficit gate
  4. look at the amount. It held the job on that printer, pinned there, while
  5. another idle H2D Pro had plenty. The job only ran once the user moved it there
  6. by hand.
  7. Now the matcher passes over a printer that would be short while another
  8. eligible one is not. When every idle printer is short the job is held on the
  9. first, as before, so "Print Anyway" still has a printer to print on; and once
  10. another printer of its model can run it, the hold is released and the job
  11. goes there.
  12. """
  13. from contextlib import ExitStack
  14. from types import SimpleNamespace
  15. from unittest.mock import AsyncMock, MagicMock, patch
  16. import pytest
  17. from sqlalchemy import select
  18. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  19. import backend.app.models # noqa: F401 - populate Base.metadata
  20. from backend.app.core.database import Base
  21. from backend.app.models.library import LibraryFile
  22. from backend.app.models.print_queue import PrintQueueItem
  23. from backend.app.models.printer import Printer
  24. from backend.app.services.filament_deficit import FilamentDeficit
  25. from backend.app.services.print_scheduler import PrintScheduler
  26. _DEFICIT = [
  27. FilamentDeficit(
  28. slot_id=1,
  29. ams_id=0,
  30. tray_id=0,
  31. filament_type="PLA",
  32. required_grams=120.0,
  33. remaining_grams=40.0,
  34. )
  35. ]
  36. @pytest.fixture
  37. async def queue_db():
  38. """Three X1Cs, in id order, so "the first idle printer" is well defined."""
  39. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  40. async with engine.begin() as conn:
  41. await conn.run_sync(Base.metadata.create_all)
  42. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  43. async with session_maker() as db:
  44. db.add_all(
  45. [
  46. Printer(
  47. id=pid,
  48. name=f"X1C-{pid}",
  49. serial_number=f"X1C000{pid}",
  50. ip_address=f"10.0.0.{pid}",
  51. access_code="x",
  52. model="X1C",
  53. is_active=True,
  54. )
  55. for pid in (1, 2, 3)
  56. ]
  57. )
  58. await db.commit()
  59. try:
  60. yield SimpleNamespace(session_maker=session_maker)
  61. finally:
  62. await engine.dispose()
  63. async def _add_item(ctx, **fields):
  64. async with ctx.session_maker() as db:
  65. lib = LibraryFile(
  66. filename="job.gcode.3mf",
  67. file_path="/library/job.gcode.3mf",
  68. file_size=10,
  69. file_type="gcode.3mf",
  70. file_metadata={"sliced_for_model": "X1C"},
  71. )
  72. db.add(lib)
  73. await db.flush()
  74. values = {"status": "pending", "position": 1, "target_model": "X1C", "library_file_id": lib.id}
  75. values.update(fields)
  76. item = PrintQueueItem(**values)
  77. db.add(item)
  78. await db.commit()
  79. return item.id
  80. async def _get_item(ctx, item_id):
  81. async with ctx.session_maker() as db:
  82. return (await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
  83. def _short_on(short: set[int], unknown: frozenset[int] = frozenset()) -> AsyncMock:
  84. """``_filament_short_on`` stand-in: the printers in ``short`` are too light,
  85. and those in ``unknown`` have spools whose amount is not on record."""
  86. async def check(db, item, printer_id, *, require_known=False):
  87. return printer_id in short or (require_known and printer_id in unknown)
  88. return AsyncMock(side_effect=check)
  89. async def _run(
  90. ctx, scheduler, *, short: set[int], launched, idle=lambda pid: True, short_check=None, unknown=frozenset()
  91. ):
  92. """One queue pass. The deficit gate is real; only the deficit it reads is faked."""
  93. async def deficit(db, item, *, printer_id=None, ams_mapping=None):
  94. return _DEFICIT if (printer_id or item.printer_id) in short else []
  95. patches = [
  96. patch("backend.app.services.print_scheduler.async_session", ctx.session_maker),
  97. patch("backend.app.core.database.async_session", ctx.session_maker),
  98. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  99. patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
  100. patch(
  101. "backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers",
  102. AsyncMock(return_value={}),
  103. ),
  104. patch(
  105. "backend.app.services.notification_service.notification_service.on_queue_job_waiting",
  106. AsyncMock(),
  107. ),
  108. patch(
  109. "backend.app.services.notification_service.notification_service.on_queue_job_assigned",
  110. AsyncMock(),
  111. ),
  112. patch("backend.app.services.print_scheduler.compute_deficit_for_queue_item", AsyncMock(side_effect=deficit)),
  113. patch.object(scheduler, "_filament_short_on", short_check or _short_on(short, unknown)),
  114. patch.object(scheduler, "_is_printer_idle", MagicMock(side_effect=lambda pid, *a, **k: idle(pid))),
  115. patch.object(scheduler, "_check_auto_drying", AsyncMock()),
  116. patch.object(scheduler, "_ensure_ams_mapping", AsyncMock(return_value=None)),
  117. patch.object(scheduler, "_block_on_unmatched_filament", AsyncMock(return_value=False)),
  118. patch.object(scheduler, "_launch_uploads", launched),
  119. ]
  120. with ExitStack() as stack:
  121. for p in patches:
  122. stack.enter_context(p)
  123. return await scheduler.check_queue()
  124. class TestMatcher:
  125. """``_find_idle_printer_for_model`` with and without the shortfall check."""
  126. async def _find(self, ctx, scheduler, *, short=None, **kwargs):
  127. async def is_short(pid):
  128. return pid in short
  129. with (
  130. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  131. patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=True)),
  132. ):
  133. async with ctx.session_maker() as db:
  134. return await scheduler._find_idle_printer_for_model(
  135. db, "X1C", set(), is_short=is_short if short is not None else None, **kwargs
  136. )
  137. @pytest.mark.asyncio
  138. async def test_passes_over_a_short_printer(self, queue_db):
  139. assert await self._find(queue_db, PrintScheduler(), short={1}) == (2, None)
  140. @pytest.mark.asyncio
  141. async def test_every_printer_short_returns_the_first(self, queue_db):
  142. """The deficit gate then holds the job there, where Print Anyway can start it."""
  143. assert await self._find(queue_db, PrintScheduler(), short={1, 2, 3}) == (1, None)
  144. @pytest.mark.asyncio
  145. async def test_without_the_check_the_first_idle_printer_wins(self, queue_db):
  146. assert await self._find(queue_db, PrintScheduler()) == (1, None)
  147. @pytest.mark.asyncio
  148. async def test_force_colour_jobs_pass_over_a_short_printer(self, queue_db):
  149. scheduler = PrintScheduler()
  150. overrides = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "force_color_match": True}]
  151. with patch.object(scheduler, "_get_missing_force_color_slots", MagicMock(return_value=[])):
  152. result = await self._find(queue_db, scheduler, short={1}, filament_overrides=overrides)
  153. assert result == (2, None)
  154. @pytest.mark.asyncio
  155. async def test_colour_ranking_takes_the_best_printer_that_is_not_short(self, queue_db):
  156. scheduler = PrintScheduler()
  157. overrides = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
  158. matches = {1: 1, 2: 2, 3: 1}
  159. with patch.object(
  160. scheduler, "_count_override_color_matches", MagicMock(side_effect=lambda pid, _o: matches[pid])
  161. ):
  162. ranked_first = await self._find(queue_db, scheduler, short=set(), filament_overrides=overrides)
  163. best_is_short = await self._find(queue_db, scheduler, short={2}, filament_overrides=overrides)
  164. all_short = await self._find(queue_db, scheduler, short={1, 2, 3}, filament_overrides=overrides)
  165. assert ranked_first == (2, None)
  166. assert best_is_short == (1, None)
  167. assert all_short == (2, None)
  168. class TestAssignment:
  169. @pytest.mark.asyncio
  170. async def test_goes_to_the_printer_with_enough_filament(self, queue_db):
  171. """The reporter's case: printer 1 is idle but light, printer 2 idle and full."""
  172. item_id = await _add_item(queue_db)
  173. launched = MagicMock()
  174. await _run(queue_db, PrintScheduler(), short={1}, launched=launched)
  175. launched.assert_called_once()
  176. assert launched.call_args[0][0] == [item_id]
  177. item = await _get_item(queue_db, item_id)
  178. assert item.printer_id == 2
  179. assert item.manual_start is False
  180. assert item.filament_short is False
  181. @pytest.mark.asyncio
  182. async def test_held_on_the_first_printer_when_every_idle_printer_is_short(self, queue_db):
  183. item_id = await _add_item(queue_db)
  184. launched = MagicMock()
  185. await _run(queue_db, PrintScheduler(), short={1, 2, 3}, launched=launched)
  186. launched.assert_not_called()
  187. item = await _get_item(queue_db, item_id)
  188. assert item.printer_id == 1
  189. assert item.manual_start is True
  190. assert item.filament_short is True
  191. @pytest.mark.asyncio
  192. async def test_print_anyway_is_not_second_guessed(self, queue_db):
  193. """With Print Anyway set, no printer is passed over for being short."""
  194. item_id = await _add_item(queue_db, skip_filament_check=True)
  195. launched = MagicMock()
  196. scheduler = PrintScheduler()
  197. with patch(
  198. "backend.app.services.print_scheduler.PrintScheduler._compute_ams_mapping_for_printer",
  199. AsyncMock(return_value=[0]),
  200. ):
  201. await _run(
  202. queue_db,
  203. scheduler,
  204. short={1},
  205. launched=launched,
  206. short_check=AsyncMock(side_effect=scheduler._filament_short_on),
  207. )
  208. launched.assert_called_once()
  209. assert (await _get_item(queue_db, item_id)).printer_id == 1
  210. class TestHeldJob:
  211. @pytest.mark.asyncio
  212. async def test_moves_to_another_printer_that_has_the_filament(self, queue_db):
  213. item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
  214. launched = MagicMock()
  215. await _run(queue_db, PrintScheduler(), short={1}, launched=launched)
  216. launched.assert_called_once()
  217. assert launched.call_args[0][0] == [item_id]
  218. item = await _get_item(queue_db, item_id)
  219. assert item.printer_id == 2
  220. assert item.manual_start is False
  221. assert item.filament_short is False
  222. @pytest.mark.asyncio
  223. async def test_stays_held_while_every_other_printer_is_short(self, queue_db):
  224. item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
  225. launched = MagicMock()
  226. await _run(queue_db, PrintScheduler(), short={1, 2, 3}, launched=launched)
  227. launched.assert_not_called()
  228. item = await _get_item(queue_db, item_id)
  229. assert item.printer_id == 1
  230. assert item.manual_start is True
  231. assert item.filament_short is True
  232. @pytest.mark.asyncio
  233. async def test_stays_held_while_the_other_printers_are_busy(self, queue_db):
  234. """Item 2080 in the reporter's log: the printer it waits on was the only idle one."""
  235. item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
  236. launched = MagicMock()
  237. short_check = _short_on({1})
  238. await _run(
  239. queue_db, PrintScheduler(), short={1}, launched=launched, idle=lambda pid: pid == 1, short_check=short_check
  240. )
  241. launched.assert_not_called()
  242. assert (await _get_item(queue_db, item_id)).printer_id == 1
  243. # The printer it is held on is not checked again: a spool loaded there
  244. # is started with Start, as before.
  245. short_check.assert_not_called()
  246. @pytest.mark.asyncio
  247. async def test_a_job_the_user_put_on_a_printer_is_not_moved(self, queue_db):
  248. item_id = await _add_item(queue_db, target_model=None, printer_id=1, manual_start=True, filament_short=True)
  249. launched = MagicMock()
  250. await _run(queue_db, PrintScheduler(), short={1}, launched=launched)
  251. launched.assert_not_called()
  252. item = await _get_item(queue_db, item_id)
  253. assert item.printer_id == 1
  254. assert item.manual_start is True
  255. @pytest.mark.asyncio
  256. async def test_a_job_held_for_another_reason_is_not_moved(self, queue_db):
  257. """Only the deficit gate sets filament_short; a plain manual start waits for the user."""
  258. item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=False)
  259. launched = MagicMock()
  260. short_check = _short_on(set())
  261. await _run(queue_db, PrintScheduler(), short=set(), launched=launched, short_check=short_check)
  262. launched.assert_not_called()
  263. assert (await _get_item(queue_db, item_id)).manual_start is True
  264. short_check.assert_not_called()
  265. @pytest.mark.asyncio
  266. async def test_not_moved_to_a_printer_whose_spools_are_not_tracked(self, queue_db):
  267. """Moving starts the job with nobody asked, so "nothing against it" is not enough."""
  268. item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
  269. launched = MagicMock()
  270. await _run(queue_db, PrintScheduler(), short={1}, unknown={2, 3}, launched=launched)
  271. launched.assert_not_called()
  272. item = await _get_item(queue_db, item_id)
  273. assert item.printer_id == 1
  274. assert item.manual_start is True
  275. @pytest.mark.asyncio
  276. async def test_goes_to_the_printer_that_was_checked(self, queue_db):
  277. """Printer 2 comes first and has nothing against it, but only printer 3
  278. has the amount on record: the job goes to 3, not to whichever printer a
  279. fresh pick would find first."""
  280. item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
  281. launched = MagicMock()
  282. await _run(queue_db, PrintScheduler(), short={1}, unknown={2}, launched=launched)
  283. launched.assert_called_once()
  284. item = await _get_item(queue_db, item_id)
  285. assert item.printer_id == 3
  286. assert item.target_model == "X1C"
  287. assert item.manual_start is False
  288. assert item.filament_short is False
  289. assert item.waiting_reason is None
  290. @pytest.mark.asyncio
  291. async def test_a_failing_check_keeps_the_hold_and_the_pass_going(self, queue_db):
  292. held_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
  293. other_id = await _add_item(queue_db, position=2, target_model=None, printer_id=3)
  294. launched = MagicMock()
  295. scheduler = PrintScheduler()
  296. with patch.object(scheduler, "_find_idle_printer_for_model", AsyncMock(side_effect=RuntimeError("boom"))):
  297. await _run(queue_db, scheduler, short={1}, launched=launched)
  298. held = await _get_item(queue_db, held_id)
  299. assert held.printer_id == 1
  300. assert held.manual_start is True
  301. launched.assert_called_once()
  302. assert launched.call_args[0][0] == [other_id]
  303. @pytest.mark.asyncio
  304. async def test_looks_again_only_after_the_recheck_interval(self, queue_db):
  305. item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
  306. scheduler = PrintScheduler()
  307. short_check = _short_on({1, 2, 3})
  308. await _run(queue_db, scheduler, short={1, 2, 3}, launched=MagicMock(), short_check=short_check)
  309. first_pass_calls = short_check.await_count
  310. await _run(queue_db, scheduler, short={1, 2, 3}, launched=MagicMock(), short_check=short_check)
  311. assert short_check.await_count == first_pass_calls
  312. scheduler._short_hold_checked_at[item_id] -= 121
  313. launched = MagicMock()
  314. await _run(queue_db, scheduler, short={1}, launched=launched)
  315. launched.assert_called_once()
  316. assert (await _get_item(queue_db, item_id)).printer_id == 2
  317. class TestFilamentShortOn:
  318. """``_filament_short_on``'s answer where it cannot see the amounts."""
  319. @staticmethod
  320. async def _ask(*, mapping=None, deficit=None, raises=None, skip=False, require_known=False):
  321. scheduler = PrintScheduler()
  322. item = PrintQueueItem(id=7, skip_filament_check=skip)
  323. compute = AsyncMock(side_effect=raises) if raises else AsyncMock(return_value=mapping)
  324. with (
  325. patch.object(scheduler, "_compute_ams_mapping_for_printer", compute),
  326. patch(
  327. "backend.app.services.print_scheduler.compute_deficit_for_queue_item",
  328. AsyncMock(return_value=deficit or []),
  329. ),
  330. ):
  331. return await scheduler._filament_short_on(MagicMock(), item, 1, require_known=require_known)
  332. @pytest.mark.asyncio
  333. async def test_a_deficit_is_short(self):
  334. assert await self._ask(mapping=[0], deficit=_DEFICIT) is True
  335. assert await self._ask(mapping=[0], deficit=_DEFICIT, require_known=True) is True
  336. @pytest.mark.asyncio
  337. async def test_enough_is_not_short(self):
  338. assert await self._ask(mapping=[0]) is False
  339. assert await self._ask(mapping=[0], require_known=True) is False
  340. @pytest.mark.asyncio
  341. async def test_no_mapping_counts_against_the_printer_only_when_required_known(self):
  342. assert await self._ask(mapping=None) is False
  343. assert await self._ask(mapping=None, require_known=True) is True
  344. @pytest.mark.asyncio
  345. async def test_a_failing_check_counts_against_the_printer_only_when_required_known(self):
  346. assert await self._ask(raises=RuntimeError("boom")) is False
  347. assert await self._ask(raises=RuntimeError("boom"), require_known=True) is True
  348. @pytest.mark.asyncio
  349. async def test_print_anyway_is_never_short(self):
  350. assert await self._ask(mapping=[0], deficit=_DEFICIT, skip=True) is False
  351. assert await self._ask(mapping=[0], deficit=_DEFICIT, skip=True, require_known=True) is False