Просмотр исходного кода

Repair the bed temperature on archives written before the fix (issue #2989)

The forward fix reads the array the fitted plate points at, but only for
archives made after it. Everything already in the library stays blank, and
preheat keeps falling back to the keep-warm bed temperature whenever one of
those jobs is reprinted from the queue - 0 of 455 real 3MFs had resolved.

A one-shot pass re-reads the 3MF already on disk, gated by a settings flag the
way #2614's repair is: the rows it cannot fill are exactly the ones it would
reopen every boot. It fills NULLs only. Nothing recorded is overwritten, an
archive whose file is gone stays NULL, and a corrupted 3MF is skipped rather
than failing startup.

The plate mapping moves to threemf_tools.bed_temperature_from_config so the
ingest path and the repair cannot read a 3MF differently - the same drift
move.

_extract_settings_from_content is deleted. Nothing called it anywhere in the
repo, and it carried the old bed_temperature mapping this issue fixed.
maziggy 1 неделя назад
Родитель
Сommit
b0ecb8fd88

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 93 - 0
backend/app/core/database.py

@@ -887,6 +887,94 @@ async def _migrate_scope_run_filament_to_plate(conn) -> None:
         )
 
 
+async def _backfill_archive_bed_temperature(conn) -> None:
+    """Fill in ``print_archives.bed_temperature`` for archives written before #2989.
+
+    Bed temperature was read by looking for a ``bed_temperature`` key, which
+    BambuStudio does not write -- it stores a per-filament array per plate type
+    and names the fitted plate in ``curr_bed_type``. Every archive from a Bambu
+    slice therefore stored NULL: 0 of 455 real 3MFs resolved on the install this
+    was measured on. The forward fix reads the right array; without this, every
+    archive made before it stays blank, and preheat keeps falling back to the
+    keep-warm bed temperature when those jobs are reprinted from the queue.
+
+    Only rows that are still NULL are touched, and only from the 3MF already on
+    disk -- nothing is invented and nothing already recorded is overwritten. An
+    archive whose file is gone (a no-3MF fallback, or one whose 3MF has been
+    cleaned up) is skipped and stays NULL, which is the honest answer.
+
+    Gated to run exactly once via a settings flag, like #2614's repair. The
+    work itself is repeatable -- it only fills NULLs -- but the rows it cannot
+    fill are exactly the ones it would re-open on every boot, and that set grows
+    with print history.
+    """
+    from pathlib import Path
+
+    from sqlalchemy import text
+
+    from backend.app.utils.threemf_tools import extract_bed_temperature_from_3mf
+
+    flag = "_backfill_2989_bed_temperature_done"
+
+    async with conn.begin_nested():
+        already = (
+            await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
+        ).scalar_one_or_none()
+        if already is not None:
+            # Presence, not truthiness. A flag row that somehow holds an empty
+            # string would otherwise re-run and then fail the unique key on the
+            # INSERT below -- which, at startup, is a boot loop.
+            return
+
+        rows = (
+            await conn.execute(
+                text(
+                    "SELECT id, file_path FROM print_archives "
+                    "WHERE bed_temperature IS NULL "
+                    "AND file_path IS NOT NULL AND file_path != ''"
+                )
+            )
+        ).fetchall()
+
+        filled = 0
+        for row in rows:
+            # Per row, and broad, for the reason in the extractor's docstring:
+            # nothing above this has a handler, so one unreadable archive must
+            # not cost the user their boot. #2614's repair guards its rows the
+            # same way.
+            try:
+                path = Path(row.file_path)
+                if not path.is_absolute():
+                    path = settings.base_dir / row.file_path
+                if not path.exists():
+                    continue
+                temperature = extract_bed_temperature_from_3mf(path)
+            except Exception as exc:
+                logger.warning("[#2989] could not read %s for archive %s: %s", row.file_path, row.id, exc)
+                continue
+            if not temperature:
+                continue
+            await conn.execute(
+                text("UPDATE print_archives SET bed_temperature = :t WHERE id = :id"),
+                {"t": temperature, "id": row.id},
+            )
+            filled += 1
+
+        if filled:
+            logger.info(
+                "[#2989] Read the bed temperature from the 3MF for %d archive(s) that had none",
+                filled,
+            )
+
+        # Marked done even when nothing matched, so the rows it could not fill --
+        # which are exactly the ones it would re-open every boot -- are not
+        # rescanned forever. Same shape as #2614's one-shot.
+        await conn.execute(
+            text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
+            {"k": flag, "v": "true"},
+        )
+
+
 async def _migrate_drop_library_print_name(conn) -> None:
     """Strip the embedded 3MF Title (``print_name``) from library file metadata (#1489).
 
@@ -4636,6 +4724,11 @@ async def run_migrations(conn):
     # #2603 archive plate_id backfill above so print_archives.plate_id is populated.
     await _migrate_scope_run_filament_to_plate(conn)
 
+    # Backfill: archives written before #2989 have no bed temperature, because
+    # the extractor looked for a key BambuStudio never writes. Re-reads the 3MF
+    # already on disk. One-shot; see the function for why it is gated.
+    await _backfill_archive_bed_temperature(conn)
+
     # Migration: Add controls_printer_power to smart_plugs (#2629). Marks
     # whether a plug actually feeds the printer's own power — only then may an
     # auto-off mark the printer offline. Defaults to true so existing plugs

+ 4 - 81
backend/app/services/archive.py

@@ -22,56 +22,11 @@ from backend.app.utils.archive_paths import archive_dir as resolve_archive_dir
 from backend.app.utils.ffmpeg_output import NO_FFMPEG_OUTPUT, summarize_ffmpeg_stderr
 from backend.app.utils.filename import clean_display_name
 from backend.app.utils.safe_path import PathTraversalError, assert_under, safe_join_under
+from backend.app.utils.threemf_tools import bed_temperature_from_config
 
 logger = logging.getLogger(__name__)
 
 
-# Bed temperature is not one key in a BambuStudio project. Every plate type has
-# its own per-filament array, and the plate actually fitted is named separately
-# in ``curr_bed_type`` -- so reading a bed temperature means picking the array
-# the plate points at. Keys and mapping are BambuStudio's own
-# ``get_bed_temp_1st_layer_key`` / ``get_bed_temp_key`` (PrintConfig.hpp), and
-# the plate names are the ``curr_bed_type`` enum values (PrintConfig.cpp).
-# First-layer temperature first: that is what the printer heats to before the
-# print starts, which is what preheat is trying to reach.
-#
-# ``Default Plate`` is deliberately absent -- BambuStudio maps it to no key at
-# all, so there is nothing to read and guessing a plate would invent a bed
-# temperature the slice never specified.
-_BED_TEMP_KEYS: dict[str, tuple[str, str]] = {
-    "Cool Plate": ("cool_plate_temp_initial_layer", "cool_plate_temp"),
-    "Engineering Plate": ("eng_plate_temp_initial_layer", "eng_plate_temp"),
-    "High Temp Plate": ("hot_plate_temp_initial_layer", "hot_plate_temp"),
-    "Textured PEI Plate": ("textured_plate_temp_initial_layer", "textured_plate_temp"),
-    "Supertack Plate": ("supertack_plate_temp_initial_layer", "supertack_plate_temp"),
-}
-
-# Fallback for a config that names no plate: the Orca/PrusaSlicer spelling,
-# which is a single value rather than a per-plate array.
-_GENERIC_BED_TEMP_KEYS = ("bed_temperature_initial_layer", "bed_temperature")
-
-
-def _plate_temperature(val) -> int | None:
-    """Bed temperature from one plate-temperature entry, or None.
-
-    The plate arrays carry one entry per filament in the project, and a 0 means
-    that filament cannot print on this plate. The bed only has one temperature,
-    so the print runs at the highest its filaments ask for -- taking entry 0 the
-    way the neighbouring scalar settings do would store a 0 for any project
-    whose first filament is not one this plate is heated for.
-    """
-    values = val if isinstance(val, list) else [val]
-    temps = []
-    for entry in values:
-        if isinstance(entry, bool) or not isinstance(entry, (int, float, str)):
-            continue
-        try:
-            temps.append(int(float(entry)))
-        except (TypeError, ValueError):
-            continue
-    return max(temps) if temps else None
-
-
 def _copy_and_fsync(src: Path, dst: Path, chunk_size: int = 1024 * 1024) -> None:
     """Copy src to dst with an explicit chunked read/write and fsync the dst.
 
