test_library_dispatch_reuses_archive.py 17 KB

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