test_scheduler_concurrent_dispatch.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  1. """Concurrent queue dispatch across printers (#2555).
  2. Reported as "prints are sent to the printer one by one, very slowly" on a
  3. 19-printer farm — up to an hour before the last printer started. Not a config
  4. problem: ``check_queue`` awaited ``_start_print`` inline for each pending item,
  5. and ``_start_print`` performs the FTP upload, so every printer queued behind
  6. every other printer's transfer. A Bambu printer's FTP server sustains ~150 KB/s
  7. (its own SD write is the bottleneck, not the network), so the reporter's 41 MB
  8. 3MF took ~254 s *per printer* — 19 of those in series is ~80 minutes.
  9. Printers are independent machines, so the uploads have no reason to be
  10. serialized. They now run concurrently, capped by ``queue_max_concurrent_uploads``.
  11. What must stay true:
  12. * Uploads to different printers overlap in time (the actual fix).
  13. * No more than ``queue_max_concurrent_uploads`` run at once (the host is not
  14. infinite: each in-flight upload holds an FTP thread, a TLS session, a handle).
  15. * Setting it to 1 restores exactly the old serial behaviour.
  16. * One printer failing must not cancel its siblings' in-flight uploads.
  17. * A pass still never overlaps with the next one — ``_start_print`` flips the row
  18. pending -> printing only *after* the upload completes, so returning early
  19. while uploads were in flight would let the next pass re-dispatch the same rows.
  20. """
  21. import asyncio
  22. from contextlib import ExitStack
  23. from pathlib import Path
  24. from types import SimpleNamespace
  25. from unittest.mock import AsyncMock, MagicMock, patch
  26. import pytest
  27. from sqlalchemy import select
  28. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  29. import backend.app.models # noqa: F401 - populate Base.metadata
  30. import backend.app.services.archive as archive_module
  31. import backend.app.services.print_scheduler as scheduler_module
  32. from backend.app.core.database import Base
  33. from backend.app.models.archive import PrintArchive
  34. from backend.app.models.library import LibraryFile
  35. from backend.app.models.print_queue import PrintQueueItem
  36. from backend.app.models.printer import Printer
  37. from backend.app.models.settings import Settings
  38. from backend.app.services.print_scheduler import PrintScheduler
  39. UPLOAD_SECONDS = 0.15
  40. @pytest.fixture
  41. async def farm(tmp_path):
  42. """Build a farm of N printers, each with one pending queue item."""
  43. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  44. async with engine.begin() as conn:
  45. await conn.run_sync(Base.metadata.create_all)
  46. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  47. async def make_farm(printer_count: int, *, max_concurrent: int | None = None):
  48. base_dir = tmp_path / "farm"
  49. (base_dir / "archives").mkdir(parents=True, exist_ok=True)
  50. async with session_maker() as db:
  51. if max_concurrent is not None:
  52. db.add(Settings(key="queue_max_concurrent_uploads", value=str(max_concurrent)))
  53. printer_ids = []
  54. for n in range(printer_count):
  55. archive_rel = Path("archives") / f"job-{n}.3mf"
  56. (base_dir / archive_rel).write_bytes(b"archive payload")
  57. printer = Printer(
  58. name=f"Printer {n}",
  59. serial_number=f"SERIAL-{n}",
  60. ip_address=f"10.0.0.{n + 1}",
  61. access_code="access-code",
  62. model="A1",
  63. )
  64. db.add(printer)
  65. await db.flush()
  66. archive = PrintArchive(
  67. printer_id=printer.id,
  68. filename=f"job-{n}.3mf",
  69. file_path=str(archive_rel),
  70. file_size=15,
  71. print_time_seconds=120,
  72. status="completed",
  73. )
  74. db.add(archive)
  75. await db.flush()
  76. db.add(
  77. PrintQueueItem(
  78. printer_id=printer.id,
  79. archive_id=archive.id,
  80. status="pending",
  81. position=n,
  82. )
  83. )
  84. printer_ids.append(printer.id)
  85. await db.commit()
  86. return SimpleNamespace(
  87. session_maker=session_maker,
  88. base_dir=base_dir,
  89. printer_ids=printer_ids,
  90. )
  91. try:
  92. yield make_farm
  93. finally:
  94. await engine.dispose()
  95. class _UploadRecorder:
  96. """Stands in for ``upload_file_async``; records overlap.
  97. Each call sleeps, so genuinely concurrent uploads have overlapping
  98. lifetimes. ``peak`` is the high-water mark of simultaneous in-flight
  99. uploads — the number the whole fix turns on.
  100. """
  101. def __init__(self, *, fail_for_ip: str | None = None):
  102. self.in_flight = 0
  103. self.peak = 0
  104. self.order: list[str] = []
  105. self.fail_for_ip = fail_for_ip
  106. async def __call__(self, ip_address, access_code, local_path, remote_path, **kwargs):
  107. self.in_flight += 1
  108. self.peak = max(self.peak, self.in_flight)
  109. self.order.append(ip_address)
  110. try:
  111. await asyncio.sleep(UPLOAD_SECONDS)
  112. if self.fail_for_ip is not None and ip_address == self.fail_for_ip:
  113. raise OSError(f"simulated FTP failure for {ip_address}")
  114. return True
  115. finally:
  116. self.in_flight -= 1
  117. async def _run_check_queue(ctx, upload, job_started=None):
  118. scheduler = PrintScheduler()
  119. job_started = job_started or AsyncMock()
  120. patches = [
  121. patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
  122. # The library-file path archives the 3MF before uploading it, and the
  123. # archive service resolves its own settings — redirect both or it writes
  124. # into the real repo and then fails relative_to(base_dir).
  125. patch.object(archive_module.settings, "base_dir", ctx.base_dir),
  126. patch.object(archive_module.settings, "archive_dir", ctx.base_dir / "archive"),
  127. patch("backend.app.services.print_scheduler.async_session", ctx.session_maker),
  128. patch("backend.app.core.database.async_session", ctx.session_maker),
  129. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  130. patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
  131. patch("backend.app.services.print_scheduler.printer_manager.start_print", MagicMock(return_value=True)),
  132. patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
  133. patch("backend.app.services.print_scheduler.upload_file_async", upload),
  134. patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
  135. patch(
  136. "backend.app.services.print_scheduler.get_ftp_retry_settings",
  137. AsyncMock(return_value=(False, 0, 0, 1.0)),
  138. ),
  139. patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
  140. patch("backend.app.services.print_scheduler.spawn_background_task", MagicMock()),
  141. patch("backend.app.services.notification_service.notification_service.on_queue_job_started", job_started),
  142. patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
  143. patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
  144. patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=True)),
  145. patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
  146. patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
  147. patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
  148. patch.object(scheduler, "_check_auto_drying", AsyncMock()),
  149. ]
  150. with ExitStack() as stack:
  151. for patcher in patches:
  152. stack.enter_context(patcher)
  153. return await scheduler.check_queue()
  154. async def _statuses(ctx):
  155. async with ctx.session_maker() as db:
  156. rows = (await db.execute(select(PrintQueueItem).order_by(PrintQueueItem.position))).scalars().all()
  157. return [r.status for r in rows]
  158. @pytest.mark.asyncio
  159. async def test_uploads_to_different_printers_overlap(farm):
  160. """The headline fix: six printers must not queue behind each other.
  161. Pre-fix this recorded peak == 1 no matter how many printers were pending.
  162. """
  163. ctx = await farm(6, max_concurrent=6)
  164. upload = _UploadRecorder()
  165. await _run_check_queue(ctx, upload)
  166. assert upload.peak == 6, (
  167. f"expected all 6 printers to be uploaded to concurrently, but the "
  168. f"high-water mark was {upload.peak} — uploads are still serialized"
  169. )
  170. assert await _statuses(ctx) == ["printing"] * 6
  171. @pytest.mark.asyncio
  172. async def test_concurrency_is_capped_by_the_setting(farm):
  173. """Eight pending printers, cap of 3 — never more than 3 uploads at once.
  174. The cap is the reason this is a setting and not just ``asyncio.gather``:
  175. the printers are independent but the Bambuddy host is not.
  176. """
  177. ctx = await farm(8, max_concurrent=3)
  178. upload = _UploadRecorder()
  179. await _run_check_queue(ctx, upload)
  180. assert upload.peak == 3, f"cap of 3 not honoured — peak was {upload.peak}"
  181. assert len(upload.order) == 8, "every pending item must still be dispatched, just not all at once"
  182. assert await _statuses(ctx) == ["printing"] * 8
  183. @pytest.mark.asyncio
  184. async def test_limit_of_one_restores_serial_behaviour(farm):
  185. """An escape hatch for weak networks: 1 == the pre-#2555 behaviour."""
  186. ctx = await farm(4, max_concurrent=1)
  187. upload = _UploadRecorder()
  188. await _run_check_queue(ctx, upload)
  189. assert upload.peak == 1
  190. assert await _statuses(ctx) == ["printing"] * 4
  191. @pytest.mark.asyncio
  192. async def test_default_concurrency_applies_when_setting_absent(farm):
  193. """No Settings row (every existing install) must still dispatch in parallel.
  194. The whole point is that the reporter's farm gets faster *without* him having
  195. to find a new setting first. Default is 4.
  196. """
  197. ctx = await farm(5, max_concurrent=None)
  198. upload = _UploadRecorder()
  199. await _run_check_queue(ctx, upload)
  200. assert upload.peak == 4, f"expected the default cap of 4, got {upload.peak}"
  201. assert await _statuses(ctx) == ["printing"] * 5
  202. @pytest.mark.asyncio
  203. async def test_one_failing_upload_does_not_cancel_the_others(farm):
  204. """A dead printer must not take its siblings' in-flight uploads down with it.
  205. ``asyncio.gather`` without ``return_exceptions=True`` cancels every sibling
  206. task the moment one raises — which would mean a single unreachable printer
  207. silently aborts the whole batch mid-transfer.
  208. """
  209. ctx = await farm(4, max_concurrent=4)
  210. upload = _UploadRecorder(fail_for_ip="10.0.0.2") # printer index 1
  211. await _run_check_queue(ctx, upload)
  212. statuses = await _statuses(ctx)
  213. assert statuses[1] == "failed", "the unreachable printer's item should be marked failed"
  214. assert [s for i, s in enumerate(statuses) if i != 1] == ["printing"] * 3, (
  215. "the other three printers must have started despite the failure"
  216. )
  217. @pytest.mark.asyncio
  218. async def test_check_queue_reports_it_dispatched(farm):
  219. """A productive pass returns True so ``run()`` re-checks quickly (#2555).
  220. The fast re-tick is what stops a draining batch from stalling 30 s behind
  221. the idle sleep every time a printer frees up.
  222. """
  223. ctx = await farm(3, max_concurrent=3)
  224. dispatched = await _run_check_queue(ctx, _UploadRecorder())
  225. assert dispatched is True, "check_queue dispatched 3 items but did not report it"
  226. @pytest.mark.asyncio
  227. async def test_check_queue_reports_nothing_dispatched_when_empty(farm):
  228. """An empty queue returns False so ``run()`` falls back to the idle interval."""
  229. ctx = await farm(0, max_concurrent=3)
  230. dispatched = await _run_check_queue(ctx, _UploadRecorder())
  231. assert dispatched is False, "an empty pass must not trigger a fast re-tick"
  232. @pytest.mark.asyncio
  233. async def test_check_queue_awaits_its_dispatches_before_returning(farm):
  234. """The pass must not return while uploads are still in flight.
  235. ``_start_print`` flips the row pending -> printing only *after* the upload
  236. finishes. If ``check_queue`` returned early, the next 30-second tick would
  237. still see those rows as ``pending`` on an idle-looking printer and dispatch
  238. them a second time.
  239. """
  240. ctx = await farm(3, max_concurrent=3)
  241. upload = _UploadRecorder()
  242. await _run_check_queue(ctx, upload)
  243. assert upload.in_flight == 0, "check_queue returned with uploads still running"
  244. assert await _statuses(ctx) == ["printing"] * 3
  245. class TestSharedLibraryRow:
  246. """Dispatching in parallel means two items can now reach the same library row
  247. at the same time — impossible when dispatch was serial.
  248. Only the ``cleanup_library_after_dispatch`` flow (printer-card "upload and
  249. print") *mutates* that row: it deletes it and unlinks the 3MF from disk once
  250. the print is away. Two of those against one row would race — the loser's
  251. DELETE matches no row, and the winner's unlink can pull the file out from
  252. under the loser's in-flight upload.
  253. An ordinary library print only reads the row. That distinction is load-bearing:
  254. the reporter's own batch was one File Manager file fanned out across his farm
  255. (both of the queue items in his log point at library file 116), so a blanket
  256. "never share a library row" guard would re-serialize the exact workload this
  257. change exists to fix.
  258. """
  259. @staticmethod
  260. async def _library_farm(session_maker, tmp_path, printer_count, *, cleanup: bool):
  261. """One shared library file, one queue item per printer, all pointing at it."""
  262. base_dir = tmp_path / "libfarm"
  263. (base_dir / "library").mkdir(parents=True, exist_ok=True)
  264. shared = base_dir / "library" / "shared.3mf"
  265. shared.write_bytes(b"shared payload")
  266. async with session_maker() as db:
  267. db.add(Settings(key="queue_max_concurrent_uploads", value=str(printer_count)))
  268. library_file = LibraryFile(
  269. filename="shared.3mf",
  270. file_path=str(shared),
  271. file_type="3mf",
  272. file_size=shared.stat().st_size,
  273. )
  274. db.add(library_file)
  275. await db.flush()
  276. for n in range(printer_count):
  277. printer = Printer(
  278. name=f"Printer {n}",
  279. serial_number=f"LIB-SERIAL-{n}",
  280. ip_address=f"10.1.0.{n + 1}",
  281. access_code="access-code",
  282. model="A1",
  283. )
  284. db.add(printer)
  285. await db.flush()
  286. db.add(
  287. PrintQueueItem(
  288. printer_id=printer.id,
  289. library_file_id=library_file.id,
  290. cleanup_library_after_dispatch=cleanup,
  291. status="pending",
  292. position=n,
  293. )
  294. )
  295. await db.commit()
  296. return SimpleNamespace(session_maker=session_maker, base_dir=base_dir, printer_ids=None)
  297. @pytest.mark.asyncio
  298. async def test_plain_library_file_still_fans_out_in_parallel(self, tmp_path):
  299. """The reporter's actual workload: one File Manager file, four printers.
  300. Nothing here mutates the library row, so all four must upload at once. If
  301. this ever drops to 1 the headline fix is gone.
  302. """
  303. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  304. async with engine.begin() as conn:
  305. await conn.run_sync(Base.metadata.create_all)
  306. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  307. try:
  308. ctx = await self._library_farm(session_maker, tmp_path, 4, cleanup=False)
  309. upload = _UploadRecorder()
  310. await _run_check_queue(ctx, upload)
  311. assert upload.peak == 4, f"a shared library file must not re-serialize the fan-out — peak was {upload.peak}"
  312. assert await _statuses(ctx) == ["printing"] * 4
  313. finally:
  314. await engine.dispose()
  315. @pytest.mark.asyncio
  316. async def test_cleanup_items_never_share_a_row_in_one_pass(self, tmp_path):
  317. """The mutating flow must be held to one dispatch per pass.
  318. Each of these deletes the library row and unlinks the 3MF when it is done.
  319. Exactly one may go per pass; the rest stay pending for a later one.
  320. """
  321. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  322. async with engine.begin() as conn:
  323. await conn.run_sync(Base.metadata.create_all)
  324. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  325. try:
  326. ctx = await self._library_farm(session_maker, tmp_path, 3, cleanup=True)
  327. upload = _UploadRecorder()
  328. await _run_check_queue(ctx, upload)
  329. assert upload.peak <= 1, (
  330. f"{upload.peak} dispatches raced over one consumable library row — "
  331. f"the loser's DELETE finds nothing and its 3MF can be unlinked mid-upload"
  332. )
  333. statuses = await _statuses(ctx)
  334. assert statuses.count("printing") == 1, "exactly one item should have gone out"
  335. assert statuses.count("pending") == 2, "the rest must stay queued, not fail"
  336. finally:
  337. await engine.dispose()
  338. @pytest.mark.asyncio
  339. async def test_library_print_without_a_parseable_print_time_does_not_crash(tmp_path):
  340. """Regression: `_start_print` read `library_file.print_time_seconds`, a column
  341. LibraryFile does not have.
  342. It only fired when the archive carried no print time — a plain .gcode, or a 3MF
  343. the parser could not read — and it fired *after* the printer had been sent the
  344. job. The started-notification was lost, and the AttributeError unwound the whole
  345. queue pass, so every other printer still waiting to be dispatched on that tick
  346. silently missed its turn. Exactly the "why did only some of them start" shape.
  347. Two printers here: if the first one's dispatch blows up, the second must still
  348. go out.
  349. """
  350. engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
  351. async with engine.begin() as conn:
  352. await conn.run_sync(Base.metadata.create_all)
  353. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  354. try:
  355. base_dir = tmp_path / "nolibtime"
  356. (base_dir / "library").mkdir(parents=True, exist_ok=True)
  357. async with session_maker() as db:
  358. db.add(Settings(key="queue_max_concurrent_uploads", value="2"))
  359. for n in range(2):
  360. src = base_dir / "library" / f"job-{n}.gcode"
  361. src.write_bytes(b"G28\n")
  362. lib = LibraryFile(
  363. filename=f"job-{n}.gcode",
  364. file_path=str(src),
  365. file_type="gcode",
  366. file_size=src.stat().st_size,
  367. )
  368. db.add(lib)
  369. printer = Printer(
  370. name=f"Printer {n}",
  371. serial_number=f"NT-{n}",
  372. ip_address=f"10.2.0.{n + 1}",
  373. access_code="access-code",
  374. model="A1",
  375. )
  376. db.add(printer)
  377. await db.flush()
  378. db.add(
  379. PrintQueueItem(
  380. printer_id=printer.id,
  381. library_file_id=lib.id,
  382. status="pending",
  383. position=n,
  384. print_time_seconds=None, # nothing cached either — the crashing shape
  385. )
  386. )
  387. await db.commit()
  388. ctx = SimpleNamespace(session_maker=session_maker, base_dir=base_dir, printer_ids=None)
  389. job_started = AsyncMock()
  390. await _run_check_queue(ctx, _UploadRecorder(), job_started=job_started)
  391. assert await _statuses(ctx) == ["printing", "printing"]
  392. # The status flip happens BEFORE the crash point, so it is not the signal —
  393. # both rows read "printing" even with the bug present. The started-notification
  394. # is emitted just after it, and is what the AttributeError actually destroyed.
  395. assert job_started.await_count == 2, (
  396. "the job-started notification was lost — _start_print raised after the "
  397. "printer had already been sent the job"
  398. )
  399. finally:
  400. await engine.dispose()