test_scheduler_cleanup_library.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  1. from contextlib import ExitStack
  2. from pathlib import Path
  3. from types import SimpleNamespace
  4. from unittest.mock import AsyncMock, MagicMock, patch
  5. import pytest
  6. from sqlalchemy import select
  7. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  8. import backend.app.models # noqa: F401 - populate Base.metadata
  9. import backend.app.services.print_scheduler as scheduler_module
  10. from backend.app.core.database import Base
  11. from backend.app.models.archive import PrintArchive
  12. from backend.app.models.library import LibraryFile
  13. from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
  14. from backend.app.models.printer import Printer
  15. from backend.app.services.print_scheduler import PrintScheduler
  16. from backend.tests._fixtures.background_tasks import discarding_spawn_patch
  17. @pytest.fixture
  18. async def queue_factory(tmp_path):
  19. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  20. async with engine.begin() as conn:
  21. await conn.run_sync(Base.metadata.create_all)
  22. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  23. case_counter = 0
  24. async def make_case(*, cleanup=True, is_external=False, thumbnail_path=None, siblings=()):
  25. nonlocal case_counter
  26. case_counter += 1
  27. base_dir = tmp_path / f"case-{case_counter}"
  28. base_dir.mkdir()
  29. source_path = base_dir / "library" / f"source-{case_counter}.3mf"
  30. source_path.parent.mkdir()
  31. source_path.write_bytes(b"library source")
  32. thumbnail_actual_path = None
  33. thumbnail_db_path = None
  34. if thumbnail_path == "relative":
  35. thumbnail_db_path = f"thumbs/preview-{case_counter}.png"
  36. thumbnail_actual_path = base_dir / thumbnail_db_path
  37. elif thumbnail_path == "absolute":
  38. thumbnail_actual_path = tmp_path / f"absolute-preview-{case_counter}.png"
  39. thumbnail_db_path = str(thumbnail_actual_path)
  40. elif thumbnail_path is not None:
  41. thumbnail_actual_path = Path(thumbnail_path)
  42. thumbnail_db_path = str(thumbnail_path)
  43. if thumbnail_actual_path:
  44. thumbnail_actual_path.parent.mkdir(parents=True, exist_ok=True)
  45. thumbnail_actual_path.write_bytes(b"thumbnail")
  46. async with session_maker() as db:
  47. printer = Printer(
  48. name=f"Printer {case_counter}",
  49. serial_number=f"SERIAL-{case_counter}",
  50. ip_address="127.0.0.1",
  51. access_code="access-code",
  52. model="X1C",
  53. )
  54. library_file = LibraryFile(
  55. filename=f"source-{case_counter}.3mf",
  56. file_path=str(source_path),
  57. file_type="3mf",
  58. file_size=source_path.stat().st_size,
  59. file_hash=None,
  60. thumbnail_path=thumbnail_db_path,
  61. file_metadata=None,
  62. is_external=is_external,
  63. )
  64. db.add_all([printer, library_file])
  65. await db.flush()
  66. item = PrintQueueItem(
  67. printer_id=printer.id,
  68. library_file_id=library_file.id,
  69. status="pending",
  70. cleanup_library_after_dispatch=cleanup,
  71. bed_levelling="on",
  72. flow_cali="off",
  73. vibration_cali=True,
  74. layer_inspect=False,
  75. timelapse=False,
  76. use_ams=True,
  77. nozzle_offset_cali="on",
  78. )
  79. db.add(item)
  80. await db.flush()
  81. # The other copies of a quantity>1 dispatch (#2819). Each entry is a
  82. # dict of overrides: `status`, `own_archive` for a copy that already
  83. # holds one, and `extra_variant` for a cross-model copy that keeps a
  84. # candidate this cleanup does not consume.
  85. sibling_ids = []
  86. other_file = None
  87. for spec in siblings:
  88. sibling = PrintQueueItem(
  89. printer_id=printer.id,
  90. library_file_id=None if spec.get("variants") else library_file.id,
  91. status=spec.get("status", "pending"),
  92. cleanup_library_after_dispatch=cleanup,
  93. )
  94. if spec.get("own_archive"):
  95. own = PrintArchive(
  96. printer_id=printer.id,
  97. filename="already-dispatched.3mf",
  98. file_path="archives/already-dispatched.3mf",
  99. file_size=1,
  100. status="printing",
  101. )
  102. db.add(own)
  103. await db.flush()
  104. sibling.archive_id = own.id
  105. db.add(sibling)
  106. await db.flush()
  107. if spec.get("variants"):
  108. db.add(
  109. PrintQueueVariant(
  110. queue_item_id=sibling.id,
  111. library_file_id=library_file.id,
  112. target_model="X1C",
  113. position=0,
  114. )
  115. )
  116. if spec.get("extra_variant"):
  117. if other_file is None:
  118. other_path = base_dir / "library" / f"other-{case_counter}.3mf"
  119. other_path.write_bytes(b"other source")
  120. other_file = LibraryFile(
  121. filename=f"other-{case_counter}.3mf",
  122. file_path=str(other_path),
  123. file_type="3mf",
  124. file_size=other_path.stat().st_size,
  125. )
  126. db.add(other_file)
  127. await db.flush()
  128. db.add(
  129. PrintQueueVariant(
  130. queue_item_id=sibling.id,
  131. library_file_id=other_file.id,
  132. target_model="P1S",
  133. position=1,
  134. )
  135. )
  136. sibling_ids.append(sibling.id)
  137. await db.commit()
  138. return SimpleNamespace(
  139. session_maker=session_maker,
  140. base_dir=base_dir,
  141. source_path=source_path,
  142. thumbnail_path=thumbnail_actual_path,
  143. printer_id=printer.id,
  144. library_file_id=library_file.id,
  145. queue_item_id=item.id,
  146. sibling_ids=sibling_ids,
  147. other_library_file_id=other_file.id if other_file is not None else None,
  148. archive_path=None,
  149. upload=AsyncMock(return_value=True),
  150. start_print=MagicMock(return_value=True),
  151. )
  152. try:
  153. yield make_case
  154. finally:
  155. await engine.dispose()
  156. async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effect=None):
  157. scheduler = PrintScheduler()
  158. async def archive_print(
  159. self,
  160. *,
  161. printer_id,
  162. source_file,
  163. original_filename,
  164. created_by_id=None,
  165. project_id=None,
  166. cost_center_id=None,
  167. plate_id=None,
  168. library_file_id=None,
  169. ):
  170. if archive_failure:
  171. raise RuntimeError("archive copy failed")
  172. archive_rel_path = Path("archives") / f"archive-{ctx.queue_item_id}.3mf"
  173. ctx.archive_path = ctx.base_dir / archive_rel_path
  174. ctx.archive_path.parent.mkdir(parents=True, exist_ok=True)
  175. ctx.archive_path.write_bytes(Path(source_file).read_bytes())
  176. archive = PrintArchive(
  177. printer_id=printer_id,
  178. filename=original_filename,
  179. file_path=str(archive_rel_path),
  180. file_size=ctx.archive_path.stat().st_size,
  181. content_hash=None,
  182. thumbnail_path=None,
  183. timelapse_path=None,
  184. print_time_seconds=120,
  185. status="completed",
  186. project_id=project_id,
  187. library_file_id=library_file_id,
  188. created_by_id=created_by_id,
  189. )
  190. self.db.add(archive)
  191. await self.db.flush()
  192. return archive
  193. patches = [
  194. patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
  195. patch("backend.app.services.archive.ArchiveService.archive_print", new=archive_print),
  196. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  197. patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
  198. patch("backend.app.services.print_scheduler.printer_manager.start_print", ctx.start_print),
  199. patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
  200. patch(
  201. "backend.app.services.print_scheduler.get_ftp_retry_settings", AsyncMock(return_value=(False, 0, 0, 1.0))
  202. ),
  203. patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
  204. patch("backend.app.services.print_scheduler.upload_file_async", ctx.upload),
  205. patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
  206. discarding_spawn_patch(),
  207. patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
  208. patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
  209. patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
  210. patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
  211. patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
  212. ]
  213. if unlink_side_effect:
  214. patches.append(patch.object(type(ctx.source_path), "unlink", unlink_side_effect))
  215. with ExitStack() as stack:
  216. for patcher in patches:
  217. stack.enter_context(patcher)
  218. async with ctx.session_maker() as db:
  219. item = await db.get(PrintQueueItem, ctx.queue_item_id)
  220. await scheduler._start_print(db, item)
  221. async def _queue_snapshot(ctx):
  222. async with ctx.session_maker() as db:
  223. item = await db.get(PrintQueueItem, ctx.queue_item_id)
  224. library_file = await db.get(LibraryFile, ctx.library_file_id)
  225. archive = await db.get(PrintArchive, item.archive_id) if item.archive_id else None
  226. return item, library_file, archive
  227. @pytest.mark.asyncio
  228. async def test_cleanup_unlinks_library_file_and_removes_db_row(queue_factory):
  229. ctx = await queue_factory(cleanup=True)
  230. await _dispatch_library_item(ctx)
  231. item, library_file, archive = await _queue_snapshot(ctx)
  232. assert item.status == "printing"
  233. assert item.library_file_id is None
  234. assert item.archive_id == archive.id
  235. assert library_file is None
  236. assert not ctx.source_path.exists()
  237. @pytest.mark.asyncio
  238. async def test_external_library_file_skips_cleanup(queue_factory):
  239. ctx = await queue_factory(cleanup=True, is_external=True)
  240. await _dispatch_library_item(ctx)
  241. item, library_file, archive = await _queue_snapshot(ctx)
  242. assert item.status == "printing"
  243. assert item.library_file_id == ctx.library_file_id
  244. assert item.archive_id == archive.id
  245. assert library_file is not None
  246. assert ctx.source_path.exists()
  247. @pytest.mark.asyncio
  248. async def test_archive_creation_failure_skips_cleanup_and_dispatch(queue_factory):
  249. ctx = await queue_factory(cleanup=True, thumbnail_path="relative")
  250. await _dispatch_library_item(ctx, archive_failure=True)
  251. item, library_file, archive = await _queue_snapshot(ctx)
  252. assert item.status == "failed"
  253. assert item.error_message == "Failed to create archive from library file"
  254. assert item.archive_id is None
  255. assert archive is None
  256. assert library_file is not None
  257. assert ctx.source_path.exists()
  258. assert ctx.thumbnail_path.exists()
  259. ctx.upload.assert_not_awaited()
  260. ctx.start_print.assert_not_called()
  261. @pytest.mark.parametrize("thumbnail_path", ["absolute", "relative"])
  262. @pytest.mark.asyncio
  263. async def test_cleanup_resolves_absolute_and_relative_thumbnail_paths(queue_factory, thumbnail_path):
  264. ctx = await queue_factory(cleanup=True, thumbnail_path=thumbnail_path)
  265. await _dispatch_library_item(ctx)
  266. item, library_file, archive = await _queue_snapshot(ctx)
  267. assert item.status == "printing"
  268. assert item.archive_id == archive.id
  269. assert library_file is None
  270. assert not ctx.source_path.exists()
  271. assert not ctx.thumbnail_path.exists()
  272. @pytest.mark.asyncio
  273. async def test_archive_copy_survives_library_cleanup(queue_factory):
  274. ctx = await queue_factory(cleanup=True)
  275. await _dispatch_library_item(ctx)
  276. assert not ctx.source_path.exists()
  277. assert ctx.archive_path.exists()
  278. assert ctx.archive_path.read_bytes() == b"library source"
  279. uploaded_path = ctx.upload.await_args.args[2]
  280. assert uploaded_path == ctx.archive_path
  281. async def _sibling_snapshot(ctx):
  282. async with ctx.session_maker() as db:
  283. return [await db.get(PrintQueueItem, sid) for sid in ctx.sibling_ids]
  284. async def _variant_files(ctx, sibling_id):
  285. async with ctx.session_maker() as db:
  286. rows = await db.execute(
  287. select(PrintQueueVariant.library_file_id).where(PrintQueueVariant.queue_item_id == sibling_id)
  288. )
  289. return sorted(rows.scalars().all())
  290. # ---------------------------------------------------------------------------
  291. # Sibling copies of the same library row (#2819)
  292. #
  293. # `quantity > 1` on the printer-card upload-and-print flow puts the cleanup flag
  294. # on every copy, and batch clones inherit `library_file_id`. Consuming the row
  295. # for the first copy used to leave the others pointing at it, which failed with
  296. # "Library file not found" on SQLite and deleted the rows outright on
  297. # PostgreSQL, where the FK cascade is enforced.
  298. #
  299. # These run on SQLite, so they cover the orphan half directly. The cascade half
  300. # was verified by hand against a real PostgreSQL 16, building this same fixture
  301. # on both backends and comparing every row: without the fix the copies were gone
  302. # after the delete -- including the finished ones a batch order counts its
  303. # progress from, and a copy already printing from its own archive. With it, the
  304. # two backends agree row for row. `print_archives.library_file_id` is SET NULL,
  305. # so it is cleared by the same delete, which is why looking the archive up by
  306. # the consumed library id -- the obvious alternative fix -- cannot work there.
  307. # ---------------------------------------------------------------------------
  308. @pytest.mark.asyncio
  309. async def test_pending_copies_are_repointed_at_the_archive(queue_factory):
  310. ctx = await queue_factory(cleanup=True, siblings=({}, {}))
  311. await _dispatch_library_item(ctx)
  312. item, library_file, archive = await _queue_snapshot(ctx)
  313. assert library_file is None
  314. for sibling in await _sibling_snapshot(ctx):
  315. # Still queued -- the point is that they can now run, not that they run now.
  316. assert sibling.status == "pending"
  317. assert sibling.archive_id == archive.id
  318. assert sibling.library_file_id is None
  319. # Their file is already consumed; leaving this armed would delete
  320. # whatever library row they were next given.
  321. assert sibling.cleanup_library_after_dispatch is False
  322. @pytest.mark.asyncio
  323. async def test_copy_that_already_has_its_own_archive_keeps_it(queue_factory):
  324. ctx = await queue_factory(cleanup=True, siblings=({"own_archive": True, "status": "printing"},))
  325. await _dispatch_library_item(ctx)
  326. _, _, archive = await _queue_snapshot(ctx)
  327. (sibling,) = await _sibling_snapshot(ctx)
  328. # It is mid-print from its own archive and does not need the library file.
  329. # Re-pointing it would swap the file under a job already running.
  330. assert sibling.archive_id != archive.id
  331. assert sibling.status == "printing"
  332. # Cleared all the same: on PostgreSQL a row still naming the file goes with
  333. # it, and this one is a job that is currently printing.
  334. assert sibling.library_file_id is None
  335. @pytest.mark.parametrize("status", ["completed", "failed", "cancelled", "aborted"])
  336. @pytest.mark.asyncio
  337. async def test_finished_copies_keep_their_outcome_and_survive_the_delete(queue_factory, status):
  338. ctx = await queue_factory(cleanup=True, siblings=({"status": status},))
  339. await _dispatch_library_item(ctx)
  340. (sibling,) = await _sibling_snapshot(ctx)
  341. # A finished row is a record of what happened, not a spare part -- it keeps
  342. # its outcome and is not handed the archive.
  343. assert sibling.status == status
  344. assert sibling.archive_id is None
  345. # But the reference has to go: it is the only thing tying the row to the
  346. # cascade that would otherwise delete it, and a batch order counts its
  347. # progress from rows exactly like this one.
  348. assert sibling.library_file_id is None
  349. @pytest.mark.asyncio
  350. async def test_skipped_copy_is_repointed_because_it_can_come_back(queue_factory):
  351. ctx = await queue_factory(cleanup=True, siblings=({"status": "skipped"},))
  352. await _dispatch_library_item(ctx)
  353. _, _, archive = await _queue_snapshot(ctx)
  354. (sibling,) = await _sibling_snapshot(ctx)
  355. # Clearing the printer's previous-success gate puts skipped items back to
  356. # pending, so this one is only waiting -- not finished.
  357. assert sibling.status == "skipped"
  358. assert sibling.archive_id == archive.id
  359. assert sibling.library_file_id is None
  360. @pytest.mark.asyncio
  361. async def test_copies_are_untouched_when_the_dispatch_does_not_consume_the_file(queue_factory):
  362. ctx = await queue_factory(cleanup=False, siblings=({},))
  363. await _dispatch_library_item(ctx)
  364. item, library_file, _ = await _queue_snapshot(ctx)
  365. assert library_file is not None
  366. (sibling,) = await _sibling_snapshot(ctx)
  367. assert sibling.library_file_id == ctx.library_file_id
  368. assert sibling.archive_id is None
  369. @pytest.mark.asyncio
  370. async def test_cross_model_copy_keeps_its_other_candidate_instead_of_the_archive(queue_factory):
  371. ctx = await queue_factory(cleanup=True, siblings=({"variants": True, "extra_variant": True},))
  372. await _dispatch_library_item(ctx)
  373. (sibling,) = await _sibling_snapshot(ctx)
  374. # It still has somewhere to go, and that candidate carries its own target
  375. # model -- pointing it at this archive would print a file the matcher never
  376. # chose.
  377. assert sibling.archive_id is None
  378. assert sibling.library_file_id is None
  379. assert await _variant_files(ctx, sibling.id) == [ctx.other_library_file_id]
  380. @pytest.mark.asyncio
  381. async def test_copy_whose_only_candidate_was_consumed_is_repointed(queue_factory):
  382. ctx = await queue_factory(cleanup=True, siblings=({"variants": True},))
  383. await _dispatch_library_item(ctx)
  384. _, _, archive = await _queue_snapshot(ctx)
  385. (sibling,) = await _sibling_snapshot(ctx)
  386. # Its one candidate is gone. Without the re-point the resolver would hold it
  387. # pending forever with nothing left to dispatch.
  388. assert sibling.archive_id == archive.id
  389. assert sibling.library_file_id is None
  390. assert await _variant_files(ctx, sibling.id) == []
  391. @pytest.mark.asyncio
  392. async def test_oserror_during_unlink_logs_orphan_path_and_does_not_crash_dispatch(queue_factory, caplog):
  393. ctx = await queue_factory(cleanup=True, thumbnail_path="relative")
  394. original_unlink = type(ctx.source_path).unlink
  395. def unlink_with_source_failure(path, *args, **kwargs):
  396. if Path(path) == ctx.source_path:
  397. raise OSError("permission denied")
  398. return original_unlink(path, *args, **kwargs)
  399. with caplog.at_level("WARNING", logger="backend.app.services.print_scheduler"):
  400. await _dispatch_library_item(ctx, unlink_side_effect=unlink_with_source_failure)
  401. item, library_file, archive = await _queue_snapshot(ctx)
  402. assert item.status == "printing"
  403. assert item.archive_id == archive.id
  404. assert item.library_file_id is None
  405. assert library_file is None
  406. assert ctx.source_path.exists()
  407. assert not ctx.thumbnail_path.exists()
  408. assert ctx.archive_path.exists()
  409. assert "TRANSIENT_LIBRARY_FILE_ORPHAN" in caplog.text
  410. assert str(ctx.source_path) in caplog.text
  411. assert "permission denied" in caplog.text