@@ -550,17 +505,9 @@ class ThreeMFParser:
             # not write -- so every archive from a Bambu slice stored NULL, and
             # preheat fell back to a configured bed temperature on every job
             # (#2989). Orca-exported 3MFs keep working through the generic keys.
-            bed_type = str(data.get("curr_bed_type") or "").strip()
-            for key in (*_BED_TEMP_KEYS.get(bed_type, ()), *_GENERIC_BED_TEMP_KEYS):
-                if key not in data:
-                    continue
-                temperature = _plate_temperature(data[key])
-                # A plate array of all zeros means no filament in the project
-                # prints on this plate, which is not a bed temperature -- keep
-                # looking rather than recording a 0 that reads as "cold bed".
-                if temperature:
-                    self.metadata["bed_temperature"] = temperature
-                    break
+            bed_temperature = bed_temperature_from_config(data)
+            if bed_temperature is not None:
+                self.metadata["bed_temperature"] = bed_temperature
 
             # Nozzle temperature
             for key in ["nozzle_temperature_initial_layer", "nozzle_temperature"]:
@@ -587,30 +534,6 @@ class ThreeMFParser:
         except Exception:
             pass  # Print settings are optional; missing values are left unset
 
-    def _extract_settings_from_content(self, content: str):
-        """Extract print settings from config content."""
-        settings_map = {
-            "layer_height": ("layer_height", float),
-            "nozzle_diameter": ("nozzle_diameter", float),
-            "bed_temperature": ("bed_temperature", int),
-            "nozzle_temperature": ("nozzle_temperature", int),
-        }
-
-        for key, (search_key, converter) in settings_map.items():
-            if key not in self.metadata:
-                try:
-                    # Try JSON format
-                    if f'"{search_key}"' in content:
-                        start = content.find(f'"{search_key}"')
-                        value_start = content.find(":", start) + 1
-                        value_end = content.find(",", value_start)
-                        if value_end == -1:
-                            value_end = content.find("}", value_start)
-                        value = content[value_start:value_end].strip().strip('"')
-                        self.metadata[key] = converter(value)
-                except (ValueError, TypeError):
-                    pass  # Skip settings with unconvertible values
-
     def _parse_3dmodel(self, zf: zipfile.ZipFile):
         """Parse 3D/3dmodel.model for MakerWorld metadata."""
         try:

