test_library_dispatch_reuses_archive.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. """A library file printed again adds its run to the file's archive.
  2. Every queue dispatch of a library or project file used to archive the file
  3. anew, so a two-plate file printed plate by plate showed two archives of the
  4. same file, flagged as duplicates of each other. The dispatch now adds its run
  5. to the archive the file already has: same file, same owner, same project, from
  6. this library file (or one since deleted).
  7. An archive holds one active run, and uploads run in parallel, so it is only
  8. taken while idle: not printing, not sent or waiting to start, not queued, not
  9. claimed by another dispatch still uploading. A print of the file running at
  10. the same time gets an archive of its own, as before.
  11. """
  12. import hashlib
  13. from contextlib import ExitStack
  14. from datetime import datetime
  15. from pathlib import Path
  16. from types import SimpleNamespace
  17. from unittest.mock import AsyncMock, MagicMock, patch
  18. import pytest
  19. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  20. import backend.app.models # noqa: F401 - populate Base.metadata
  21. import backend.app.services.print_scheduler as scheduler_module
  22. from backend.app import main as main_module
  23. from backend.app.core.database import Base
  24. from backend.app.models.archive import PrintArchive
  25. from backend.app.models.library import LibraryFile
  26. from backend.app.models.print_queue import PrintQueueItem
  27. from backend.app.models.printer import Printer
  28. from backend.app.services.print_scheduler import PrintScheduler
  29. from backend.tests._fixtures.background_tasks import discarding_spawn_patch
  30. CONTENT = b"two plates"
  31. HASH = hashlib.sha256(CONTENT).hexdigest()
  32. @pytest.fixture(autouse=True)
  33. def _clean_print_state():
  34. main_module._active_prints.clear()
  35. main_module._expected_prints.clear()
  36. main_module._archive_runs_finishing.clear()
  37. yield
  38. main_module._active_prints.clear()
  39. main_module._expected_prints.clear()
  40. main_module._archive_runs_finishing.clear()
  41. @pytest.fixture
  42. async def case(tmp_path):
  43. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  44. async with engine.begin() as conn:
  45. await conn.run_sync(Base.metadata.create_all)
  46. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  47. source = tmp_path / "library" / "planter.3mf"
  48. source.parent.mkdir()
  49. source.write_bytes(CONTENT)
  50. async with session_maker() as db:
  51. printer = Printer(name="X1C", serial_number="SERIAL-1", ip_address="127.0.0.1", access_code="code", model="X1C")
  52. library_file = LibraryFile(
  53. filename="planter.3mf",
  54. file_path=str(source),
  55. file_type="3mf",
  56. file_size=source.stat().st_size,
  57. file_hash=HASH,
  58. )
  59. db.add_all([printer, library_file])
  60. await db.commit()
  61. ctx = SimpleNamespace(
  62. session_maker=session_maker,
  63. base_dir=tmp_path,
  64. printer_id=printer.id,
  65. library_file_id=library_file.id,
  66. )
  67. async def archive(**overrides) -> int:
  68. """An archive of the file, its copy on disk unless ``on_disk=False``."""
  69. on_disk = overrides.pop("on_disk", True)
  70. rel = Path("archive") / f"copy-{overrides.get('plate_id', 1)}-{len(overrides)}.3mf"
  71. async with session_maker() as db:
  72. fields = {
  73. "printer_id": ctx.printer_id,
  74. "filename": "planter.3mf",
  75. "file_path": str(rel),
  76. "file_size": 10,
  77. "content_hash": HASH,
  78. "status": "completed",
  79. "library_file_id": ctx.library_file_id,
  80. "plate_id": 1,
  81. }
  82. row = PrintArchive(**(fields | overrides))
  83. db.add(row)
  84. await db.commit()
  85. if on_disk:
  86. path = tmp_path / rel
  87. path.parent.mkdir(parents=True, exist_ok=True)
  88. path.write_bytes(CONTENT)
  89. return row.id
  90. async def item(**overrides) -> int:
  91. async with session_maker() as db:
  92. fields = {
  93. "printer_id": ctx.printer_id,
  94. "library_file_id": ctx.library_file_id,
  95. "status": "pending",
  96. "plate_id": 2,
  97. }
  98. row = PrintQueueItem(**(fields | overrides))
  99. db.add(row)
  100. await db.commit()
  101. return row.id
  102. ctx.archive = archive
  103. ctx.item = item
  104. with patch.object(scheduler_module.settings, "base_dir", tmp_path):
  105. yield ctx
  106. await engine.dispose()
  107. async def _claim(ctx, scheduler, item_id):
  108. async with ctx.session_maker() as db:
  109. item = await db.get(PrintQueueItem, item_id)
  110. library_file = await db.get(LibraryFile, ctx.library_file_id)
  111. archive = await scheduler._claim_reusable_archive(db, item, Path(library_file.file_path))
  112. return archive.id if archive is not None else None
  113. @pytest.mark.asyncio
  114. async def test_the_files_archive_is_reused(case):
  115. existing = await case.archive()
  116. assert await _claim(case, PrintScheduler(), await case.item()) == existing
  117. @pytest.mark.asyncio
  118. async def test_the_file_is_matched_as_it_is_on_disk(case):
  119. """The stored hash is not refreshed when the file changes in place (an
  120. external folder): a stale one neither finds the old content's archive
  121. nor misses the current one."""
  122. existing = await case.archive()
  123. async with case.session_maker() as db:
  124. (await db.get(LibraryFile, case.library_file_id)).file_hash = "b" * 64
  125. await db.commit()
  126. assert await _claim(case, PrintScheduler(), await case.item()) == existing
  127. @pytest.mark.asyncio
  128. async def test_an_archive_whose_library_file_was_deleted_is_reused(case):
  129. existing = await case.archive(library_file_id=None)
  130. assert await _claim(case, PrintScheduler(), await case.item()) == existing
  131. @pytest.mark.asyncio
  132. async def test_the_newest_idle_archive_is_taken(case):
  133. await case.archive()
  134. newest = await case.archive(tags="newest")
  135. assert await _claim(case, PrintScheduler(), await case.item()) == newest
  136. @pytest.mark.parametrize(
  137. "archive_overrides, item_overrides",
  138. [
  139. pytest.param({"content_hash": "b" * 64}, {}, id="another-file"),
  140. pytest.param({"created_by_id": 7}, {}, id="another-owner"),
  141. pytest.param({}, {"created_by_id": 7}, id="owned-print-of-an-unowned-archive"),
  142. pytest.param({"project_id": 3}, {}, id="another-project"),
  143. pytest.param({}, {"project_id": 3}, id="project-print-of-a-loose-archive"),
  144. pytest.param({"library_file_id": 999}, {}, id="another-library-file"),
  145. pytest.param({"deleted_at": datetime(2026, 10, 1)}, {}, id="deleted"),
  146. pytest.param({"status": "printing"}, {}, id="printing"),
  147. pytest.param({"on_disk": False}, {}, id="its-copy-is-gone"),
  148. ],
  149. )
  150. @pytest.mark.asyncio
  151. async def test_no_archive_that_is_not_this_files_or_not_usable(case, archive_overrides, item_overrides):
  152. await case.archive(**archive_overrides)
  153. assert await _claim(case, PrintScheduler(), await case.item(**item_overrides)) is None
  154. @pytest.mark.asyncio
  155. async def test_not_while_another_queue_item_prints_or_waits_on_it(case):
  156. existing = await case.archive()
  157. await case.item(archive_id=existing, library_file_id=None, status="pending")
  158. assert await _claim(case, PrintScheduler(), await case.item()) is None
  159. @pytest.mark.asyncio
  160. async def test_not_while_one_of_its_prints_runs_or_finishes(case):
  161. existing = await case.archive()
  162. main_module._active_prints[(case.printer_id, "planter.3mf")] = existing
  163. assert await _claim(case, PrintScheduler(), await case.item()) is None
  164. # Completed, but its timelapse is still being looked for: that scan tells
  165. # its run by the archive's start time, which a new run would change.
  166. main_module._active_prints.clear()
  167. main_module._archive_runs_finishing[existing] = 1
  168. assert await _claim(case, PrintScheduler(), await case.item()) is None
  169. @pytest.mark.asyncio
  170. async def test_a_name_left_from_an_earlier_dispatch_does_not_block_it(case):
  171. """A print start leaves the '<name>.gcode' variant of its registration
  172. behind for two hours; the plate printed next must still join."""
  173. existing = await case.archive()
  174. main_module._expected_prints[(case.printer_id, "planter.gcode")] = existing
  175. assert await _claim(case, PrintScheduler(), await case.item()) == existing
  176. @pytest.mark.asyncio
  177. async def test_two_dispatches_in_flight_never_share_it(case):
  178. existing = await case.archive()
  179. scheduler = PrintScheduler()
  180. first, second = await case.item(), await case.item()
  181. assert await _claim(case, scheduler, first) == existing
  182. assert await _claim(case, scheduler, second) is None
  183. # The first dispatch ended: the archive is free for the next print.
  184. scheduler._release_archive_claims(first)
  185. assert await _claim(case, scheduler, second) == existing
  186. async def _start(case, item_id, archive_print):
  187. scheduler = PrintScheduler()
  188. patches = [
  189. patch("backend.app.services.archive.ArchiveService.archive_print", new=archive_print),
  190. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  191. patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
  192. patch("backend.app.services.print_scheduler.printer_manager.start_print", MagicMock(return_value=True)),
  193. patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
  194. patch(
  195. "backend.app.services.print_scheduler.get_ftp_retry_settings", AsyncMock(return_value=(False, 0, 0, 1.0))
  196. ),
  197. patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
  198. patch("backend.app.services.print_scheduler.upload_file_async", AsyncMock(return_value=True)),
  199. patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
  200. discarding_spawn_patch(),
  201. patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
  202. patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
  203. patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
  204. patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
  205. patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
  206. ]
  207. with ExitStack() as stack:
  208. for patcher in patches:
  209. stack.enter_context(patcher)
  210. async with case.session_maker() as db:
  211. item = await db.get(PrintQueueItem, item_id)
  212. await scheduler._start_print(db, item)
  213. async with case.session_maker() as db:
  214. item = await db.get(PrintQueueItem, item_id)
  215. return item, (await db.get(PrintArchive, item.archive_id) if item.archive_id else None)
  216. @pytest.mark.asyncio
  217. async def test_the_dispatch_adds_its_run_to_the_files_archive(case):
  218. existing = await case.archive(cost_center_id=5)
  219. archive_print = AsyncMock()
  220. item, archive = await _start(case, await case.item(plate_id=2), archive_print)
  221. archive_print.assert_not_called()
  222. assert item.archive_id == existing
  223. # The latest run's, as a new archive would have them.
  224. assert archive.plate_id == 2
  225. assert archive.cost_center_id is None
  226. @pytest.mark.asyncio
  227. async def test_a_file_never_archived_is_archived(case):
  228. created = {}
  229. async def archive_print(self, **kwargs):
  230. row = PrintArchive(
  231. printer_id=kwargs["printer_id"],
  232. filename="planter.3mf",
  233. file_path="archive/new.3mf",
  234. file_size=10,
  235. content_hash=HASH,
  236. status="archived",
  237. )
  238. self.db.add(row)
  239. await self.db.flush()
  240. created["id"] = row.id
  241. return row
  242. item, _archive = await _start(case, await case.item(), archive_print)
  243. assert item.archive_id == created["id"]
  244. @pytest.mark.asyncio
  245. async def test_a_reprint_of_another_plate_shows_that_plate(case):
  246. """The archive shows its latest run's plate; the first one's stays on its
  247. print log entry."""
  248. existing = await case.archive(plate_id=1)
  249. _item, archive = await _start(
  250. case, await case.item(archive_id=existing, library_file_id=None, plate_id=3), AsyncMock()
  251. )
  252. assert archive.plate_id == 3
  253. @pytest.mark.parametrize("fails", [False, True], ids=["sent", "failed"])
  254. @pytest.mark.asyncio
  255. async def test_the_dispatch_gives_its_claim_back_when_it_ends(fails):
  256. """Sent, its print shows as running; failed, the archive is free again.
  257. Another dispatch's claim is left alone."""
  258. scheduler = PrintScheduler()
  259. async def start_print(db, item):
  260. scheduler._archive_claims[298] = 597
  261. if fails:
  262. raise RuntimeError("upload failed")
  263. scheduler._archive_claims[300] = 600
  264. db = MagicMock()
  265. db.get = AsyncMock(return_value=MagicMock(id=597))
  266. session = MagicMock()
  267. session.__aenter__ = AsyncMock(return_value=db)
  268. session.__aexit__ = AsyncMock(return_value=False)
  269. with ExitStack() as stack:
  270. stack.enter_context(patch("backend.app.services.print_scheduler.async_session", return_value=session))
  271. stack.enter_context(patch.object(scheduler, "_claim_for_dispatch", AsyncMock(return_value=True)))
  272. stack.enter_context(patch.object(scheduler, "_start_print", side_effect=start_print))
  273. stack.enter_context(patch.object(scheduler, "_clear_dispatch_claim", AsyncMock()))
  274. if fails:
  275. stack.enter_context(pytest.raises(RuntimeError))
  276. await scheduler._dispatch_one(597)
  277. assert scheduler._archive_claims == {300: 600}