test_bed_temperature_backfill_2989.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. """Archives written before #2989 get their bed temperature read back off disk.
  2. The extractor looked for a ``bed_temperature`` key BambuStudio does not write,
  3. so every archive from a Bambu slice stored NULL -- 0 of 455 real 3MFs resolved
  4. on the install this was measured on. The forward fix reads the array the fitted
  5. plate points at, but only for archives made after it; everything already in the
  6. library stays blank, and preheat keeps falling back to the keep-warm bed
  7. temperature whenever one of those jobs is reprinted from the queue.
  8. This one-shot re-reads the 3MF that is already on disk. It fills NULLs and
  9. nothing else: no value is invented, none is overwritten, and an archive whose
  10. file is gone stays NULL rather than being guessed at.
  11. The 3MFs here are real zips rather than a patched extractor, because
  12. ``extract_bed_temperature_from_3mf`` is itself new code and stubbing it would
  13. leave the only thing this migration depends on untested.
  14. """
  15. import json
  16. import logging
  17. import zipfile
  18. import pytest
  19. from sqlalchemy import text
  20. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  21. import backend.app.models # noqa: F401 - populate Base.metadata
  22. from backend.app.core import database as database_module
  23. from backend.app.core.database import Base, _backfill_archive_bed_temperature
  24. from backend.app.models.archive import PrintArchive
  25. # A Textured PEI slice of a two-filament project, in the shape BambuStudio
  26. # writes: an array per plate type, and the fitted plate named separately.
  27. _PEI_55 = {
  28. "curr_bed_type": "Textured PEI Plate",
  29. "cool_plate_temp": ["0", "0"],
  30. "eng_plate_temp": ["0", "0"],
  31. "hot_plate_temp": ["0", "0"],
  32. "textured_plate_temp_initial_layer": ["55", "55"],
  33. "textured_plate_temp": ["55", "55"],
  34. "supertack_plate_temp": ["0", "0"],
  35. }
  36. @pytest.fixture
  37. async def engine(tmp_path):
  38. eng = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/t.db")
  39. async with eng.begin() as conn:
  40. await conn.run_sync(Base.metadata.create_all)
  41. try:
  42. yield eng
  43. finally:
  44. await eng.dispose()
  45. @pytest.fixture
  46. def data_dir(tmp_path, monkeypatch):
  47. monkeypatch.setattr(database_module.settings, "base_dir", tmp_path)
  48. return tmp_path
  49. def _write_3mf(data_dir, relative: str, config: dict | None) -> str:
  50. """A 3MF on disk. ``config=None`` writes a file that is not a zip at all."""
  51. path = data_dir / relative
  52. path.parent.mkdir(parents=True, exist_ok=True)
  53. if config is None:
  54. path.write_bytes(b"not a zip")
  55. return relative
  56. with zipfile.ZipFile(path, "w") as zf:
  57. zf.writestr("Metadata/project_settings.config", json.dumps(config))
  58. return relative
  59. async def _archive(db, file_path: str, *, bed_temperature=None) -> PrintArchive:
  60. archive = PrintArchive(
  61. filename="Benchy.gcode.3mf",
  62. file_path=file_path,
  63. file_size=1,
  64. status="completed",
  65. bed_temperature=bed_temperature,
  66. )
  67. db.add(archive)
  68. await db.flush()
  69. return archive
  70. async def _bed_temperature(engine, archive_id: int):
  71. async with engine.begin() as conn:
  72. return (
  73. await conn.execute(text("SELECT bed_temperature FROM print_archives WHERE id = :id"), {"id": archive_id})
  74. ).scalar_one()
  75. class TestItFillsWhatItCan:
  76. @pytest.mark.asyncio
  77. async def test_a_null_is_read_from_the_plate_the_project_is_sliced_for(self, engine, data_dir):
  78. sm = async_sessionmaker(engine, expire_on_commit=False)
  79. async with sm() as db:
  80. relative = _write_3mf(data_dir, "archive/1/20260828_Benchy/Benchy.gcode.3mf", _PEI_55)
  81. archive = await _archive(db, relative)
  82. await db.commit()
  83. async with engine.begin() as conn:
  84. await _backfill_archive_bed_temperature(conn)
  85. assert await _bed_temperature(engine, archive.id) == 55
  86. @pytest.mark.asyncio
  87. async def test_an_orca_export_still_resolves(self, engine, data_dir):
  88. """The generic spelling is the fallback, not a second-class citizen."""
  89. sm = async_sessionmaker(engine, expire_on_commit=False)
  90. async with sm() as db:
  91. relative = _write_3mf(data_dir, "archive/1/a/Benchy.gcode.3mf", {"bed_temperature": 60})
  92. archive = await _archive(db, relative)
  93. await db.commit()
  94. async with engine.begin() as conn:
  95. await _backfill_archive_bed_temperature(conn)
  96. assert await _bed_temperature(engine, archive.id) == 60
  97. @pytest.mark.asyncio
  98. async def test_several_archives_in_one_pass(self, engine, data_dir):
  99. sm = async_sessionmaker(engine, expire_on_commit=False)
  100. async with sm() as db:
  101. first = await _archive(db, _write_3mf(data_dir, "archive/1/a/x.3mf", _PEI_55))
  102. second = await _archive(
  103. db,
  104. _write_3mf(
  105. data_dir, "archive/1/b/y.3mf", {"curr_bed_type": "High Temp Plate", "hot_plate_temp": ["100"]}
  106. ),
  107. )
  108. await db.commit()
  109. async with engine.begin() as conn:
  110. await _backfill_archive_bed_temperature(conn)
  111. assert await _bed_temperature(engine, first.id) == 55
  112. assert await _bed_temperature(engine, second.id) == 100
  113. class TestWhatItRefusesToTouch:
  114. @pytest.mark.asyncio
  115. async def test_a_value_already_recorded_is_left_alone(self, engine, data_dir):
  116. """Only NULLs. A temperature somebody set, or one a later archive read
  117. correctly, must not be rewritten from the file."""
  118. sm = async_sessionmaker(engine, expire_on_commit=False)
  119. async with sm() as db:
  120. relative = _write_3mf(data_dir, "archive/1/a/Benchy.gcode.3mf", _PEI_55)
  121. archive = await _archive(db, relative, bed_temperature=90)
  122. await db.commit()
  123. async with engine.begin() as conn:
  124. await _backfill_archive_bed_temperature(conn)
  125. assert await _bed_temperature(engine, archive.id) == 90
  126. @pytest.mark.asyncio
  127. async def test_a_no_3mf_archive_stays_null(self, engine, data_dir):
  128. """``file_path == ""`` is the ordinary shape of a Studio-sent H2 print.
  129. There is no file to read, and inventing one is the whole bug."""
  130. sm = async_sessionmaker(engine, expire_on_commit=False)
  131. async with sm() as db:
  132. archive = await _archive(db, "")
  133. await db.commit()
  134. async with engine.begin() as conn:
  135. await _backfill_archive_bed_temperature(conn)
  136. assert await _bed_temperature(engine, archive.id) is None
  137. @pytest.mark.asyncio
  138. async def test_a_file_that_is_gone_stays_null(self, engine, data_dir):
  139. sm = async_sessionmaker(engine, expire_on_commit=False)
  140. async with sm() as db:
  141. archive = await _archive(db, "archive/1/a/deleted.gcode.3mf")
  142. await db.commit()
  143. async with engine.begin() as conn:
  144. await _backfill_archive_bed_temperature(conn)
  145. assert await _bed_temperature(engine, archive.id) is None
  146. @pytest.mark.asyncio
  147. async def test_a_file_that_is_not_a_zip_stays_null(self, engine, data_dir):
  148. """A truncated or corrupted 3MF must not take the whole boot down."""
  149. sm = async_sessionmaker(engine, expire_on_commit=False)
  150. async with sm() as db:
  151. archive = await _archive(db, _write_3mf(data_dir, "archive/1/a/broken.3mf", None))
  152. await db.commit()
  153. async with engine.begin() as conn:
  154. await _backfill_archive_bed_temperature(conn)
  155. assert await _bed_temperature(engine, archive.id) is None
  156. @pytest.mark.asyncio
  157. async def test_an_all_zero_plate_array_stays_null(self, engine, data_dir):
  158. """0 means no filament in the project prints on this plate. Recording
  159. it would read as a cold bed, which is worse than nothing."""
  160. sm = async_sessionmaker(engine, expire_on_commit=False)
  161. async with sm() as db:
  162. relative = _write_3mf(
  163. data_dir,
  164. "archive/1/a/zero.3mf",
  165. {"curr_bed_type": "Cool Plate", "cool_plate_temp": ["0", "0"]},
  166. )
  167. archive = await _archive(db, relative)
  168. await db.commit()
  169. async with engine.begin() as conn:
  170. await _backfill_archive_bed_temperature(conn)
  171. assert await _bed_temperature(engine, archive.id) is None
  172. class TestItCannotStopBambuddyBooting:
  173. """The migration sequence has no handler above it.
  174. ``run_migrations`` is awaited straight from ``init_db`` with no try/except,
  175. so anything escaping this function stops startup -- and keeps stopping it,
  176. because the one-shot flag is written inside the transaction that just rolled
  177. back. Measured: two consecutive boots, same failure, flag never written. So
  178. the guards here are load-bearing rather than tidy.
  179. """
  180. @pytest.mark.asyncio
  181. async def test_an_unexpected_exception_costs_one_archive_not_the_boot(self, engine, data_dir, monkeypatch, caplog):
  182. """Not a listed zip error -- the point is that the guard does not depend
  183. on having predicted which exception a bad file raises."""
  184. import backend.app.utils.threemf_tools as tools
  185. sm = async_sessionmaker(engine, expire_on_commit=False)
  186. async with sm() as db:
  187. bad = await _archive(db, _write_3mf(data_dir, "archive/1/a/bad.3mf", _PEI_55))
  188. await db.commit()
  189. def _explode(_path):
  190. raise RecursionError("boom")
  191. monkeypatch.setattr(tools, "extract_bed_temperature_from_3mf", _explode)
  192. with caplog.at_level(logging.WARNING):
  193. async with engine.begin() as conn:
  194. await _backfill_archive_bed_temperature(conn)
  195. assert await _bed_temperature(engine, bad.id) is None
  196. assert any("could not read" in r.getMessage() for r in caplog.records)
  197. @pytest.mark.asyncio
  198. async def test_one_bad_archive_does_not_stop_the_others(self, engine, data_dir, monkeypatch):
  199. """The guard is per row, so the rest of the library is still repaired."""
  200. import backend.app.utils.threemf_tools as tools
  201. sm = async_sessionmaker(engine, expire_on_commit=False)
  202. async with sm() as db:
  203. bad = await _archive(db, _write_3mf(data_dir, "archive/1/a/bad.3mf", _PEI_55))
  204. good = await _archive(db, _write_3mf(data_dir, "archive/1/b/good.3mf", _PEI_55))
  205. await db.commit()
  206. real = tools.extract_bed_temperature_from_3mf
  207. def _explode_on_bad(path):
  208. if path.name == "bad.3mf":
  209. raise RecursionError("boom")
  210. return real(path)
  211. monkeypatch.setattr(tools, "extract_bed_temperature_from_3mf", _explode_on_bad)
  212. async with engine.begin() as conn:
  213. await _backfill_archive_bed_temperature(conn)
  214. assert await _bed_temperature(engine, bad.id) is None
  215. assert await _bed_temperature(engine, good.id) == 55
  216. @pytest.mark.asyncio
  217. async def test_the_extractor_swallows_anything_a_file_can_throw(self, tmp_path):
  218. """Its callers are inside startup, so None is the only outcome."""
  219. from backend.app.utils.threemf_tools import extract_bed_temperature_from_3mf
  220. missing = tmp_path / "nope.3mf"
  221. directory = tmp_path / "adir.3mf"
  222. directory.mkdir()
  223. truncated = tmp_path / "cut.3mf"
  224. truncated.write_bytes(b"PK\x03\x04 and then nothing")
  225. empty = tmp_path / "empty.3mf"
  226. empty.write_bytes(b"")
  227. for candidate in (missing, directory, truncated, empty):
  228. assert extract_bed_temperature_from_3mf(candidate) is None
  229. @pytest.mark.asyncio
  230. async def test_the_extractor_swallows_what_no_one_predicted(self, tmp_path, monkeypatch):
  231. """The four cases above all raise OSError or BadZipFile, so on their own
  232. they would still pass with the guard narrowed back to that pair. This
  233. one forces something outside it, which is the whole reason the catch is
  234. broad -- the failure being guarded is an exception nobody listed."""
  235. import backend.app.utils.threemf_tools as tools
  236. good = tmp_path / "ok.3mf"
  237. with zipfile.ZipFile(good, "w") as zf:
  238. zf.writestr("Metadata/project_settings.config", json.dumps(_PEI_55))
  239. assert tools.extract_bed_temperature_from_3mf(good) == 55
  240. class _Exploding:
  241. def __init__(self, *a, **k):
  242. raise RecursionError("boom")
  243. monkeypatch.setattr(tools.zipfile, "ZipFile", _Exploding)
  244. assert tools.extract_bed_temperature_from_3mf(good) is None
  245. @pytest.mark.asyncio
  246. async def test_a_flag_row_with_an_empty_value_does_not_re_run(self, engine, data_dir):
  247. """``if already:`` would treat "" as not-done, re-run, and then fail the
  248. unique key on the INSERT -- a boot loop from a single odd row."""
  249. async with engine.begin() as conn:
  250. await conn.execute(
  251. text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
  252. {"k": "_backfill_2989_bed_temperature_done", "v": ""},
  253. )
  254. async with engine.begin() as conn:
  255. await _backfill_archive_bed_temperature(conn)
  256. async with engine.begin() as conn:
  257. await _backfill_archive_bed_temperature(conn)
  258. class TestItRunsExactlyOnce:
  259. @pytest.mark.asyncio
  260. async def test_the_flag_is_written_even_when_nothing_matched(self, engine, data_dir):
  261. """The rows it cannot fill are the ones it would reopen every boot."""
  262. async with engine.begin() as conn:
  263. await _backfill_archive_bed_temperature(conn)
  264. async with engine.begin() as conn:
  265. flag = (
  266. await conn.execute(
  267. text('SELECT value FROM settings WHERE "key" = :k'),
  268. {"k": "_backfill_2989_bed_temperature_done"},
  269. )
  270. ).scalar_one_or_none()
  271. assert flag == "true"
  272. @pytest.mark.asyncio
  273. async def test_a_second_boot_does_not_rescan(self, engine, data_dir):
  274. """An archive added after the one-shot has run is left to the forward
  275. fix, which is what writes bed_temperature for anything new."""
  276. sm = async_sessionmaker(engine, expire_on_commit=False)
  277. async with sm() as db:
  278. first = await _archive(db, _write_3mf(data_dir, "archive/1/a/x.3mf", _PEI_55))
  279. await db.commit()
  280. async with engine.begin() as conn:
  281. await _backfill_archive_bed_temperature(conn)
  282. assert await _bed_temperature(engine, first.id) == 55
  283. async with sm() as db:
  284. later = await _archive(db, _write_3mf(data_dir, "archive/1/b/y.3mf", _PEI_55))
  285. await db.commit()
  286. async with engine.begin() as conn:
  287. await _backfill_archive_bed_temperature(conn)
  288. assert await _bed_temperature(engine, later.id) is None
  289. # And the flag was not written twice, which the settings table's unique
  290. # key would refuse anyway -- the guard is the SELECT, not the database.
  291. async with engine.begin() as conn:
  292. count = (
  293. await conn.execute(
  294. text('SELECT COUNT(*) FROM settings WHERE "key" = :k'),
  295. {"k": "_backfill_2989_bed_temperature_done"},
  296. )
  297. ).scalar_one()
  298. assert count == 1