test_scheduler_cleanup_library.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811
  1. import json
  2. import logging
  3. from contextlib import ExitStack
  4. from pathlib import Path
  5. from types import SimpleNamespace
  6. from unittest.mock import AsyncMock, MagicMock, patch
  7. import pytest
  8. from sqlalchemy import event, select
  9. from sqlalchemy.exc import OperationalError
  10. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  11. import backend.app.models # noqa: F401 - populate Base.metadata
  12. import backend.app.services.print_scheduler as scheduler_module
  13. from backend.app.core.database import Base
  14. from backend.app.models.archive import PrintArchive
  15. from backend.app.models.library import LibraryFile
  16. from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
  17. from backend.app.models.printer import Printer
  18. from backend.app.services.print_scheduler import PrintScheduler
  19. from backend.app.utils.archive_paths import archive_photos_dir
  20. from backend.app.utils.library_paths import library_photos_dir
  21. from backend.tests._fixtures.background_tasks import discarding_spawn_patch
  22. @pytest.fixture
  23. async def queue_factory(tmp_path):
  24. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  25. async with engine.begin() as conn:
  26. await conn.run_sync(Base.metadata.create_all)
  27. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  28. case_counter = 0
  29. async def make_case(
  30. *,
  31. cleanup=True,
  32. is_external=False,
  33. thumbnail_path=None,
  34. siblings=(),
  35. photos=(),
  36. source_type=None,
  37. slicer_ams_mapping=None,
  38. ):
  39. nonlocal case_counter
  40. case_counter += 1
  41. base_dir = tmp_path / f"case-{case_counter}"
  42. base_dir.mkdir()
  43. archive_dir = base_dir / "archive"
  44. source_path = base_dir / "library" / f"source-{case_counter}.3mf"
  45. source_path.parent.mkdir()
  46. source_path.write_bytes(b"library source")
  47. thumbnail_actual_path = None
  48. thumbnail_db_path = None
  49. if thumbnail_path == "relative":
  50. thumbnail_db_path = f"thumbs/preview-{case_counter}.png"
  51. thumbnail_actual_path = base_dir / thumbnail_db_path
  52. elif thumbnail_path == "absolute":
  53. thumbnail_actual_path = tmp_path / f"absolute-preview-{case_counter}.png"
  54. thumbnail_db_path = str(thumbnail_actual_path)
  55. elif thumbnail_path is not None:
  56. thumbnail_actual_path = Path(thumbnail_path)
  57. thumbnail_db_path = str(thumbnail_path)
  58. if thumbnail_actual_path:
  59. thumbnail_actual_path.parent.mkdir(parents=True, exist_ok=True)
  60. thumbnail_actual_path.write_bytes(b"thumbnail")
  61. async with session_maker() as db:
  62. printer = Printer(
  63. name=f"Printer {case_counter}",
  64. serial_number=f"SERIAL-{case_counter}",
  65. ip_address="127.0.0.1",
  66. access_code="access-code",
  67. model="X1C",
  68. )
  69. library_file = LibraryFile(
  70. filename=f"source-{case_counter}.3mf",
  71. file_path=str(source_path),
  72. file_type="3mf",
  73. file_size=source_path.stat().st_size,
  74. file_hash=None,
  75. thumbnail_path=thumbnail_db_path,
  76. file_metadata=None,
  77. is_external=is_external,
  78. photos=list(photos) or None,
  79. source_type=source_type,
  80. )
  81. db.add_all([printer, library_file])
  82. await db.flush()
  83. # Photos of the printed result (#3077). Written through the real
  84. # helper so the test cannot drift from the layout the code uses.
  85. photos_dir = None
  86. if photos:
  87. with patch.object(scheduler_module.settings, "archive_dir", archive_dir):
  88. photos_dir = library_photos_dir(library_file.id)
  89. photos_dir.mkdir(parents=True, exist_ok=True)
  90. for name in photos:
  91. (photos_dir / name).write_bytes(f"photo {name}".encode())
  92. item = PrintQueueItem(
  93. printer_id=printer.id,
  94. library_file_id=library_file.id,
  95. status="pending",
  96. cleanup_library_after_dispatch=cleanup,
  97. bed_levelling="on",
  98. flow_cali="off",
  99. vibration_cali=True,
  100. layer_inspect=False,
  101. timelapse=False,
  102. use_ams=True,
  103. nozzle_offset_cali="on",
  104. # A callable gets the printer's id (only known here).
  105. slicer_ams_mapping=(
  106. slicer_ams_mapping(printer.id) if callable(slicer_ams_mapping) else slicer_ams_mapping
  107. ),
  108. )
  109. db.add(item)
  110. await db.flush()
  111. # The other copies of a quantity>1 dispatch (#2819). Each entry is a
  112. # dict of overrides: `status`, `own_archive` for a copy that already
  113. # holds one, and `extra_variant` for a cross-model copy that keeps a
  114. # candidate this cleanup does not consume.
  115. sibling_ids = []
  116. other_file = None
  117. for spec in siblings:
  118. sibling = PrintQueueItem(
  119. printer_id=printer.id,
  120. library_file_id=None if spec.get("variants") else library_file.id,
  121. status=spec.get("status", "pending"),
  122. cleanup_library_after_dispatch=cleanup,
  123. )
  124. if spec.get("own_archive"):
  125. own = PrintArchive(
  126. printer_id=printer.id,
  127. filename="already-dispatched.3mf",
  128. file_path="archives/already-dispatched.3mf",
  129. file_size=1,
  130. status="printing",
  131. )
  132. db.add(own)
  133. await db.flush()
  134. sibling.archive_id = own.id
  135. db.add(sibling)
  136. await db.flush()
  137. if spec.get("variants"):
  138. db.add(
  139. PrintQueueVariant(
  140. queue_item_id=sibling.id,
  141. library_file_id=library_file.id,
  142. target_model="X1C",
  143. position=0,
  144. )
  145. )
  146. if spec.get("extra_variant"):
  147. if other_file is None:
  148. other_path = base_dir / "library" / f"other-{case_counter}.3mf"
  149. other_path.write_bytes(b"other source")
  150. other_file = LibraryFile(
  151. filename=f"other-{case_counter}.3mf",
  152. file_path=str(other_path),
  153. file_type="3mf",
  154. file_size=other_path.stat().st_size,
  155. )
  156. db.add(other_file)
  157. await db.flush()
  158. db.add(
  159. PrintQueueVariant(
  160. queue_item_id=sibling.id,
  161. library_file_id=other_file.id,
  162. target_model="P1S",
  163. position=1,
  164. )
  165. )
  166. sibling_ids.append(sibling.id)
  167. await db.commit()
  168. return SimpleNamespace(
  169. session_maker=session_maker,
  170. base_dir=base_dir,
  171. archive_dir=archive_dir,
  172. source_path=source_path,
  173. photos_dir=photos_dir,
  174. photo_names=list(photos),
  175. thumbnail_path=thumbnail_actual_path,
  176. printer_id=printer.id,
  177. library_file_id=library_file.id,
  178. queue_item_id=item.id,
  179. sibling_ids=sibling_ids,
  180. other_library_file_id=other_file.id if other_file is not None else None,
  181. archive_path=None,
  182. archive_kwargs=None,
  183. upload=AsyncMock(return_value=True),
  184. start_print=MagicMock(return_value=True),
  185. )
  186. try:
  187. yield make_case
  188. finally:
  189. await engine.dispose()
  190. async def _dispatch_library_item(
  191. ctx,
  192. *,
  193. archive_failure=False,
  194. unlink_side_effect=None,
  195. cleanup_commit_failure=False,
  196. photo_commit_failure=False,
  197. ):
  198. scheduler = PrintScheduler()
  199. async def archive_print(
  200. self,
  201. *,
  202. printer_id,
  203. source_file,
  204. original_filename,
  205. created_by_id=None,
  206. project_id=None,
  207. cost_center_id=None,
  208. plate_id=None,
  209. library_file_id=None,
  210. **extra,
  211. ):
  212. ctx.archive_kwargs = extra
  213. if archive_failure:
  214. raise RuntimeError("archive copy failed")
  215. archive_rel_path = Path("archives") / f"archive-{ctx.queue_item_id}.3mf"
  216. ctx.archive_path = ctx.base_dir / archive_rel_path
  217. ctx.archive_path.parent.mkdir(parents=True, exist_ok=True)
  218. ctx.archive_path.write_bytes(Path(source_file).read_bytes())
  219. archive = PrintArchive(
  220. printer_id=printer_id,
  221. filename=original_filename,
  222. file_path=str(archive_rel_path),
  223. file_size=ctx.archive_path.stat().st_size,
  224. content_hash=None,
  225. thumbnail_path=None,
  226. timelapse_path=None,
  227. print_time_seconds=120,
  228. status="completed",
  229. project_id=project_id,
  230. library_file_id=library_file_id,
  231. created_by_id=created_by_id,
  232. )
  233. self.db.add(archive)
  234. await self.db.flush()
  235. return archive
  236. patches = [
  237. patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
  238. patch.object(scheduler_module.settings, "archive_dir", ctx.archive_dir),
  239. patch("backend.app.services.archive.ArchiveService.archive_print", new=archive_print),
  240. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  241. patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
  242. patch("backend.app.services.print_scheduler.printer_manager.start_print", ctx.start_print),
  243. patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
  244. patch(
  245. "backend.app.services.print_scheduler.get_ftp_retry_settings", AsyncMock(return_value=(False, 0, 0, 1.0))
  246. ),
  247. patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
  248. patch("backend.app.services.print_scheduler.upload_file_async", ctx.upload),
  249. patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
  250. discarding_spawn_patch(),
  251. patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
  252. patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
  253. patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
  254. patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
  255. patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
  256. ]
  257. if unlink_side_effect:
  258. patches.append(patch.object(type(ctx.source_path), "unlink", unlink_side_effect))
  259. with ExitStack() as stack:
  260. for patcher in patches:
  261. stack.enter_context(patcher)
  262. async with ctx.session_maker() as db:
  263. if cleanup_commit_failure:
  264. _arm_commit_failure_on_library_delete(db)
  265. if photo_commit_failure:
  266. _arm_commit_failure_on_photo_move(db, stack)
  267. item = await db.get(PrintQueueItem, ctx.queue_item_id)
  268. await scheduler._start_print(db, item)
  269. def _fail_inside_the_next_flush(db, statement):
  270. """Make the next flush on this session fail, once, the way SQLite does.
  271. Raising *instead of* calling `db.commit()` does not reproduce a failed
  272. commit and is not the dangerous case: the session stays ACTIVE, nothing is
  273. expired, and every loaded instance still reads out of `__dict__`. What a
  274. busy writer actually gives you is a statement error raised inside the
  275. flush — SQLite takes the write lock at the first DML statement, not at
  276. COMMIT, so "database is locked" surfaces there (#1853). SQLAlchemy rolls
  277. that back internally through `safe_reraise` before re-raising, which
  278. expires every loaded instance and leaves the session in pending-rollback
  279. state: the next ORM attribute read raises PendingRollbackError, *before*
  280. the handler's own rollback can run. That is the state a handler on this
  281. path has to survive, so it is the state these tests have to produce.
  282. """
  283. sync_session = db.sync_session
  284. fired = False
  285. def after_flush(session, flush_context):
  286. nonlocal fired
  287. if fired:
  288. return
  289. fired = True
  290. raise OperationalError(statement, {}, Exception("database is locked"))
  291. event.listen(sync_session, "after_flush", after_flush)
  292. def _arm_commit_failure_on_library_delete(db):
  293. """Make the one commit that removes the library row fail, once.
  294. Stands in for the "database is locked" cascades the commit's own comment
  295. cites (#1853). Armed by the delete rather than by a call count so it
  296. cannot drift onto a different commit.
  297. """
  298. original_delete = db.delete
  299. original_commit = db.commit
  300. armed = False
  301. async def delete(obj):
  302. nonlocal armed
  303. if isinstance(obj, LibraryFile):
  304. armed = True
  305. return await original_delete(obj)
  306. async def commit():
  307. nonlocal armed
  308. if armed:
  309. armed = False
  310. _fail_inside_the_next_flush(db, "DELETE FROM library_files WHERE library_files.id = ?")
  311. return await original_commit()
  312. db.delete = delete
  313. db.commit = commit
  314. def _arm_commit_failure_on_photo_move(db, stack):
  315. """Make the commit that records the carried photos fail, once.
  316. The second commit of this path (#3077): the archive and the delete are
  317. already committed, the pictures are already on disk under the archive,
  318. and only `archive.photos` is pending. Armed by the move itself so it
  319. cannot drift onto the delete's commit.
  320. """
  321. original_commit = db.commit
  322. original_move = scheduler_module.move_library_photos
  323. armed = False
  324. def move_library_photos(file_id, photos, destination):
  325. nonlocal armed
  326. carried = original_move(file_id, photos, destination)
  327. armed = bool(carried)
  328. return carried
  329. async def commit():
  330. nonlocal armed
  331. if armed:
  332. armed = False
  333. _fail_inside_the_next_flush(db, "UPDATE print_archives SET photos=? WHERE print_archives.id = ?")
  334. return await original_commit()
  335. stack.enter_context(patch.object(scheduler_module, "move_library_photos", move_library_photos))
  336. db.commit = commit
  337. async def _queue_snapshot(ctx):
  338. async with ctx.session_maker() as db:
  339. item = await db.get(PrintQueueItem, ctx.queue_item_id)
  340. library_file = await db.get(LibraryFile, ctx.library_file_id)
  341. archive = await db.get(PrintArchive, item.archive_id) if item.archive_id else None
  342. return item, library_file, archive
  343. @pytest.mark.asyncio
  344. async def test_cleanup_unlinks_library_file_and_removes_db_row(queue_factory):
  345. ctx = await queue_factory(cleanup=True)
  346. await _dispatch_library_item(ctx)
  347. item, library_file, archive = await _queue_snapshot(ctx)
  348. assert item.status == "printing"
  349. assert item.library_file_id is None
  350. assert item.archive_id == archive.id
  351. assert library_file is None
  352. assert not ctx.source_path.exists()
  353. @pytest.mark.asyncio
  354. async def test_external_library_file_skips_cleanup(queue_factory):
  355. ctx = await queue_factory(cleanup=True, is_external=True)
  356. await _dispatch_library_item(ctx)
  357. item, library_file, archive = await _queue_snapshot(ctx)
  358. assert item.status == "printing"
  359. assert item.library_file_id == ctx.library_file_id
  360. assert item.archive_id == archive.id
  361. assert library_file is not None
  362. assert ctx.source_path.exists()
  363. @pytest.mark.asyncio
  364. async def test_cleanup_moves_the_photos_into_the_archive(queue_factory):
  365. """Photos follow the consumed file into the archive that replaces it (#3077).
  366. The row is hard-deleted here, so leaving the photo directory alone
  367. orphaned it under an id nothing points at any more — and the pictures
  368. of a print that still has a record disappeared from the UI.
  369. """
  370. ctx = await queue_factory(cleanup=True, photos=["a1b2c3d4.jpg", "e5f6a7b8.png"])
  371. await _dispatch_library_item(ctx)
  372. _, library_file, archive = await _queue_snapshot(ctx)
  373. assert library_file is None
  374. assert not ctx.photos_dir.exists()
  375. assert archive.photos == ctx.photo_names
  376. with patch.object(scheduler_module.settings, "base_dir", ctx.base_dir):
  377. destination = archive_photos_dir(archive)
  378. for name in ctx.photo_names:
  379. assert (destination / name).read_bytes() == f"photo {name}".encode()
  380. @pytest.mark.asyncio
  381. async def test_external_library_file_keeps_its_photos(queue_factory):
  382. ctx = await queue_factory(cleanup=True, is_external=True, photos=["a1b2c3d4.jpg"])
  383. await _dispatch_library_item(ctx)
  384. _, library_file, archive = await _queue_snapshot(ctx)
  385. assert library_file is not None
  386. assert (ctx.photos_dir / "a1b2c3d4.jpg").is_file()
  387. assert archive.photos is None
  388. @pytest.mark.asyncio
  389. async def test_archive_creation_failure_keeps_the_photos(queue_factory):
  390. ctx = await queue_factory(cleanup=True, photos=["a1b2c3d4.jpg"])
  391. await _dispatch_library_item(ctx, archive_failure=True)
  392. _, library_file, archive = await _queue_snapshot(ctx)
  393. assert archive is None
  394. assert library_file is not None
  395. assert (ctx.photos_dir / "a1b2c3d4.jpg").is_file()
  396. @pytest.mark.asyncio
  397. async def test_cleanup_commit_failure_keeps_the_photos_with_the_library_file(queue_factory):
  398. """The photos move after the delete commits, not before it (#3077).
  399. The commit that removes the library row can fail; the except branch rolls
  400. it back and the file is in the library again. Photos moved ahead of that
  401. commit would be gone from under it — the row would name a directory that
  402. no longer exists, and the pictures would sit under an archive that was
  403. rolled back too.
  404. """
  405. ctx = await queue_factory(cleanup=True, photos=["a1b2c3d4.jpg"])
  406. await _dispatch_library_item(ctx, cleanup_commit_failure=True)
  407. item, library_file, archive = await _queue_snapshot(ctx)
  408. assert item.status == "failed"
  409. assert archive is None
  410. assert library_file is not None
  411. assert library_file.photos == ["a1b2c3d4.jpg"]
  412. assert (ctx.photos_dir / "a1b2c3d4.jpg").read_bytes() == b"photo a1b2c3d4.jpg"
  413. assert not (ctx.base_dir / "archives" / "photos").exists()
  414. @pytest.mark.asyncio
  415. async def test_photo_commit_failure_still_dispatches_the_print(queue_factory, caplog):
  416. """A failed photos commit must not take the dispatch down with it (#3077).
  417. The archive and the delete are committed by then, so the print goes ahead
  418. and the pictures sit unnamed under the archive.
  419. The commit fails inside the flush, which is where a locked SQLite fails —
  420. see `_fail_inside_the_next_flush`. That expires every loaded instance
  421. twice over: once by SQLAlchemy's internal rollback, before the handler
  422. runs at all, and again at the handler's own `rollback()`. So the handler
  423. may not read an ORM attribute on either side of that rollback. Before it,
  424. a read raises PendingRollbackError; after it, MissingGreenlet — the nozzle
  425. guard's `archive.nozzle_diameter` and the upload's `printer.name` are the
  426. ones that used to die.
  427. """
  428. ctx = await queue_factory(cleanup=True, photos=["a1b2c3d4.jpg"])
  429. with caplog.at_level(logging.WARNING, logger="backend.app.services.print_scheduler"):
  430. await _dispatch_library_item(ctx, photo_commit_failure=True)
  431. item, library_file, archive = await _queue_snapshot(ctx)
  432. # The handler absorbed it rather than the failure being skipped: it names
  433. # the queue item and the archive from ints it held before the commit.
  434. assert any(
  435. f"Queue item {ctx.queue_item_id}: failed to carry library photos into archive {item.archive_id}"
  436. in record.message
  437. for record in caplog.records
  438. )
  439. assert item.status == "printing"
  440. assert item.archive_id == archive.id
  441. assert library_file is None
  442. assert not archive.photos
  443. ctx.upload.assert_awaited()
  444. ctx.start_print.assert_called()
  445. with patch.object(scheduler_module.settings, "base_dir", ctx.base_dir):
  446. destination = archive_photos_dir(archive)
  447. assert (destination / "a1b2c3d4.jpg").read_bytes() == b"photo a1b2c3d4.jpg"
  448. @pytest.mark.asyncio
  449. async def test_archive_creation_failure_skips_cleanup_and_dispatch(queue_factory):
  450. ctx = await queue_factory(cleanup=True, thumbnail_path="relative")
  451. await _dispatch_library_item(ctx, archive_failure=True)
  452. item, library_file, archive = await _queue_snapshot(ctx)
  453. assert item.status == "failed"
  454. assert item.error_message == "Failed to create archive from library file"
  455. assert item.archive_id is None
  456. assert archive is None
  457. assert library_file is not None
  458. assert ctx.source_path.exists()
  459. assert ctx.thumbnail_path.exists()
  460. ctx.upload.assert_not_awaited()
  461. ctx.start_print.assert_not_called()
  462. @pytest.mark.parametrize("thumbnail_path", ["absolute", "relative"])
  463. @pytest.mark.asyncio
  464. async def test_cleanup_resolves_absolute_and_relative_thumbnail_paths(queue_factory, thumbnail_path):
  465. ctx = await queue_factory(cleanup=True, thumbnail_path=thumbnail_path)
  466. await _dispatch_library_item(ctx)
  467. item, library_file, archive = await _queue_snapshot(ctx)
  468. assert item.status == "printing"
  469. assert item.archive_id == archive.id
  470. assert library_file is None
  471. assert not ctx.source_path.exists()
  472. assert not ctx.thumbnail_path.exists()
  473. @pytest.mark.asyncio
  474. async def test_archive_copy_survives_library_cleanup(queue_factory):
  475. ctx = await queue_factory(cleanup=True)
  476. await _dispatch_library_item(ctx)
  477. assert not ctx.source_path.exists()
  478. assert ctx.archive_path.exists()
  479. assert ctx.archive_path.read_bytes() == b"library source"
  480. uploaded_path = ctx.upload.await_args.args[2]
  481. assert uploaded_path == ctx.archive_path
  482. async def _sibling_snapshot(ctx):
  483. async with ctx.session_maker() as db:
  484. return [await db.get(PrintQueueItem, sid) for sid in ctx.sibling_ids]
  485. async def _variant_files(ctx, sibling_id):
  486. async with ctx.session_maker() as db:
  487. rows = await db.execute(
  488. select(PrintQueueVariant.library_file_id).where(PrintQueueVariant.queue_item_id == sibling_id)
  489. )
  490. return sorted(rows.scalars().all())
  491. # ---------------------------------------------------------------------------
  492. # Sibling copies of the same library row (#2819)
  493. #
  494. # `quantity > 1` on the printer-card upload-and-print flow puts the cleanup flag
  495. # on every copy, and batch clones inherit `library_file_id`. Consuming the row
  496. # for the first copy used to leave the others pointing at it, which failed with
  497. # "Library file not found" on SQLite and deleted the rows outright on
  498. # PostgreSQL, where the FK cascade is enforced.
  499. #
  500. # These run on SQLite, so they cover the orphan half directly. The cascade half
  501. # was verified by hand against a real PostgreSQL 16, building this same fixture
  502. # on both backends and comparing every row: without the fix the copies were gone
  503. # after the delete -- including the finished ones a batch order counts its
  504. # progress from, and a copy already printing from its own archive. With it, the
  505. # two backends agree row for row. `print_archives.library_file_id` is SET NULL,
  506. # so it is cleared by the same delete, which is why looking the archive up by
  507. # the consumed library id -- the obvious alternative fix -- cannot work there.
  508. # ---------------------------------------------------------------------------
  509. @pytest.mark.asyncio
  510. async def test_pending_copies_are_repointed_at_the_archive(queue_factory):
  511. ctx = await queue_factory(cleanup=True, siblings=({}, {}))
  512. await _dispatch_library_item(ctx)
  513. item, library_file, archive = await _queue_snapshot(ctx)
  514. assert library_file is None
  515. for sibling in await _sibling_snapshot(ctx):
  516. # Still queued -- the point is that they can now run, not that they run now.
  517. assert sibling.status == "pending"
  518. assert sibling.archive_id == archive.id
  519. assert sibling.library_file_id is None
  520. # Their file is already consumed; leaving this armed would delete
  521. # whatever library row they were next given.
  522. assert sibling.cleanup_library_after_dispatch is False
  523. @pytest.mark.asyncio
  524. async def test_copy_that_already_has_its_own_archive_keeps_it(queue_factory):
  525. ctx = await queue_factory(cleanup=True, siblings=({"own_archive": True, "status": "printing"},))
  526. await _dispatch_library_item(ctx)
  527. _, _, archive = await _queue_snapshot(ctx)
  528. (sibling,) = await _sibling_snapshot(ctx)
  529. # It is mid-print from its own archive and does not need the library file.
  530. # Re-pointing it would swap the file under a job already running.
  531. assert sibling.archive_id != archive.id
  532. assert sibling.status == "printing"
  533. # Cleared all the same: on PostgreSQL a row still naming the file goes with
  534. # it, and this one is a job that is currently printing.
  535. assert sibling.library_file_id is None
  536. @pytest.mark.parametrize("status", ["completed", "failed", "cancelled", "aborted"])
  537. @pytest.mark.asyncio
  538. async def test_finished_copies_keep_their_outcome_and_survive_the_delete(queue_factory, status):
  539. ctx = await queue_factory(cleanup=True, siblings=({"status": status},))
  540. await _dispatch_library_item(ctx)
  541. (sibling,) = await _sibling_snapshot(ctx)
  542. # A finished row is a record of what happened, not a spare part -- it keeps
  543. # its outcome and is not handed the archive.
  544. assert sibling.status == status
  545. assert sibling.archive_id is None
  546. # But the reference has to go: it is the only thing tying the row to the
  547. # cascade that would otherwise delete it, and a batch order counts its
  548. # progress from rows exactly like this one.
  549. assert sibling.library_file_id is None
  550. @pytest.mark.asyncio
  551. async def test_skipped_copy_is_repointed_because_it_can_come_back(queue_factory):
  552. ctx = await queue_factory(cleanup=True, siblings=({"status": "skipped"},))
  553. await _dispatch_library_item(ctx)
  554. _, _, archive = await _queue_snapshot(ctx)
  555. (sibling,) = await _sibling_snapshot(ctx)
  556. # Clearing the printer's previous-success gate puts skipped items back to
  557. # pending, so this one is only waiting -- not finished.
  558. assert sibling.status == "skipped"
  559. assert sibling.archive_id == archive.id
  560. assert sibling.library_file_id is None
  561. @pytest.mark.asyncio
  562. async def test_copies_are_untouched_when_the_dispatch_does_not_consume_the_file(queue_factory):
  563. ctx = await queue_factory(cleanup=False, siblings=({},))
  564. await _dispatch_library_item(ctx)
  565. item, library_file, _ = await _queue_snapshot(ctx)
  566. assert library_file is not None
  567. (sibling,) = await _sibling_snapshot(ctx)
  568. assert sibling.library_file_id == ctx.library_file_id
  569. assert sibling.archive_id is None
  570. @pytest.mark.asyncio
  571. async def test_cross_model_copy_keeps_its_other_candidate_instead_of_the_archive(queue_factory):
  572. ctx = await queue_factory(cleanup=True, siblings=({"variants": True, "extra_variant": True},))
  573. await _dispatch_library_item(ctx)
  574. (sibling,) = await _sibling_snapshot(ctx)
  575. # It still has somewhere to go, and that candidate carries its own target
  576. # model -- pointing it at this archive would print a file the matcher never
  577. # chose.
  578. assert sibling.archive_id is None
  579. assert sibling.library_file_id is None
  580. assert await _variant_files(ctx, sibling.id) == [ctx.other_library_file_id]
  581. @pytest.mark.asyncio
  582. async def test_copy_whose_only_candidate_was_consumed_is_repointed(queue_factory):
  583. ctx = await queue_factory(cleanup=True, siblings=({"variants": True},))
  584. await _dispatch_library_item(ctx)
  585. _, _, archive = await _queue_snapshot(ctx)
  586. (sibling,) = await _sibling_snapshot(ctx)
  587. # Its one candidate is gone. Without the re-point the resolver would hold it
  588. # pending forever with nothing left to dispatch.
  589. assert sibling.archive_id == archive.id
  590. assert sibling.library_file_id is None
  591. assert await _variant_files(ctx, sibling.id) == []
  592. @pytest.mark.asyncio
  593. async def test_oserror_during_unlink_logs_orphan_path_and_does_not_crash_dispatch(queue_factory, caplog):
  594. ctx = await queue_factory(cleanup=True, thumbnail_path="relative")
  595. original_unlink = type(ctx.source_path).unlink
  596. def unlink_with_source_failure(path, *args, **kwargs):
  597. if Path(path) == ctx.source_path:
  598. raise OSError("permission denied")
  599. return original_unlink(path, *args, **kwargs)
  600. with caplog.at_level("WARNING", logger="backend.app.services.print_scheduler"):
  601. await _dispatch_library_item(ctx, unlink_side_effect=unlink_with_source_failure)
  602. item, library_file, archive = await _queue_snapshot(ctx)
  603. assert item.status == "printing"
  604. assert item.archive_id == archive.id
  605. assert item.library_file_id is None
  606. assert library_file is None
  607. assert ctx.source_path.exists()
  608. assert not ctx.thumbnail_path.exists()
  609. assert ctx.archive_path.exists()
  610. assert "TRANSIENT_LIBRARY_FILE_ORPHAN" in caplog.text
  611. assert str(ctx.source_path) in caplog.text
  612. assert "permission denied" in caplog.text
  613. @pytest.mark.asyncio
  614. async def test_lost_k_profiles_are_restored_before_the_print_starts(queue_factory):
  615. """A slot reset to the default K must be fixed before the job goes out (#3219)."""
  616. ctx = await queue_factory(cleanup=False)
  617. order = []
  618. reapply = AsyncMock(side_effect=lambda *a, **k: order.append("reapply") or 0)
  619. ctx.start_print.side_effect = lambda *a, **k: order.append("start") or True
  620. with patch.object(scheduler_module.kprofile_drift, "reapply_lost_kprofiles", reapply):
  621. await _dispatch_library_item(ctx)
  622. assert order == ["reapply", "start"]
  623. # No stored mapping: every loaded tray is a candidate, and no retry wait.
  624. reapply.assert_awaited_once_with(ctx.printer_id, None, throttle=False)
  625. @pytest.mark.asyncio
  626. async def test_a_failing_k_profile_check_does_not_stop_the_print(queue_factory):
  627. ctx = await queue_factory(cleanup=False)
  628. reapply = AsyncMock(side_effect=RuntimeError("boom"))
  629. with patch.object(scheduler_module.kprofile_drift, "reapply_lost_kprofiles", reapply):
  630. await _dispatch_library_item(ctx)
  631. ctx.start_print.assert_called_once()
  632. # A virtual printer's upload is a File Manager file (source_type
  633. # "virtual_printer") that its queue items print. The archive is made at
  634. # dispatch, so that is where the upload's slicer AMS pick (#2700) and the
  635. # virtual printer's archive-name setting reach it.
  636. @pytest.mark.asyncio
  637. @pytest.mark.parametrize("moved", [False, True], ids=["same-printer", "moved-to-another"])
  638. async def test_a_vp_upload_hands_its_slicer_pick_to_the_archive(queue_factory, moved):
  639. # The pick stays tagged with the printer the slicer resolved it on, also
  640. # when the job was moved to another printer before it printed: its tray
  641. # numbers mean nothing there, so a reprint there must not reuse it.
  642. resolved_on = 4242 if moved else None
  643. ctx = await queue_factory(
  644. cleanup=False,
  645. source_type="virtual_printer",
  646. slicer_ams_mapping=lambda printer_id: json.dumps(
  647. {"mapping": [4, -1, 12], "printer_id": resolved_on or printer_id}
  648. ),
  649. )
  650. await _dispatch_library_item(ctx)
  651. assert ctx.archive_kwargs["slicer_ams_mapping"] == [4, -1, 12]
  652. assert ctx.archive_kwargs["slicer_ams_mapping_printer_id"] == (resolved_on or ctx.printer_id)
  653. @pytest.mark.asyncio
  654. @pytest.mark.parametrize(
  655. ("source_type", "setting", "expected"),
  656. [
  657. ("virtual_printer", "filename", {"prefer_filename_for_name": True}),
  658. ("virtual_printer", "metadata", {}),
  659. (None, "filename", {}),
  660. ],
  661. ids=["vp-filename", "vp-metadata", "other-file"],
  662. )
  663. async def test_the_vp_name_setting_names_the_archive_of_its_uploads_only(queue_factory, source_type, setting, expected):
  664. ctx = await queue_factory(cleanup=False, source_type=source_type)
  665. with patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value=setting)):
  666. await _dispatch_library_item(ctx)
  667. assert ctx.archive_kwargs == expected