test_library_dispatch_reuses_archive.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  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. Also while the file prints, is queued or still finishing elsewhere: each
  8. print of an archive is its own run, with its own start, end, charge, energy,
  9. outcome, timelapse and photos (archive redesign), as the copies of a batch
  10. always were.
  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. yield
  37. main_module._active_prints.clear()
  38. main_module._expected_prints.clear()
  39. @pytest.fixture
  40. async def case(tmp_path):
  41. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  42. async with engine.begin() as conn:
  43. await conn.run_sync(Base.metadata.create_all)
  44. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  45. source = tmp_path / "library" / "planter.3mf"
  46. source.parent.mkdir()
  47. source.write_bytes(CONTENT)
  48. async with session_maker() as db:
  49. printer = Printer(name="X1C", serial_number="SERIAL-1", ip_address="127.0.0.1", access_code="code", model="X1C")
  50. library_file = LibraryFile(
  51. filename="planter.3mf",
  52. file_path=str(source),
  53. file_type="3mf",
  54. file_size=source.stat().st_size,
  55. file_hash=HASH,
  56. )
  57. db.add_all([printer, library_file])
  58. await db.commit()
  59. ctx = SimpleNamespace(
  60. session_maker=session_maker,
  61. base_dir=tmp_path,
  62. printer_id=printer.id,
  63. library_file_id=library_file.id,
  64. )
  65. async def archive(**overrides) -> int:
  66. """An archive of the file, its copy on disk unless ``on_disk=False``."""
  67. on_disk = overrides.pop("on_disk", True)
  68. rel = Path("archive") / f"copy-{overrides.get('plate_id', 1)}-{len(overrides)}.3mf"
  69. async with session_maker() as db:
  70. fields = {
  71. "printer_id": ctx.printer_id,
  72. "filename": "planter.3mf",
  73. "file_path": str(rel),
  74. "file_size": 10,
  75. "content_hash": HASH,
  76. "status": "completed",
  77. "library_file_id": ctx.library_file_id,
  78. "plate_id": 1,
  79. }
  80. row = PrintArchive(**(fields | overrides))
  81. db.add(row)
  82. await db.commit()
  83. if on_disk:
  84. path = tmp_path / rel
  85. path.parent.mkdir(parents=True, exist_ok=True)
  86. path.write_bytes(CONTENT)
  87. return row.id
  88. async def item(**overrides) -> int:
  89. async with session_maker() as db:
  90. fields = {
  91. "printer_id": ctx.printer_id,
  92. "library_file_id": ctx.library_file_id,
  93. "status": "pending",
  94. "plate_id": 2,
  95. }
  96. row = PrintQueueItem(**(fields | overrides))
  97. db.add(row)
  98. await db.commit()
  99. return row.id
  100. ctx.archive = archive
  101. ctx.item = item
  102. with patch.object(scheduler_module.settings, "base_dir", tmp_path):
  103. yield ctx
  104. await engine.dispose()
  105. async def _claim(ctx, scheduler, item_id):
  106. async with ctx.session_maker() as db:
  107. item = await db.get(PrintQueueItem, item_id)
  108. library_file = await db.get(LibraryFile, ctx.library_file_id)
  109. archive = await scheduler._claim_reusable_archive(db, item, Path(library_file.file_path))
  110. return archive.id if archive is not None else None
  111. @pytest.mark.asyncio
  112. async def test_the_files_archive_is_reused(case):
  113. existing = await case.archive()
  114. assert await _claim(case, PrintScheduler(), await case.item()) == existing
  115. @pytest.mark.asyncio
  116. async def test_the_file_is_matched_as_it_is_on_disk(case):
  117. """The stored hash is not refreshed when the file changes in place (an
  118. external folder): a stale one neither finds the old content's archive
  119. nor misses the current one."""
  120. existing = await case.archive()
  121. async with case.session_maker() as db:
  122. (await db.get(LibraryFile, case.library_file_id)).file_hash = "b" * 64
  123. await db.commit()
  124. assert await _claim(case, PrintScheduler(), await case.item()) == existing
  125. @pytest.mark.asyncio
  126. async def test_an_archive_whose_library_file_was_deleted_is_reused(case):
  127. existing = await case.archive(library_file_id=None)
  128. assert await _claim(case, PrintScheduler(), await case.item()) == existing
  129. @pytest.mark.asyncio
  130. async def test_the_newest_archive_is_taken(case):
  131. await case.archive()
  132. newest = await case.archive(tags="newest")
  133. assert await _claim(case, PrintScheduler(), await case.item()) == newest
  134. @pytest.mark.parametrize(
  135. "archive_overrides, item_overrides",
  136. [
  137. pytest.param({"content_hash": "b" * 64}, {}, id="another-file"),
  138. pytest.param({"created_by_id": 7}, {}, id="another-owner"),
  139. pytest.param({}, {"created_by_id": 7}, id="owned-print-of-an-unowned-archive"),
  140. pytest.param({"project_id": 3}, {}, id="another-project"),
  141. pytest.param({}, {"project_id": 3}, id="project-print-of-a-loose-archive"),
  142. pytest.param({"library_file_id": 999}, {}, id="another-library-file"),
  143. pytest.param({"deleted_at": datetime(2026, 10, 1)}, {}, id="deleted"),
  144. pytest.param({"on_disk": False}, {}, id="its-copy-is-gone"),
  145. ],
  146. )
  147. @pytest.mark.asyncio
  148. async def test_no_archive_that_is_not_this_files_or_not_usable(case, archive_overrides, item_overrides):
  149. await case.archive(**archive_overrides)
  150. assert await _claim(case, PrintScheduler(), await case.item(**item_overrides)) is None
  151. @pytest.mark.asyncio
  152. async def test_also_while_another_queue_item_prints_or_waits_on_it(case):
  153. existing = await case.archive()
  154. await case.item(archive_id=existing, library_file_id=None, status="pending")
  155. assert await _claim(case, PrintScheduler(), await case.item()) == existing
  156. @pytest.mark.asyncio
  157. async def test_also_while_one_of_its_prints_runs(case):
  158. existing = await case.archive(status="printing")
  159. main_module._active_prints[(case.printer_id, "planter.3mf")] = existing
  160. assert await _claim(case, PrintScheduler(), await case.item()) == existing
  161. @pytest.mark.asyncio
  162. async def test_a_name_left_from_an_earlier_dispatch_does_not_block_it(case):
  163. """A print start leaves the '<name>.gcode' variant of its registration
  164. behind for two hours; the plate printed next must still join."""
  165. existing = await case.archive()
  166. main_module._expected_prints[(case.printer_id, "planter.gcode")] = existing
  167. assert await _claim(case, PrintScheduler(), await case.item()) == existing
  168. @pytest.mark.asyncio
  169. async def test_two_dispatches_in_flight_share_it(case):
  170. """The same file sent to two printers at once: one archive, two runs."""
  171. existing = await case.archive()
  172. scheduler = PrintScheduler()
  173. first, second = await case.item(), await case.item()
  174. assert await _claim(case, scheduler, first) == existing
  175. assert await _claim(case, scheduler, second) == existing
  176. async def _start(case, item_id, archive_print):
  177. scheduler = PrintScheduler()
  178. patches = [
  179. patch("backend.app.services.archive.ArchiveService.archive_print", new=archive_print),
  180. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  181. patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
  182. patch("backend.app.services.print_scheduler.printer_manager.start_print", MagicMock(return_value=True)),
  183. patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
  184. patch(
  185. "backend.app.services.print_scheduler.get_ftp_retry_settings", AsyncMock(return_value=(False, 0, 0, 1.0))
  186. ),
  187. patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
  188. patch("backend.app.services.print_scheduler.upload_file_async", AsyncMock(return_value=True)),
  189. patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
  190. discarding_spawn_patch(),
  191. patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
  192. patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
  193. patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
  194. patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
  195. patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
  196. ]
  197. with ExitStack() as stack:
  198. for patcher in patches:
  199. stack.enter_context(patcher)
  200. async with case.session_maker() as db:
  201. item = await db.get(PrintQueueItem, item_id)
  202. await scheduler._start_print(db, item)
  203. async with case.session_maker() as db:
  204. item = await db.get(PrintQueueItem, item_id)
  205. return item, (await db.get(PrintArchive, item.archive_id) if item.archive_id else None)
  206. @pytest.mark.asyncio
  207. async def test_the_dispatch_adds_its_run_to_the_files_archive(case):
  208. existing = await case.archive(cost_center_id=5)
  209. archive_print = AsyncMock()
  210. item, archive = await _start(case, await case.item(plate_id=2), archive_print)
  211. archive_print.assert_not_called()
  212. assert item.archive_id == existing
  213. # The latest run's, as a new archive would have them.
  214. assert archive.plate_id == 2
  215. assert archive.cost_center_id is None
  216. @pytest.mark.asyncio
  217. async def test_a_file_never_archived_is_archived(case):
  218. created = {}
  219. async def archive_print(self, **kwargs):
  220. row = PrintArchive(
  221. printer_id=kwargs["printer_id"],
  222. filename="planter.3mf",
  223. file_path="archive/new.3mf",
  224. file_size=10,
  225. content_hash=HASH,
  226. status="archived",
  227. )
  228. self.db.add(row)
  229. await self.db.flush()
  230. created["id"] = row.id
  231. return row
  232. item, _archive = await _start(case, await case.item(), archive_print)
  233. assert item.archive_id == created["id"]
  234. @pytest.mark.asyncio
  235. async def test_a_reprint_of_another_plate_shows_that_plate(case):
  236. """The archive shows its latest run's plate; the first one's stays on its
  237. print log entry."""
  238. existing = await case.archive(plate_id=1)
  239. _item, archive = await _start(
  240. case, await case.item(archive_id=existing, library_file_id=None, plate_id=3), AsyncMock()
  241. )
  242. assert archive.plate_id == 3
  243. @pytest.mark.asyncio
  244. async def test_the_file_sent_to_two_printers_at_once_has_one_archive(case):
  245. """The first copy is printing when the second is sent: both are the
  246. archive's, each printer's print its own run."""
  247. existing = await case.archive()
  248. async with case.session_maker() as db:
  249. other = Printer(name="P1S", serial_number="SERIAL-2", ip_address="127.0.0.2", access_code="code", model="P1S")
  250. db.add(other)
  251. await db.commit()
  252. other_id = other.id
  253. archive_print = AsyncMock()
  254. first, _ = await _start(case, await case.item(), archive_print)
  255. async with case.session_maker() as db:
  256. (await db.get(PrintArchive, existing)).status = "printing"
  257. (await db.get(PrintQueueItem, first.id)).status = "printing"
  258. await db.commit()
  259. main_module._active_prints[(case.printer_id, "planter.3mf")] = existing
  260. second, _ = await _start(case, await case.item(printer_id=other_id), archive_print)
  261. archive_print.assert_not_called()
  262. assert (first.archive_id, second.archive_id) == (existing, existing)
  263. @pytest.mark.asyncio
  264. async def test_while_it_prints_elsewhere_its_plate_and_cost_centre_stay_that_prints(case):
  265. """A print without a queue item falls back to the archive's plate and cost
  266. centre at its end; the new dispatch's run has its own."""
  267. from backend.app.models.print_log import RUN_OPEN, PrintLogEntry
  268. existing = await case.archive(cost_center_id=5, plate_id=1, status="printing")
  269. async with case.session_maker() as db:
  270. db.add(PrintLogEntry(archive_id=existing, printer_id=case.printer_id, status=RUN_OPEN, plate_id=1))
  271. await db.commit()
  272. item, archive = await _start(case, await case.item(plate_id=2), AsyncMock())
  273. assert item.archive_id == existing
  274. assert (archive.plate_id, archive.cost_center_id) == (1, 5)
  275. @pytest.mark.asyncio
  276. async def test_not_an_archive_being_merged(case):
  277. """A merge is moving it into another archive (archive redesign P2)."""
  278. from backend.app.services import archive_work
  279. existing = await case.archive()
  280. with archive_work.merging([existing]):
  281. assert await _claim(case, PrintScheduler(), await case.item()) is None
  282. @pytest.mark.asyncio
  283. async def test_the_chosen_archive_is_held_until_the_dispatch_ends(case):
  284. from backend.app.services import archive_work
  285. existing = await case.archive()
  286. scheduler = PrintScheduler()
  287. item_id = await case.item()
  288. assert await _claim(case, scheduler, item_id) == existing
  289. assert archive_work.is_held(existing)
  290. scheduler._release_held_archive(item_id)
  291. assert not archive_work.is_held(existing)
  292. @pytest.mark.parametrize("fails", [False, True], ids=["sent", "failed"])
  293. @pytest.mark.asyncio
  294. async def test_the_dispatch_releases_its_archive_when_it_ends(fails):
  295. """Sent or failed, a merge may take the archive again; another dispatch's
  296. hold is left alone."""
  297. from backend.app.services import archive_work
  298. scheduler = PrintScheduler()
  299. async def start_print(db, item):
  300. scheduler._hold_archive(597, 298)
  301. if fails:
  302. raise RuntimeError("upload failed")
  303. scheduler._hold_archive(600, 300)
  304. db = MagicMock()
  305. db.get = AsyncMock(return_value=MagicMock(id=597))
  306. session = MagicMock()
  307. session.__aenter__ = AsyncMock(return_value=db)
  308. session.__aexit__ = AsyncMock(return_value=False)
  309. with ExitStack() as stack:
  310. stack.enter_context(patch("backend.app.services.print_scheduler.async_session", return_value=session))
  311. stack.enter_context(patch.object(scheduler, "_claim_for_dispatch", AsyncMock(return_value=True)))
  312. stack.enter_context(patch.object(scheduler, "_start_print", side_effect=start_print))
  313. stack.enter_context(patch.object(scheduler, "_clear_dispatch_claim", AsyncMock()))
  314. if fails:
  315. stack.enter_context(pytest.raises(RuntimeError))
  316. await scheduler._dispatch_one(597)
  317. assert (archive_work.is_held(298), archive_work.is_held(300)) == (False, True)
  318. @pytest.mark.asyncio
  319. async def test_a_queued_print_of_a_merged_archive_prints_the_one_it_went_into(case):
  320. """Queued while the merge ran (archive redesign P2)."""
  321. from backend.app.models.archive_alias import ArchiveAlias
  322. survivor = await case.archive()
  323. merged = await case.archive(deleted_at=datetime(2026, 10, 31), tags="merged")
  324. async with case.session_maker() as db:
  325. db.add(ArchiveAlias(merged_archive_id=merged, archive_id=survivor))
  326. await db.commit()
  327. item, archive = await _start(case, await case.item(archive_id=merged, library_file_id=None), AsyncMock())
  328. assert (item.archive_id, archive.id) == (survivor, survivor)
  329. def test_the_completions_media_work_holds_the_archive():
  330. """Held from the spawn: a merge would move the print's run away."""
  331. from backend.app.services import archive_work
  332. spawned = []
  333. with patch.object(main_module, "spawn_background_task", side_effect=lambda coro, **_: spawned.append(coro)):
  334. main_module._schedule_timelapse_scan(41, 1, plate_id=None, run_id=7)
  335. assert archive_work.is_held(41)
  336. spawned[0].close()