test_fallback_3mf_transfer_retry_3063.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. """A transfer that ran out of time is a temporary give-up too (#3063).
  2. The reporter's P1S was sent a 19 MB 3MF, the card had it, and FTPS served it --
  3. just not inside the 30s budget plus its 30s grace, four times over, while the
  4. printer was also running MQTT, the camera and the job upload at print start.
  5. Bambuddy wrote an empty fallback archive at 03:14:23. The same file then
  6. downloaded successfully at 03:15:11, 03:16:09 and 03:16:36, and every one of
  7. those copies was thrown away, because the only code that would have attached one
  8. had already given up.
  9. #2957 built the machinery to fill a fallback archive in after the fact, but
  10. armed it for exactly one give-up: the FTPS cool-off. Everything else was treated
  11. as settled, which is right for the three storage verdicts -- a job on internal
  12. eMMC never appears at any FTPS path -- and wrong here, where the file is on the
  13. card and the only thing that failed was the transfer.
  14. The discrimination these tests pin is the one the sweep already has and never
  15. used: a file that is genuinely not there answers 550, which surfaces as
  16. FileNotOnPrinterError and is caught by name. A timeout returns falsy instead --
  17. ``with_ftp_retry`` hands back None once its budget is spent -- so "we never got a
  18. straight answer" and "the printer says no such file" are distinguishable without
  19. guessing.
  20. """
  21. from unittest.mock import AsyncMock, MagicMock, patch
  22. import pytest
  23. from backend.app.main import (
  24. _active_prints,
  25. _expected_print_creators,
  26. _expected_print_registered_at,
  27. _expected_prints,
  28. _print_ams_mappings,
  29. _timelapse_baselines,
  30. )
  31. from backend.app.services.print_storage import REASON_FTP_TRANSFER_FAILED, REASON_FTPS_COOLOFF
  32. pytestmark = pytest.mark.unit
  33. DISPATCH = "/data/Metadata/plate_1.gcode"
  34. SUBTASK = "Fan_Shroud"
  35. @pytest.fixture(autouse=True)
  36. def _clear_dicts():
  37. dicts = (
  38. _expected_prints,
  39. _expected_print_registered_at,
  40. _expected_print_creators,
  41. _print_ams_mappings,
  42. _active_prints,
  43. _timelapse_baselines,
  44. )
  45. for d in dicts:
  46. d.clear()
  47. yield
  48. for d in dicts:
  49. d.clear()
  50. def _printer():
  51. printer = MagicMock()
  52. printer.id = 1
  53. printer.auto_archive = True
  54. printer.external_camera_enabled = False
  55. printer.external_camera_url = None
  56. # Every unset MagicMock attribute is truthy, and leaving this one implicit
  57. # runs the plate-detection camera grab against a printer that is not there.
  58. printer.plate_detection_enabled = False
  59. printer.name = "P1S"
  60. printer.model = "P1S"
  61. printer.ip_address = "172.25.12.149"
  62. printer.access_code = "12345678"
  63. return printer
  64. async def _run_print_start(download, peek_plate=None):
  65. """Drive on_print_start's fallback path for a print on external storage.
  66. Returns ``(added_rows, schedule_mock)``. The card is present and the
  67. dispatch says ``ftp://``, so the storage verdict is reachable and the sweep
  68. runs -- what differs between tests is only how ``download`` fails.
  69. """
  70. printer = _printer()
  71. def execute_router(stmt, *args, **kwargs):
  72. sql = str(stmt).lower()
  73. if "from printers" in sql or "from printer " in sql:
  74. return MagicMock(
  75. scalar_one_or_none=MagicMock(return_value=printer),
  76. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[printer]))),
  77. )
  78. return MagicMock(
  79. scalar_one_or_none=MagicMock(return_value=None),
  80. scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
  81. )
  82. added: list = []
  83. session = AsyncMock()
  84. session.__aenter__ = AsyncMock(return_value=session)
  85. session.__aexit__ = AsyncMock()
  86. session.execute = AsyncMock(side_effect=execute_router)
  87. session.commit = AsyncMock()
  88. session.refresh = AsyncMock()
  89. session.add = MagicMock(side_effect=added.append)
  90. schedule = MagicMock()
  91. state = MagicMock(
  92. current_project_url=f"ftp://{SUBTASK}.gcode.3mf",
  93. sdcard=True,
  94. sdcard_reported=True,
  95. )
  96. with (
  97. patch("backend.app.main.async_session") as session_maker,
  98. patch("backend.app.main.notification_service") as notif,
  99. patch("backend.app.main.smart_plug_manager") as plug,
  100. patch("backend.app.main.ws_manager") as ws,
  101. patch("backend.app.main.mqtt_relay") as relay,
  102. patch("backend.app.main.printer_manager") as pm,
  103. patch("backend.app.main.download_file_async", new=download),
  104. patch("backend.app.main.download_file_try_paths_async", new=AsyncMock(return_value=None)),
  105. patch("backend.app.main.get_cached_3mf", return_value=None),
  106. patch("backend.app.main.cache_3mf_download"),
  107. patch("backend.app.main.peek_plate_index_in_3mf", return_value=peek_plate),
  108. # Imported inside the function, so patching it anywhere else lets the
  109. # directory walk open real sockets and the test hangs on connect.
  110. patch("backend.app.services.bambu_ftp.list_files_async", new=AsyncMock(return_value=[])),
  111. patch("backend.app.main.ftps_handshake_blocked", return_value=False),
  112. # Retry off, so `download` is called directly and its failure mode is
  113. # the one under test rather than with_ftp_retry's summary of it.
  114. patch("backend.app.main.get_ftp_retry_settings", new=AsyncMock(return_value=(False, 3, 2.0, 30))),
  115. patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
  116. patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
  117. patch("backend.app.main._maybe_start_layer_timelapse"),
  118. patch("backend.app.main._capture_timelapse_baseline_at_start", new_callable=AsyncMock),
  119. # Real, it would spawn a task that outlives the test by a minute.
  120. patch("backend.app.main._schedule_fallback_3mf_retry", new=schedule),
  121. ):
  122. session_maker.return_value = session
  123. notif.on_print_start = AsyncMock()
  124. plug.on_print_start = AsyncMock()
  125. ws.send_print_start = AsyncMock()
  126. ws.send_archive_updated = AsyncMock()
  127. # Awaited between creating the fallback row and scheduling its retry: a
  128. # plain MagicMock here raises, the handler swallows it, and every
  129. # assertion about the retry passes vacuously.
  130. ws.send_archive_created = AsyncMock()
  131. relay.on_print_start = AsyncMock()
  132. pm.get_status = MagicMock(return_value=state)
  133. pm.get_printer = MagicMock(return_value=MagicMock(serial_number="TEST3063"))
  134. from backend.app.main import on_print_start
  135. await on_print_start(1, {"filename": DISPATCH, "subtask_name": SUBTASK})
  136. return added, schedule
  137. def _fallback(added):
  138. for row in added:
  139. extra = getattr(row, "extra_data", None)
  140. if isinstance(extra, dict) and extra.get("no_3mf_available"):
  141. return row
  142. return None
  143. class TestATimedOutTransferIsWorthComingBackFor:
  144. @pytest.mark.asyncio
  145. async def test_the_archive_records_the_transfer_as_the_cause(self):
  146. """Not `None`, which is the slug for "the slicer left nothing on the
  147. card" and sends this reporter to a setting that was already on."""
  148. added, _schedule = await _run_print_start(AsyncMock(return_value=False))
  149. assert _fallback(added).extra_data["no_3mf_reason"] == REASON_FTP_TRANSFER_FAILED
  150. @pytest.mark.asyncio
  151. async def test_a_retry_is_scheduled_with_the_names_the_sweep_just_tried(self):
  152. added, schedule = await _run_print_start(AsyncMock(return_value=False))
  153. schedule.assert_called_once()
  154. kwargs = schedule.call_args.kwargs
  155. assert kwargs["reason"] == REASON_FTP_TRANSFER_FAILED
  156. assert f"{SUBTASK}.gcode.3mf" in kwargs["filenames"]
  157. assert _fallback(added) is not None
  158. @pytest.mark.asyncio
  159. async def test_a_connection_error_counts_as_a_failed_transfer_too(self):
  160. """A refused or dropped connection is not the printer saying the file
  161. is absent, and it does not last any longer than a timeout does."""
  162. added, schedule = await _run_print_start(AsyncMock(side_effect=OSError("connection reset")))
  163. assert _fallback(added).extra_data["no_3mf_reason"] == REASON_FTP_TRANSFER_FAILED
  164. schedule.assert_called_once()
  165. class TestAFileThatIsNotThereIsStillSettled:
  166. @pytest.mark.asyncio
  167. async def test_a_550_from_every_path_schedules_nothing(self):
  168. """The regression guard on the whole change. 550 is the printer
  169. answering the question, and no amount of waiting changes the answer --
  170. retrying it is the sweep #2780 removed for costing an install 1813
  171. failed connections in a day."""
  172. from backend.app.services.bambu_ftp import FileNotOnPrinterError
  173. added, schedule = await _run_print_start(AsyncMock(side_effect=FileNotOnPrinterError("550")))
  174. assert _fallback(added).extra_data["no_3mf_reason"] is None
  175. schedule.assert_not_called()
  176. @pytest.mark.asyncio
  177. async def test_a_download_that_produced_the_wrong_plate_schedules_nothing(self):
  178. """A 3MF arrived, so the transport is not what failed here -- it was the
  179. wrong plate, and #2957 discards it rather than archive another plate's
  180. filament and cost against this print.
  181. The names the sweep would retry with are the same stale ones that
  182. fetched the contradicted file, and `_recover_fallback_archive` checks
  183. that a candidate is a readable 3MF but not which plate it holds. So a
  184. retry here would put back exactly what was just thrown away.
  185. """
  186. # First path times out, the second serves a file -- for plate 2, while
  187. # the dispatch says plate 1. Without the reset, that first timeout would
  188. # be enough to arm a retry.
  189. download = AsyncMock(side_effect=[False, True, True, True, True, True])
  190. added, schedule = await _run_print_start(download, peek_plate=2)
  191. assert _fallback(added) is not None
  192. schedule.assert_not_called()
  193. class TestTheRetryActuallyFillsTheArchiveIn:
  194. """The ladder is only half of it -- the pass it schedules has to land."""
  195. @pytest.mark.asyncio
  196. async def test_the_reporters_sequence_end_to_end(self, test_engine, tmp_path, monkeypatch):
  197. """Give up on the transfer, then let the same file turn up a minute
  198. later exactly as it did for the reporter, and the empty row is filled
  199. in rather than left for good.
  200. Driven through the real ``_schedule_fallback_3mf_retry`` rather than a
  201. mock of it, because the thing #3063 reports is not that nothing was
  202. scheduled -- it is that nothing ever attached the file.
  203. """
  204. import asyncio
  205. import zipfile
  206. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  207. from backend.app import main as main_module
  208. from backend.app.models.archive import PrintArchive
  209. from backend.app.models.printer import Printer
  210. from backend.app.services import bambu_ftp
  211. maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  212. async with maker() as db:
  213. printer = Printer(
  214. name="P1S",
  215. serial_number="01P00A3B1200579",
  216. ip_address="172.25.12.149",
  217. access_code="12345678",
  218. model="P1S",
  219. )
  220. db.add(printer)
  221. await db.commit()
  222. await db.refresh(printer)
  223. archive = PrintArchive(
  224. printer_id=printer.id,
  225. filename=f"{SUBTASK}.gcode.3mf",
  226. file_path="",
  227. file_size=0,
  228. print_name=SUBTASK,
  229. status="printing",
  230. extra_data={
  231. "no_3mf_available": True,
  232. "no_3mf_reason": REASON_FTP_TRANSFER_FAILED,
  233. "_print_data": {"filename": f"{SUBTASK}.gcode.3mf"},
  234. },
  235. )
  236. db.add(archive)
  237. await db.commit()
  238. await db.refresh(archive)
  239. printer_id, archive_id = printer.id, archive.id
  240. source = tmp_path / "temp" / f"{SUBTASK}.gcode.3mf"
  241. source.parent.mkdir(parents=True, exist_ok=True)
  242. with zipfile.ZipFile(source, "w", zipfile.ZIP_DEFLATED) as zf:
  243. zf.writestr(
  244. "Metadata/slice_info.config",
  245. "<?xml version='1.0' encoding='UTF-8'?>"
  246. "<config><plate>"
  247. "<metadata key='index' value='1'/>"
  248. "<metadata key='prediction' value='3600'/>"
  249. "<metadata key='weight' value='42.5'/>"
  250. "<filament id='1' type='PLA' color='#00AE42' used_g='42.5' used_m='14.2'/>"
  251. "</plate></config>",
  252. )
  253. zf.writestr("3D/3dmodel.model", "<model/>")
  254. # The file turns up between the give-up and the first retry -- the
  255. # cover endpoint pulling it for a thumbnail, as it did at 03:15:11.
  256. bambu_ftp.cache_3mf_download(printer_id, f"{SUBTASK}.gcode.3mf", source)
  257. monkeypatch.setattr(main_module, "_FALLBACK_3MF_TRANSFER_RETRY_DELAYS_SECONDS", (0.01,))
  258. try:
  259. with patch.object(main_module, "async_session", maker):
  260. main_module._schedule_fallback_3mf_retry(
  261. printer_id=printer_id,
  262. archive_id=archive_id,
  263. filenames=[f"{SUBTASK}.gcode.3mf"],
  264. reason=REASON_FTP_TRANSFER_FAILED,
  265. )
  266. await asyncio.wait_for(main_module._fallback_3mf_retry_tasks[printer_id], timeout=5)
  267. finally:
  268. bambu_ftp.clear_3mf_cache(printer_id, delete_files=False)
  269. async with maker() as db:
  270. recovered = await db.get(PrintArchive, archive_id)
  271. assert recovered.file_path, "the row still has no 3MF"
  272. assert recovered.file_size > 0
  273. # The markers are what the archives banner counts, so they have to
  274. # go or the install keeps being told about a print that is fine.
  275. assert not recovered.extra_data.get("no_3mf_available")
  276. assert not recovered.extra_data.get("no_3mf_reason")
  277. class TestTheLadderSuitsTheCause:
  278. def test_the_transfer_ladder_starts_well_before_the_cooloff_one(self):
  279. """A cool-off has to expire first -- 300s of it -- so #2957 places its
  280. first attempt past that. Nothing has to expire here: #3063's file
  281. completed 48 seconds after the budget ran out, and waiting five minutes
  282. to ask would mean the cover endpoint is the only thing that ever
  283. recovers these.
  284. """
  285. from backend.app.main import (
  286. _FALLBACK_3MF_RETRY_DELAYS_SECONDS,
  287. _FALLBACK_3MF_TRANSFER_RETRY_DELAYS_SECONDS,
  288. )
  289. assert _FALLBACK_3MF_TRANSFER_RETRY_DELAYS_SECONDS[0] < _FALLBACK_3MF_RETRY_DELAYS_SECONDS[0]
  290. assert _FALLBACK_3MF_TRANSFER_RETRY_DELAYS_SECONDS[0] <= 60.0
  291. @pytest.mark.asyncio
  292. async def test_each_cause_gets_its_own_ladder(self, monkeypatch):
  293. """One scheduler, two callers. Passing the wrong reason would make a
  294. cool-off retry fire while the cool-off is still running, which the task
  295. can only answer by deferring."""
  296. import asyncio
  297. from backend.app import main as main_module
  298. slept: list[float] = []
  299. async def _record(delay):
  300. slept.append(delay)
  301. raise asyncio.CancelledError
  302. monkeypatch.setattr(main_module.asyncio, "sleep", _record)
  303. for reason, expected in (
  304. (REASON_FTPS_COOLOFF, main_module._FALLBACK_3MF_RETRY_DELAYS_SECONDS[0]),
  305. (REASON_FTP_TRANSFER_FAILED, main_module._FALLBACK_3MF_TRANSFER_RETRY_DELAYS_SECONDS[0]),
  306. ):
  307. slept.clear()
  308. main_module._schedule_fallback_3mf_retry(printer_id=1, archive_id=1, filenames=["x.3mf"], reason=reason)
  309. task = main_module._fallback_3mf_retry_tasks[1]
  310. with pytest.raises(asyncio.CancelledError):
  311. await task
  312. assert slept == [expected]