+ 90 - 0
backend/app/utils/threemf_tools.py

@@ -1025,6 +1025,96 @@ def extract_bed_type_from_3mf(file_path: Path, plate_id: int | None = None) -> s
     return extract_plate_metadata_from_3mf(file_path, plate_id).bed_type
 
 
+# Bed temperature is not one key in a BambuStudio project. Every plate type has
+# its own per-filament array, and the plate actually fitted is named separately
+# in ``curr_bed_type`` -- so reading a bed temperature means picking the array
+# the plate points at. Keys and mapping are BambuStudio's own
+# ``get_bed_temp_1st_layer_key`` / ``get_bed_temp_key`` (PrintConfig.hpp), and
+# the plate names are the ``curr_bed_type`` enum values (PrintConfig.cpp).
+# First-layer temperature first: that is what the printer heats to before the
+# print starts, which is what preheat is trying to reach.
+#
+# ``Default Plate`` is deliberately absent -- BambuStudio maps it to no key at
+# all, so there is nothing to read and guessing a plate would invent a bed
+# temperature the slice never specified.
+_BED_TEMP_KEYS: dict[str, tuple[str, str]] = {
+    "Cool Plate": ("cool_plate_temp_initial_layer", "cool_plate_temp"),
+    "Engineering Plate": ("eng_plate_temp_initial_layer", "eng_plate_temp"),
+    "High Temp Plate": ("hot_plate_temp_initial_layer", "hot_plate_temp"),
+    "Textured PEI Plate": ("textured_plate_temp_initial_layer", "textured_plate_temp"),
+    "Supertack Plate": ("supertack_plate_temp_initial_layer", "supertack_plate_temp"),
+}
+
+# Fallback for a config that names no plate: the Orca/PrusaSlicer spelling,
+# which is a single value rather than a per-plate array.
+_GENERIC_BED_TEMP_KEYS = ("bed_temperature_initial_layer", "bed_temperature")
+
+
+def _plate_temperature(val) -> int | None:
+    """Bed temperature from one plate-temperature entry, or None.
+
+    The plate arrays carry one entry per filament in the project, and a 0 means
+    that filament cannot print on this plate. The bed only has one temperature,
+    so the print runs at the highest its filaments ask for -- taking entry 0 the
+    way the neighbouring scalar settings do would store a 0 for any project
+    whose first filament is not one this plate is heated for.
+    """
+    values = val if isinstance(val, list) else [val]
+    temps = []
+    for entry in values:
+        if isinstance(entry, bool) or not isinstance(entry, (int, float, str)):
+            continue
+        try:
+            temps.append(int(float(entry)))
+        except (TypeError, ValueError):
+            continue
+    return max(temps) if temps else None
+
+
+def bed_temperature_from_config(data: dict) -> int | None:
+    """Bed temperature for the plate *data* is sliced for, or None (#2989).
+
+    *data* is a parsed ``Metadata/project_settings.config``. Lives here rather
+    than beside the archive parser so the ingest path and the one-shot backfill
+    that repairs archives written before the fix read it exactly the same way.
+    """
+    bed_type = str(data.get("curr_bed_type") or "").strip()
+    for key in (*_BED_TEMP_KEYS.get(bed_type, ()), *_GENERIC_BED_TEMP_KEYS):
+        if key not in data:
+            continue
+        temperature = _plate_temperature(data[key])
+        # A plate array of all zeros means no filament in the project prints on
+        # this plate, which is not a bed temperature -- keep looking rather than
+        # recording a 0 that reads as "cold bed".
+        if temperature:
+            return temperature
+    return None
+
+
+def extract_bed_temperature_from_3mf(file_path: Path) -> int | None:
+    """Read a 3MF's bed temperature straight off disk, or None.
+
+    For the backfill, which has a ``file_path`` and nothing else. Opens only
+    ``Metadata/project_settings.config`` -- the archive parser reads thumbnails,
+    the model and the slice info as well, and none of that is wanted here.
+
+    Every failure is None. Deliberately broader than the handful of exceptions a
+    malformed zip is expected to raise: the caller runs inside the startup
+    migration, which has no handler above it, so anything unlisted escaping here
+    does not skip one archive -- it stops Bambuddy from booting, and keeps
+    stopping it, because the one-shot flag is written in the same transaction
+    that just rolled back. A bed temperature is not worth that.
+    """
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            if "Metadata/project_settings.config" not in zf.namelist():
+                return None
+            data = json.loads(zf.read("Metadata/project_settings.config").decode())
+        return bed_temperature_from_config(data) if isinstance(data, dict) else None
+    except Exception:
+        return None
+
+
 # Header values exposed as `{placeholder}` substitutions inside snippets.
 # Aliases let users write Prusa-style names (`{max_layer_z}`) that map onto
 # Bambu/Orca header keys (`max_z_height`).

