test_scheduler_busy_defer_2598.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. """The scheduler defers (never fails) a dispatch that hits a busy printer (#2598).
  2. check_queue gates dispatch on _is_printer_idle(), but that treats FINISH as
  3. idle and a printer can keep reporting FINISH for tens of seconds after it
  4. accepted a project_file; a watchdog revert (#2555) also releases the dispatch
  5. hold. So a re-selected item can reach _start_print while its printer has
  6. actually started printing. Two guards keep that from cancelling the live job:
  7. * pre-dispatch — before the FTP upload, a busy printer defers (item stays
  8. pending), so there is no wasted upload and no start command;
  9. * post-dispatch — if the printer goes busy in the upload window and
  10. start_print() returns False, the item is reverted to pending (deferred), not
  11. marked failed.
  12. """
  13. from contextlib import ExitStack
  14. from pathlib import Path
  15. from types import SimpleNamespace
  16. from unittest.mock import AsyncMock, MagicMock, patch
  17. import pytest
  18. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  19. import backend.app.models # noqa: F401 - populate Base.metadata
  20. import backend.app.services.print_scheduler as scheduler_module
  21. from backend.app.core.database import Base
  22. from backend.app.models.archive import PrintArchive
  23. from backend.app.models.print_queue import PrintQueueItem
  24. from backend.app.models.printer import Printer
  25. from backend.app.services.print_scheduler import PrintScheduler
  26. @pytest.fixture
  27. async def dispatch_case(tmp_path):
  28. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  29. async with engine.begin() as conn:
  30. await conn.run_sync(Base.metadata.create_all)
  31. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  32. base_dir = tmp_path / "case"
  33. base_dir.mkdir()
  34. archive_rel = Path("archives") / "job.3mf"
  35. archive_abs = base_dir / archive_rel
  36. archive_abs.parent.mkdir(parents=True, exist_ok=True)
  37. archive_abs.write_bytes(b"archive payload")
  38. async with session_maker() as db:
  39. printer = Printer(
  40. name="Printer",
  41. serial_number="SERIAL",
  42. ip_address="127.0.0.1",
  43. access_code="access-code",
  44. model="A1MINI",
  45. )
  46. db.add(printer)
  47. await db.flush()
  48. archive = PrintArchive(
  49. printer_id=printer.id,
  50. filename="job.3mf",
  51. file_path=str(archive_rel),
  52. file_size=archive_abs.stat().st_size,
  53. status="completed",
  54. )
  55. db.add(archive)
  56. await db.flush()
  57. item = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="pending")
  58. db.add(item)
  59. await db.commit()
  60. ids = SimpleNamespace(printer_id=printer.id, archive_id=archive.id, item_id=item.id)
  61. try:
  62. yield SimpleNamespace(session_maker=session_maker, base_dir=base_dir, ids=ids)
  63. finally:
  64. await engine.dispose()
  65. def _base_patches(scheduler, ctx, upload_mock, start_print_mock, get_status):
  66. return [
  67. patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
  68. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  69. patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
  70. patch("backend.app.services.print_scheduler.printer_manager.start_print", start_print_mock),
  71. patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
  72. patch(
  73. "backend.app.services.print_scheduler.get_ftp_retry_settings",
  74. AsyncMock(return_value=(False, 0, 0, 1.0)),
  75. ),
  76. patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
  77. patch("backend.app.services.print_scheduler.upload_file_async", upload_mock),
  78. patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
  79. patch("backend.app.services.print_scheduler.spawn_background_task", MagicMock()),
  80. patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
  81. patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
  82. patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
  83. ]
  84. async def _final_item(ctx):
  85. async with ctx.session_maker() as db:
  86. return await db.get(PrintQueueItem, ctx.ids.item_id)
  87. @pytest.mark.asyncio
  88. async def test_pre_dispatch_busy_defers_without_upload(dispatch_case):
  89. """Printer already RUNNING when _start_print begins → defer, no upload/start."""
  90. scheduler = PrintScheduler()
  91. upload = AsyncMock(return_value=True)
  92. start_print = MagicMock(return_value=True)
  93. get_status = MagicMock(return_value=SimpleNamespace(state="RUNNING", subtask_id=None, gcode_file=None))
  94. async with dispatch_case.session_maker() as db:
  95. item = await db.get(PrintQueueItem, dispatch_case.ids.item_id)
  96. with ExitStack() as stack:
  97. for p in _base_patches(scheduler, dispatch_case, upload, start_print, get_status):
  98. stack.enter_context(p)
  99. await scheduler._start_print(db, item)
  100. upload.assert_not_awaited()
  101. start_print.assert_not_called()
  102. final = await _final_item(dispatch_case)
  103. assert final.status == "pending", "a busy printer must defer the item, not consume it"
  104. @pytest.mark.asyncio
  105. async def test_post_dispatch_busy_reverts_to_pending_not_failed(dispatch_case):
  106. """Printer goes busy in the upload window; start_print returns False → defer."""
  107. scheduler = PrintScheduler()
  108. upload = AsyncMock(return_value=True)
  109. holder = {"state": "IDLE"}
  110. class _Status:
  111. subtask_id = None
  112. gcode_file = None
  113. @property
  114. def state(self):
  115. return holder["state"]
  116. def _start_print(*args, **kwargs):
  117. # The printer became busy between the pre-dispatch check and the publish.
  118. holder["state"] = "RUNNING"
  119. return False # start_print() refused: busy
  120. start_print = MagicMock(side_effect=_start_print)
  121. get_status = MagicMock(return_value=_Status())
  122. async with dispatch_case.session_maker() as db:
  123. item = await db.get(PrintQueueItem, dispatch_case.ids.item_id)
  124. with ExitStack() as stack:
  125. for p in _base_patches(scheduler, dispatch_case, upload, start_print, get_status):
  126. stack.enter_context(p)
  127. await scheduler._start_print(db, item)
  128. upload.assert_awaited_once() # it proceeded past the (idle) pre-dispatch check
  129. start_print.assert_called_once()
  130. final = await _final_item(dispatch_case)
  131. assert final.status == "pending", "a busy-refused start must defer, not fail the item"
  132. assert final.started_at is None