+ 376 - 0
backend/tests/unit/test_bed_temperature_backfill_2989.py

@@ -0,0 +1,376 @@
+"""Archives written before #2989 get their bed temperature read back off disk.
+
+The extractor looked for a ``bed_temperature`` key BambuStudio does not write,
+so every archive from a Bambu slice stored NULL -- 0 of 455 real 3MFs resolved
+on the install this was measured on. The forward fix reads the array the fitted
+plate points at, but only for archives made after it; everything already in the
+library stays blank, and preheat keeps falling back to the keep-warm bed
+temperature whenever one of those jobs is reprinted from the queue.
+
+This one-shot re-reads the 3MF that is already on disk. It fills NULLs and
+nothing else: no value is invented, none is overwritten, and an archive whose
+file is gone stays NULL rather than being guessed at.
+
+The 3MFs here are real zips rather than a patched extractor, because
+``extract_bed_temperature_from_3mf`` is itself new code and stubbing it would
+leave the only thing this migration depends on untested.
+"""
+
+import json
+import logging
+import zipfile
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+from backend.app.core import database as database_module
+from backend.app.core.database import Base, _backfill_archive_bed_temperature
+from backend.app.models.archive import PrintArchive
+
+# A Textured PEI slice of a two-filament project, in the shape BambuStudio
+# writes: an array per plate type, and the fitted plate named separately.
+_PEI_55 = {
+    "curr_bed_type": "Textured PEI Plate",
+    "cool_plate_temp": ["0", "0"],
+    "eng_plate_temp": ["0", "0"],
+    "hot_plate_temp": ["0", "0"],
+    "textured_plate_temp_initial_layer": ["55", "55"],
+    "textured_plate_temp": ["55", "55"],
+    "supertack_plate_temp": ["0", "0"],
+}
+
+
+@pytest.fixture
+async def engine(tmp_path):
+    eng = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/t.db")
+    async with eng.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    try:
+        yield eng
+    finally:
+        await eng.dispose()
+
+
+@pytest.fixture
+def data_dir(tmp_path, monkeypatch):
+    monkeypatch.setattr(database_module.settings, "base_dir", tmp_path)
+    return tmp_path
+
+
+def _write_3mf(data_dir, relative: str, config: dict | None) -> str:
+    """A 3MF on disk. ``config=None`` writes a file that is not a zip at all."""
+    path = data_dir / relative
+    path.parent.mkdir(parents=True, exist_ok=True)
+    if config is None:
+        path.write_bytes(b"not a zip")
+        return relative
+    with zipfile.ZipFile(path, "w") as zf:
+        zf.writestr("Metadata/project_settings.config", json.dumps(config))
+    return relative
+
+
+async def _archive(db, file_path: str, *, bed_temperature=None) -> PrintArchive:
+    archive = PrintArchive(
+        filename="Benchy.gcode.3mf",
+        file_path=file_path,
+        file_size=1,
+        status="completed",
+        bed_temperature=bed_temperature,
+    )
+    db.add(archive)
+    await db.flush()
+    return archive
+
+
+async def _bed_temperature(engine, archive_id: int):
+    async with engine.begin() as conn:
+        return (
+            await conn.execute(text("SELECT bed_temperature FROM print_archives WHERE id = :id"), {"id": archive_id})
+        ).scalar_one()
+
+
+class TestItFillsWhatItCan:
+    @pytest.mark.asyncio
+    async def test_a_null_is_read_from_the_plate_the_project_is_sliced_for(self, engine, data_dir):
+        sm = async_sessionmaker(engine, expire_on_commit=False)
+        async with sm() as db:
+            relative = _write_3mf(data_dir, "archive/1/20260828_Benchy/Benchy.gcode.3mf", _PEI_55)
+            archive = await _archive(db, relative)
+            await db.commit()
+
+        async with engine.begin() as conn:
+            await _backfill_archive_bed_temperature(conn)
+
+        assert await _bed_temperature(engine, archive.id) == 55
+
+    @pytest.mark.asyncio
+    async def test_an_orca_export_still_resolves(self, engine, data_dir):
+        """The generic spelling is the fallback, not a second-class citizen."""
+        sm = async_sessionmaker(engine, expire_on_commit=False)
+        async with sm() as db:
+            relative = _write_3mf(data_dir, "archive/1/a/Benchy.gcode.3mf", {"bed_temperature": 60})
+            archive = await _archive(db, relative)
+            await db.commit()
+
+        async with engine.begin() as conn:
+            await _backfill_archive_bed_temperature(conn)
+
+        assert await _bed_temperature(engine, archive.id) == 60
+
+    @pytest.mark.asyncio
+    async def test_several_archives_in_one_pass(self, engine, data_dir):
+        sm = async_sessionmaker(engine, expire_on_commit=False)
+        async with sm() as db:
+            first = await _archive(db, _write_3mf(data_dir, "archive/1/a/x.3mf", _PEI_55))
+            second = await _archive(
+                db,
+                _write_3mf(
+                    data_dir, "archive/1/b/y.3mf", {"curr_bed_type": "High Temp Plate", "hot_plate_temp": ["100"]}
+                ),
+            )
+            await db.commit()
+
+        async with engine.begin() as conn:
+            await _backfill_archive_bed_temperature(conn)
+
+        assert await _bed_temperature(engine, first.id) == 55
+        assert await _bed_temperature(engine, second.id) == 100
+
+
+class TestWhatItRefusesToTouch:
+    @pytest.mark.asyncio
+    async def test_a_value_already_recorded_is_left_alone(self, engine, data_dir):
+        """Only NULLs. A temperature somebody set, or one a later archive read
+        correctly, must not be rewritten from the file."""
+        sm = async_sessionmaker(engine, expire_on_commit=False)
+        async with sm() as db:
+            relative = _write_3mf(data_dir, "archive/1/a/Benchy.gcode.3mf", _PEI_55)
+            archive = await _archive(db, relative, bed_temperature=90)
+            await db.commit()
+
+        async with engine.begin() as conn:
+            await _backfill_archive_bed_temperature(conn)
+
+        assert await _bed_temperature(engine, archive.id) == 90
+
+    @pytest.mark.asyncio
+    async def test_a_no_3mf_archive_stays_null(self, engine, data_dir):
+        """``file_path == ""`` is the ordinary shape of a Studio-sent H2 print.
+        There is no file to read, and inventing one is the whole bug."""
+        sm = async_sessionmaker(engine, expire_on_commit=False)
+        async with sm() as db:
+            archive = await _archive(db, "")
+            await db.commit()
+
+        async with engine.begin() as conn:
+            await _backfill_archive_bed_temperature(conn)
+
+        assert await _bed_temperature(engine, archive.id) is None
+
+    @pytest.mark.asyncio
+    async def test_a_file_that_is_gone_stays_null(self, engine, data_dir):
+        sm = async_sessionmaker(engine, expire_on_commit=False)
+        async with sm() as db:
+            archive = await _archive(db, "archive/1/a/deleted.gcode.3mf")
+            await db.commit()
+
+        async with engine.begin() as conn:
+            await _backfill_archive_bed_temperature(conn)
+
+        assert await _bed_temperature(engine, archive.id) is None
+
+    @pytest.mark.asyncio
+    async def test_a_file_that_is_not_a_zip_stays_null(self, engine, data_dir):
+        """A truncated or corrupted 3MF must not take the whole boot down."""
+        sm = async_sessionmaker(engine, expire_on_commit=False)
+        async with sm() as db:
+            archive = await _archive(db, _write_3mf(data_dir, "archive/1/a/broken.3mf", None))
+            await db.commit()
+
+        async with engine.begin() as conn:
+            await _backfill_archive_bed_temperature(conn)
+
+        assert await _bed_temperature(engine, archive.id) is None
+
+    @pytest.mark.asyncio
+    async def test_an_all_zero_plate_array_stays_null(self, engine, data_dir):
+        """0 means no filament in the project prints on this plate. Recording
+        it would read as a cold bed, which is worse than nothing."""
+        sm = async_sessionmaker(engine, expire_on_commit=False)
+        async with sm() as db:
+            relative = _write_3mf(
+                data_dir,
+                "archive/1/a/zero.3mf",
+                {"curr_bed_type": "Cool Plate", "cool_plate_temp": ["0", "0"]},
+            )
+            archive = await _archive(db, relative)
+            await db.commit()
+
+        async with engine.begin() as conn:
+            await _backfill_archive_bed_temperature(conn)
+
+        assert await _bed_temperature(engine, archive.id) is None
+
+
+class TestItCannotStopBambuddyBooting:
+    """The migration sequence has no handler above it.
+
+    ``run_migrations`` is awaited straight from ``init_db`` with no try/except,
+    so anything escaping this function stops startup -- and keeps stopping it,
+    because the one-shot flag is written inside the transaction that just rolled
+    back. Measured: two consecutive boots, same failure, flag never written. So
+    the guards here are load-bearing rather than tidy.
+    """
+
+    @pytest.mark.asyncio
+    async def test_an_unexpected_exception_costs_one_archive_not_the_boot(self, engine, data_dir, monkeypatch, caplog):
+        """Not a listed zip error -- the point is that the guard does not depend
+        on having predicted which exception a bad file raises."""
+        import backend.app.utils.threemf_tools as tools
+
+        sm = async_sessionmaker(engine, expire_on_commit=False)
+        async with sm() as db:
+            bad = await _archive(db, _write_3mf(data_dir, "archive/1/a/bad.3mf", _PEI_55))
+            await db.commit()
+
+        def _explode(_path):
+            raise RecursionError("boom")
+
+        monkeypatch.setattr(tools, "extract_bed_temperature_from_3mf", _explode)
+
+        with caplog.at_level(logging.WARNING):
+            async with engine.begin() as conn:
+                await _backfill_archive_bed_temperature(conn)
+
+        assert await _bed_temperature(engine, bad.id) is None
+        assert any("could not read" in r.getMessage() for r in caplog.records)
+
+    @pytest.mark.asyncio
+    async def test_one_bad_archive_does_not_stop_the_others(self, engine, data_dir, monkeypatch):
+        """The guard is per row, so the rest of the library is still repaired."""
+        import backend.app.utils.threemf_tools as tools
+
+        sm = async_sessionmaker(engine, expire_on_commit=False)
+        async with sm() as db:
+            bad = await _archive(db, _write_3mf(data_dir, "archive/1/a/bad.3mf", _PEI_55))
+            good = await _archive(db, _write_3mf(data_dir, "archive/1/b/good.3mf", _PEI_55))
+            await db.commit()
+
+        real = tools.extract_bed_temperature_from_3mf
+
+        def _explode_on_bad(path):
+            if path.name == "bad.3mf":
+                raise RecursionError("boom")
+            return real(path)
+
+        monkeypatch.setattr(tools, "extract_bed_temperature_from_3mf", _explode_on_bad)
+
+        async with engine.begin() as conn:
+            await _backfill_archive_bed_temperature(conn)
+
+        assert await _bed_temperature(engine, bad.id) is None
+        assert await _bed_temperature(engine, good.id) == 55
+
+    @pytest.mark.asyncio
+    async def test_the_extractor_swallows_anything_a_file_can_throw(self, tmp_path):
+        """Its callers are inside startup, so None is the only outcome."""
+        from backend.app.utils.threemf_tools import extract_bed_temperature_from_3mf
+
+        missing = tmp_path / "nope.3mf"
+        directory = tmp_path / "adir.3mf"
+        directory.mkdir()
+        truncated = tmp_path / "cut.3mf"
+        truncated.write_bytes(b"PK\x03\x04 and then nothing")
+        empty = tmp_path / "empty.3mf"
+        empty.write_bytes(b"")
+
+        for candidate in (missing, directory, truncated, empty):
+            assert extract_bed_temperature_from_3mf(candidate) is None
+
+    @pytest.mark.asyncio
+    async def test_the_extractor_swallows_what_no_one_predicted(self, tmp_path, monkeypatch):
+        """The four cases above all raise OSError or BadZipFile, so on their own
+        they would still pass with the guard narrowed back to that pair. This
+        one forces something outside it, which is the whole reason the catch is
+        broad -- the failure being guarded is an exception nobody listed."""
+        import backend.app.utils.threemf_tools as tools
+
+        good = tmp_path / "ok.3mf"
+        with zipfile.ZipFile(good, "w") as zf:
+            zf.writestr("Metadata/project_settings.config", json.dumps(_PEI_55))
+        assert tools.extract_bed_temperature_from_3mf(good) == 55
+
+        class _Exploding:
+            def __init__(self, *a, **k):
+                raise RecursionError("boom")
+
+        monkeypatch.setattr(tools.zipfile, "ZipFile", _Exploding)
+
+        assert tools.extract_bed_temperature_from_3mf(good) is None
+
+    @pytest.mark.asyncio
+    async def test_a_flag_row_with_an_empty_value_does_not_re_run(self, engine, data_dir):
+        """``if already:`` would treat "" as not-done, re-run, and then fail the
+        unique key on the INSERT -- a boot loop from a single odd row."""
+        async with engine.begin() as conn:
+            await conn.execute(
+                text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
+                {"k": "_backfill_2989_bed_temperature_done", "v": ""},
+            )
+
+        async with engine.begin() as conn:
+            await _backfill_archive_bed_temperature(conn)
+        async with engine.begin() as conn:
+            await _backfill_archive_bed_temperature(conn)
+
+
+class TestItRunsExactlyOnce:
+    @pytest.mark.asyncio
+    async def test_the_flag_is_written_even_when_nothing_matched(self, engine, data_dir):
+        """The rows it cannot fill are the ones it would reopen every boot."""
+        async with engine.begin() as conn:
+            await _backfill_archive_bed_temperature(conn)
+
+        async with engine.begin() as conn:
+            flag = (
+                await conn.execute(
+                    text('SELECT value FROM settings WHERE "key" = :k'),
+                    {"k": "_backfill_2989_bed_temperature_done"},
+                )
+            ).scalar_one_or_none()
+
+        assert flag == "true"
+
+    @pytest.mark.asyncio
+    async def test_a_second_boot_does_not_rescan(self, engine, data_dir):
+        """An archive added after the one-shot has run is left to the forward
+        fix, which is what writes bed_temperature for anything new."""
+        sm = async_sessionmaker(engine, expire_on_commit=False)
+        async with sm() as db:
+            first = await _archive(db, _write_3mf(data_dir, "archive/1/a/x.3mf", _PEI_55))
+            await db.commit()
+
+        async with engine.begin() as conn:
+            await _backfill_archive_bed_temperature(conn)
+        assert await _bed_temperature(engine, first.id) == 55
+
+        async with sm() as db:
+            later = await _archive(db, _write_3mf(data_dir, "archive/1/b/y.3mf", _PEI_55))
+            await db.commit()
+
+        async with engine.begin() as conn:
+            await _backfill_archive_bed_temperature(conn)
+
+        assert await _bed_temperature(engine, later.id) is None
+        # And the flag was not written twice, which the settings table's unique
+        # key would refuse anyway -- the guard is the SELECT, not the database.
+        async with engine.begin() as conn:
+            count = (
+                await conn.execute(
+                    text('SELECT COUNT(*) FROM settings WHERE "key" = :k'),
+                    {"k": "_backfill_2989_bed_temperature_done"},
+                )
+            ).scalar_one()
+        assert count == 1

Некоторые файлы не были показаны из-за большого количества измененных файлов