Przeglądaj źródła

fix(queue): cache per-plate 3MF metadata so queue polling stops re-parsing every row (#2573)

The Queue listing serialized each item by opening its 3MF and re-parsing
slice_info.config three times (print time, filament usage, bed type) on
every poll, per connected client, even for unchanged files. Add a single
combined extract_plate_metadata_from_3mf() cached by (path, plate_id,
mtime_ns, size); the three legacy helpers delegate to it. An unchanged
queue now does no repeat 3MF parsing.
maziggy 1 miesiąc temu
rodzic
commit
b8097fab20

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.5b2] - Unreleased
 
 ### Fixed
+- **Queue polling re-parsed every 3MF from scratch on each poll (#2573, reporter @Jostxxl)** — The Queue page polls `GET /api/v1/queue/` every few seconds, and for each item with a `plate_id` the serializer called three separate helpers — `extract_print_time_from_3mf`, `extract_filament_usage_from_3mf`, `extract_bed_type_from_3mf` — each of which independently opened the item's ZIP and re-parsed `Metadata/slice_info.config`. With 22 queued items that is 66 ZIP-open + XML-parse operations per poll, run in the event-loop thread, repeated for *every* connected browser even though the files never changed. The three values now come from a single combined parse (`extract_plate_metadata_from_3mf`) cached by file revision — the key is `(path, plate_id, mtime_ns, size)`, so an unchanged file is parsed at most once and a replaced or edited file transparently re-parses with no manual invalidation. The three legacy helpers still exist (other callers use them) but now delegate to the same cached parse, so usage-tracking and Spoolman paths benefit too; the queue hot path calls the combined helper once per row. The cache is a bounded (512-entry) LRU guarded by a lock so it stays small and is safe from worker threads. Listing an unchanged queue now serializes DB data and does no repeat 3MF parsing. (The reporter's broader farm-scale asks — a WebSocket-delta queue, an initial snapshot endpoint, ETag/304 support, per-row plate-request batching — are a separate queue-page redesign, not part of this fix.)
 - **Progress-milestone and HMS-error notifications held a DB connection across the camera snapshot (#2572, reporter @Jostxxl)** — Both notification paths inside `on_printer_status_change` (the 25/50/75% milestone push and the new-HMS-error push) opened a database session, then captured a camera snapshot for the notification image — an up-to-15s RTSP grab — and sent the notification, all with the session held. So a pooled connection sat idle for the whole grab, per milestone/error, per printer; on a farm those fire constantly. The snapshot needs no database, so it now runs between two short sessions: one to read the printer name, then the grab with no connection held, then a fresh session for the notification send. Behaviour is unchanged; pinned by a test that fails if the snapshot ever runs while a session is open. The AMS-change notification path was left as-is for now (it holds a per-printer lock across its write and needs separate care). Continues the #2572 effort (camera stream, timelapse scan, finish photo).
 - **Finish-photo capture held a DB connection open across the whole camera grab (#2572, reporter @Jostxxl)** — When a print finishes, the background finish-photo task reads a couple of rows (the capture setting, the printer, the archive) and then runs a capture pipeline that can take tens of seconds — timelapse last-frame extraction, waiting up to 20s for the stage-22 producer, an external-camera HTTP grab, or a fresh RTSP shot. It held one database session open across that entire pipeline, so a pooled connection sat `idle in transaction` for the full capture, once per finishing print — and finishes cluster on a farm. It now reads what it needs in a short session, releases the connection, runs the capture with no session held, and re-opens a fresh short session only to append the photo to the archive. Behaviour is unchanged. Continues the #2572 effort (camera stream, timelapse scan) to stop holding sessions across slow I/O.
 - **Timelapse scan held a DB connection open across every FTP round-trip (#2572, reporter @Jostxxl)** — After a print completes, `_scan_for_timelapse_with_retries` polls the printer's FTP server for the new timelapse file (up to 4 retry attempts, plus a name-match fallback). Each attempt opened one database session and held it across the FTP directory listing *and* the multi-MB video download — so a pooled connection sat `idle in transaction` for the whole transfer, once per attempt, per completed print. When several prints finish together on a farm that adds up. The scan now reads the archive + printer in a short session, releases the connection, does the FTP list/download with no session held, and re-opens a fresh short session only to attach the downloaded file. Behaviour is unchanged; the existing scan tests already exercise the read→download→attach path. Continues the #2572 effort (after the camera-stream fix) to stop holding sessions across slow I/O; the scheduler paths were reviewed and found already bounded (single loop + capped concurrent uploads, with an explicit pre-dispatch commit) so they were left as-is.

+ 17 - 24
backend/app/api/routes/print_queue.py

@@ -39,8 +39,7 @@ from backend.app.services.filament_requirements import overrides_for_plate
 from backend.app.services.notification_service import notification_service
 from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
 from backend.app.utils.threemf_tools import (
-    extract_bed_type_from_3mf,
-    extract_filament_usage_from_3mf,
+    extract_plate_metadata_from_3mf,
     extract_print_time_from_3mf,
 )
 
@@ -250,17 +249,14 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
             if item.plate_id:
                 archive_path = settings.base_dir / item.archive.file_path
                 if archive_path.exists():
-                    plate_time = _extract_print_time_from_3mf(archive_path, item.plate_id)
-                    plate_weight = sum(
-                        f["used_g"] for f in extract_filament_usage_from_3mf(archive_path, item.plate_id)
-                    )
-                    plate_bed = extract_bed_type_from_3mf(archive_path, item.plate_id)
-                    if plate_time is not None:
-                        response.print_time_seconds = plate_time
-                    if plate_weight > 0:
-                        response.filament_used_grams = plate_weight
-                    if plate_bed:
-                        response.bed_type = plate_bed
+                    # One cached parse for all three per-plate overrides (#2573).
+                    plate_meta = extract_plate_metadata_from_3mf(archive_path, item.plate_id)
+                    if plate_meta.print_time_seconds is not None:
+                        response.print_time_seconds = plate_meta.print_time_seconds
+                    if plate_meta.filament_used_grams > 0:
+                        response.filament_used_grams = plate_meta.filament_used_grams
+                    if plate_meta.bed_type:
+                        response.bed_type = plate_meta.bed_type
     if item.library_file:
         response.library_file_name = (
             item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
@@ -282,17 +278,14 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
             lib_path = Path(item.library_file.file_path)
             library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / item.library_file.file_path
             if library_file_path.exists():
-                plate_time = _extract_print_time_from_3mf(library_file_path, item.plate_id)
-                plate_weight = sum(
-                    f["used_g"] for f in extract_filament_usage_from_3mf(library_file_path, item.plate_id)
-                )
-                plate_bed = extract_bed_type_from_3mf(library_file_path, item.plate_id)
-                if plate_time is not None:
-                    response.print_time_seconds = plate_time
-                if plate_weight > 0:
-                    response.filament_used_grams = plate_weight
-                if plate_bed:
-                    response.bed_type = plate_bed
+                # One cached parse for all three per-plate overrides (#2573).
+                plate_meta = extract_plate_metadata_from_3mf(library_file_path, item.plate_id)
+                if plate_meta.print_time_seconds is not None:
+                    response.print_time_seconds = plate_meta.print_time_seconds
+                if plate_meta.filament_used_grams > 0:
+                    response.filament_used_grams = plate_meta.filament_used_grams
+                if plate_meta.bed_type:
+                    response.bed_type = plate_meta.bed_type
     if item.printer:
         response.printer_name = item.printer.name
     return response

+ 175 - 132
backend/app/utils/threemf_tools.py

@@ -11,7 +11,10 @@ import logging
 import math
 import re
 import zipfile
+from collections import OrderedDict
+from dataclasses import dataclass, field
 from pathlib import Path
+from threading import Lock
 
 import defusedxml.ElementTree as ET
 
@@ -424,6 +427,173 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non
         return None
 
 
+@dataclass(frozen=True)
+class PlateMetadata:
+    """Combined per-plate slice_info.config values from a single 3MF parse.
+
+    Bundles the three fields the queue listing needs so a queue poll opens and
+    parses each 3MF once instead of three times (#2573). ``filament_usage`` is
+    the full per-filament list (other callers — usage tracking, Spoolman — need
+    it); ``filament_used_grams`` is its ``used_g`` sum, precomputed here so the
+    queue path doesn't re-sum on every hit.
+    """
+
+    print_time_seconds: int | None = None
+    filament_usage: list[dict] = field(default_factory=list)
+    bed_type: str | None = None
+    filament_used_grams: float = 0.0
+
+
+_EMPTY_PLATE_METADATA = PlateMetadata()
+
+# Revision-keyed cache for parsed per-plate metadata. Queue polling re-lists the
+# same unchanged 3MFs every few seconds per connected client (#2573); without a
+# cache each row costs a ZIP open + XML parse. The key includes the file's
+# mtime_ns and size so a replaced or edited file transparently gets a fresh
+# entry — no manual invalidation needed. Bounded LRU + lock so it stays small
+# and is safe to touch from worker threads.
+_PLATE_METADATA_CACHE: "OrderedDict[tuple, PlateMetadata]" = OrderedDict()
+_PLATE_METADATA_CACHE_LOCK = Lock()
+_PLATE_METADATA_CACHE_MAX = 512
+
+
+def clear_plate_metadata_cache() -> None:
+    """Drop all cached per-plate metadata (used by tests)."""
+    with _PLATE_METADATA_CACHE_LOCK:
+        _PLATE_METADATA_CACHE.clear()
+
+
+def _parse_plate_metadata_uncached(file_path: Path, plate_id: int | None) -> PlateMetadata:
+    """Open the 3MF once and pull print time, filament usage and bed type.
+
+    Replicates the per-field ``plate_id=None`` behaviour of the three legacy
+    helpers exactly: usage collects every ``<filament>`` in the file, while
+    print time and bed type come from the first ``<plate>``.
+    """
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            if "Metadata/slice_info.config" not in zf.namelist():
+                return _EMPTY_PLATE_METADATA
+            content = zf.read("Metadata/slice_info.config").decode()
+            root = ET.fromstring(content)
+    except Exception as e:
+        logger.warning("Failed to read plate metadata from %s: %s", file_path, e)
+        return _EMPTY_PLATE_METADATA
+
+    def _plate_index(plate_elem) -> int | None:
+        for meta in plate_elem.findall("metadata"):
+            if meta.get("key") == "index":
+                try:
+                    return int(meta.get("value", "0"))
+                except ValueError:
+                    return None
+        return None
+
+    def _collect_filaments(plate_elem) -> list[dict]:
+        out: list[dict] = []
+        for f in plate_elem.findall("filament"):
+            filament_id = f.get("id")
+            try:
+                used_amount = float(f.get("used_g", "0"))
+            except (ValueError, TypeError):
+                continue
+            if filament_id:
+                out.append(
+                    {
+                        "slot_id": int(filament_id),
+                        "used_g": used_amount,
+                        "type": f.get("type", ""),
+                        "color": f.get("color", ""),
+                    }
+                )
+        return out
+
+    print_time: int | None = None
+    bed_type: str | None = None
+    filament_usage: list[dict] = []
+    matched_plate = None
+
+    if plate_id is not None:
+        for plate_elem in root.findall(".//plate"):
+            if _plate_index(plate_elem) == plate_id:
+                matched_plate = plate_elem
+                break
+    else:
+        matched_plate = root.find(".//plate")
+
+    if matched_plate is not None:
+        for meta in matched_plate.findall("metadata"):
+            key = meta.get("key")
+            if key == "prediction" and print_time is None:
+                try:
+                    print_time = int(meta.get("value", "0"))
+                except ValueError:
+                    print_time = None
+            elif key == "curr_bed_type" and meta.get("value"):
+                bed_type = (meta.get("value") or "").strip()
+
+    if plate_id is not None:
+        if matched_plate is not None:
+            filament_usage = _collect_filaments(matched_plate)
+    else:
+        # Legacy plate_id=None usage: every filament in the file, not just plate 1.
+        for f in root.findall(".//filament"):
+            filament_id = f.get("id")
+            try:
+                used_amount = float(f.get("used_g", "0"))
+            except (ValueError, TypeError):
+                continue
+            if filament_id:
+                filament_usage.append(
+                    {
+                        "slot_id": int(filament_id),
+                        "used_g": used_amount,
+                        "type": f.get("type", ""),
+                        "color": f.get("color", ""),
+                    }
+                )
+
+    return PlateMetadata(
+        print_time_seconds=print_time,
+        filament_usage=filament_usage,
+        bed_type=bed_type,
+        filament_used_grams=sum(f["used_g"] for f in filament_usage),
+    )
+
+
+def extract_plate_metadata_from_3mf(file_path: Path, plate_id: int | None = None) -> PlateMetadata:
+    """Return combined per-plate metadata, cached by file revision (#2573).
+
+    The result is keyed by ``(path, plate_id, mtime_ns, size)`` so an unchanged
+    file is parsed at most once; a replaced/edited file re-parses automatically.
+    The returned ``PlateMetadata`` is shared and MUST be treated as read-only —
+    callers that need a mutable filament list get a copy from the wrappers below.
+    """
+    file_path = Path(file_path)
+    try:
+        stat = file_path.stat()
+    except OSError:
+        # File missing/unreadable: parse (which will return empty) but don't
+        # cache — the file may appear later and we don't want a sticky miss.
+        return _parse_plate_metadata_uncached(file_path, plate_id)
+
+    key = (str(file_path), plate_id, stat.st_mtime_ns, stat.st_size)
+    with _PLATE_METADATA_CACHE_LOCK:
+        cached = _PLATE_METADATA_CACHE.get(key)
+        if cached is not None:
+            _PLATE_METADATA_CACHE.move_to_end(key)
+            return cached
+
+    metadata = _parse_plate_metadata_uncached(file_path, plate_id)
+
+    with _PLATE_METADATA_CACHE_LOCK:
+        _PLATE_METADATA_CACHE[key] = metadata
+        _PLATE_METADATA_CACHE.move_to_end(key)
+        while len(_PLATE_METADATA_CACHE) > _PLATE_METADATA_CACHE_MAX:
+            _PLATE_METADATA_CACHE.popitem(last=False)
+    return metadata
+
+
 def extract_filament_usage_from_3mf(file_path: Path, plate_id: int | None = None) -> list[dict]:
     """Extract per-filament total usage from 3MF slice_info.config.
 
@@ -438,68 +608,9 @@ def extract_filament_usage_from_3mf(file_path: Path, plate_id: int | None = None
         List of filament usage dictionaries:
         [{"slot_id": 1, "used_g": 50.5, "type": "PLA", "color": "#FF0000"}, ...]
     """
-    filament_usage = []
-    try:
-        with zipfile.ZipFile(file_path, "r") as zf:
-            if "Metadata/slice_info.config" not in zf.namelist():
-                return []
-
-            content = zf.read("Metadata/slice_info.config").decode()
-            root = ET.fromstring(content)
-
-            if plate_id is not None:
-                # Find the plate element with matching index
-                for plate_elem in root.findall(".//plate"):
-                    plate_index = None
-                    for meta in plate_elem.findall("metadata"):
-                        if meta.get("key") == "index":
-                            try:
-                                plate_index = int(meta.get("value", "0"))
-                            except ValueError:
-                                pass
-                            break
-
-                    if plate_index == plate_id:
-                        for f in plate_elem.findall("filament"):
-                            filament_id = f.get("id")
-                            used_g = f.get("used_g", "0")
-                            try:
-                                used_amount = float(used_g)
-                                if filament_id:
-                                    filament_usage.append(
-                                        {
-                                            "slot_id": int(filament_id),
-                                            "used_g": used_amount,
-                                            "type": f.get("type", ""),
-                                            "color": f.get("color", ""),
-                                        }
-                                    )
-                            except (ValueError, TypeError):
-                                pass
-                        break
-            else:
-                # No plate_id specified - extract all filaments
-                for f in root.findall(".//filament"):
-                    filament_id = f.get("id")
-                    used_g = f.get("used_g", "0")
-                    try:
-                        used_amount = float(used_g)
-                        if filament_id:
-                            filament_usage.append(
-                                {
-                                    "slot_id": int(filament_id),
-                                    "used_g": used_amount,
-                                    "type": f.get("type", ""),
-                                    "color": f.get("color", ""),
-                                }
-                            )
-                    except (ValueError, TypeError):
-                        pass  # Skip filament entries with unparseable usage values
-
-    except Exception:
-        pass  # Return whatever usage data was collected before the error
-
-    return filament_usage
+    # Delegate to the cached combined parse (#2573). Return fresh dicts so callers
+    # that mutate the list don't corrupt the shared cached PlateMetadata.
+    return [dict(f) for f in extract_plate_metadata_from_3mf(file_path, plate_id).filament_usage]
 
 
 def extract_print_time_from_3mf(file_path: Path, plate_id: int | None = None) -> int | None:
@@ -518,46 +629,7 @@ def extract_print_time_from_3mf(file_path: Path, plate_id: int | None = None) ->
     Returns:
         Predicted print time in seconds, or None if not found / unparseable.
     """
-    try:
-        with zipfile.ZipFile(file_path, "r") as zf:
-            if "Metadata/slice_info.config" not in zf.namelist():
-                return None
-
-            content = zf.read("Metadata/slice_info.config").decode()
-            root = ET.fromstring(content)
-
-            if plate_id is not None:
-                for plate_elem in root.findall(".//plate"):
-                    plate_index = None
-                    for meta in plate_elem.findall("metadata"):
-                        if meta.get("key") == "index":
-                            try:
-                                plate_index = int(meta.get("value", "0"))
-                            except ValueError:
-                                pass  # Skip plate with unparseable index
-                            break
-
-                    if plate_index == plate_id:
-                        for meta in plate_elem.findall("metadata"):
-                            if meta.get("key") == "prediction":
-                                try:
-                                    return int(meta.get("value", "0"))
-                                except ValueError:
-                                    return None
-                        break
-            else:
-                plate_elem = root.find(".//plate")
-                if plate_elem is not None:
-                    for meta in plate_elem.findall("metadata"):
-                        if meta.get("key") == "prediction":
-                            try:
-                                return int(meta.get("value", "0"))
-                            except ValueError:
-                                return None
-    except Exception as e:
-        logger.warning("Failed to extract print time from %s: %s", file_path, e)
-
-    return None
+    return extract_plate_metadata_from_3mf(file_path, plate_id).print_time_seconds
 
 
 def extract_bed_type_from_3mf(file_path: Path, plate_id: int | None = None) -> str | None:
@@ -578,36 +650,7 @@ def extract_bed_type_from_3mf(file_path: Path, plate_id: int | None = None) -> s
     Returns:
         Bed type string (e.g. "Textured PEI Plate"), or None if not found.
     """
-    try:
-        with zipfile.ZipFile(file_path, "r") as zf:
-            if "Metadata/slice_info.config" not in zf.namelist():
-                return None
-
-            content = zf.read("Metadata/slice_info.config").decode()
-            root = ET.fromstring(content)
-
-            for plate_elem in root.findall(".//plate"):
-                plate_index = None
-                bed_value: str | None = None
-                for meta in plate_elem.findall("metadata"):
-                    key = meta.get("key")
-                    if key == "index":
-                        try:
-                            plate_index = int(meta.get("value", "0"))
-                        except ValueError:
-                            pass  # Skip plate with unparseable index
-                    elif key == "curr_bed_type" and meta.get("value"):
-                        bed_value = (meta.get("value") or "").strip()
-
-                if plate_id is None:
-                    # First plate wins when no plate_id is requested.
-                    return bed_value
-                if plate_index == plate_id:
-                    return bed_value
-    except Exception:
-        pass  # Return None on any failure rather than raising — caller decides
-
-    return None
+    return extract_plate_metadata_from_3mf(file_path, plate_id).bed_type
 
 
 # Header values exposed as `{placeholder}` substitutions inside snippets.

+ 133 - 0
backend/tests/unit/test_threemf_tools.py

@@ -1034,3 +1034,136 @@ class TestExtractSupportFilamentSlotsFrom3mf:
         cfg = json.dumps({"enable_support": "1", "support_filament": "not-a-number"})
         with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf:
             assert extract_support_filament_slots_from_3mf(zf) == set()
+
+
+class TestExtractPlateMetadataFrom3mf:
+    """The combined per-plate helper parses slice_info.config once and caches
+    the result by file revision so queue polling doesn't re-open the same 3MF
+    three times per row on every poll (#2573)."""
+
+    _MULTI_PLATE = """<?xml version="1.0" encoding="UTF-8"?>
+    <config>
+        <plate>
+            <metadata key="index" value="1"/>
+            <metadata key="prediction" value="3600"/>
+            <metadata key="curr_bed_type" value="Textured PEI Plate"/>
+            <filament id="1" used_g="50.0" type="PLA" color="#FF0000"/>
+        </plate>
+        <plate>
+            <metadata key="index" value="2"/>
+            <metadata key="prediction" value="7200"/>
+            <metadata key="curr_bed_type" value="Engineering Plate"/>
+            <filament id="1" used_g="12.5" type="ABS" color="#00FF00"/>
+            <filament id="2" used_g="7.5" type="ABS" color="#0000FF"/>
+        </plate>
+    </config>
+    """
+
+    def _write(self, tmp_path, xml, name="test.3mf"):
+        from backend.app.utils.threemf_tools import clear_plate_metadata_cache
+
+        clear_plate_metadata_cache()
+        file_path = tmp_path / name
+        file_path.write_bytes(create_mock_3mf(xml).read())
+        return file_path
+
+    def test_combines_all_three_fields_for_plate(self, tmp_path):
+        from backend.app.utils.threemf_tools import extract_plate_metadata_from_3mf
+
+        file_path = self._write(tmp_path, self._MULTI_PLATE)
+
+        meta = extract_plate_metadata_from_3mf(file_path, plate_id=2)
+        assert meta.print_time_seconds == 7200
+        assert meta.bed_type == "Engineering Plate"
+        assert meta.filament_used_grams == 20.0
+        assert {f["slot_id"] for f in meta.filament_usage} == {1, 2}
+
+    def test_plate_id_none_matches_legacy_behaviour(self, tmp_path):
+        # Legacy None behaviour: time+bed from the first plate, but usage
+        # collects EVERY filament in the file (not just plate 1).
+        from backend.app.utils.threemf_tools import extract_plate_metadata_from_3mf
+
+        file_path = self._write(tmp_path, self._MULTI_PLATE)
+
+        meta = extract_plate_metadata_from_3mf(file_path, plate_id=None)
+        assert meta.print_time_seconds == 3600
+        assert meta.bed_type == "Textured PEI Plate"
+        assert len(meta.filament_usage) == 3  # 1 from plate 1 + 2 from plate 2
+
+    def test_second_call_hits_cache_without_reparsing(self, tmp_path):
+        from unittest.mock import patch
+
+        import backend.app.utils.threemf_tools as tools
+
+        file_path = self._write(tmp_path, self._MULTI_PLATE)
+
+        with patch.object(tools, "_parse_plate_metadata_uncached", wraps=tools._parse_plate_metadata_uncached) as spy:
+            first = tools.extract_plate_metadata_from_3mf(file_path, plate_id=2)
+            second = tools.extract_plate_metadata_from_3mf(file_path, plate_id=2)
+
+        assert spy.call_count == 1  # parsed once, served from cache the second time
+        assert first is second
+        assert second.print_time_seconds == 7200
+
+    def test_changed_file_reparses(self, tmp_path):
+        from unittest.mock import patch
+
+        import backend.app.utils.threemf_tools as tools
+
+        file_path = self._write(tmp_path, self._MULTI_PLATE)
+
+        with patch.object(tools, "_parse_plate_metadata_uncached", wraps=tools._parse_plate_metadata_uncached) as spy:
+            tools.extract_plate_metadata_from_3mf(file_path, plate_id=1)
+            # Replace the file with different content (and a different size, so the
+            # revision key changes even if mtime resolution is coarse).
+            new_xml = """<?xml version="1.0" encoding="UTF-8"?>
+            <config>
+                <plate>
+                    <metadata key="index" value="1"/>
+                    <metadata key="prediction" value="999"/>
+                    <metadata key="curr_bed_type" value="Cool Plate"/>
+                    <filament id="1" used_g="1.0" type="PLA" color="#FFFFFF"/>
+                </plate>
+            </config>
+            """
+            file_path.write_bytes(create_mock_3mf(new_xml).read())
+            fresh = tools.extract_plate_metadata_from_3mf(file_path, plate_id=1)
+
+        assert spy.call_count == 2  # revision changed -> re-parsed
+        assert fresh.print_time_seconds == 999
+        assert fresh.bed_type == "Cool Plate"
+
+    def test_wrapper_returns_mutable_copy(self, tmp_path):
+        # extract_filament_usage_from_3mf callers mutate the list; that must not
+        # corrupt the shared cached PlateMetadata.
+        from backend.app.utils.threemf_tools import (
+            extract_filament_usage_from_3mf,
+            extract_plate_metadata_from_3mf,
+        )
+
+        file_path = self._write(tmp_path, self._MULTI_PLATE)
+
+        usage = extract_filament_usage_from_3mf(file_path, plate_id=2)
+        usage.append({"slot_id": 99, "used_g": 0.0, "type": "", "color": ""})
+        usage[0]["used_g"] = -1.0
+
+        cached = extract_plate_metadata_from_3mf(file_path, plate_id=2)
+        assert len(cached.filament_usage) == 2
+        assert all(f["used_g"] > 0 for f in cached.filament_usage)
+
+    def test_missing_file_returns_empty_and_is_not_cached(self, tmp_path):
+        from unittest.mock import patch
+
+        import backend.app.utils.threemf_tools as tools
+
+        tools.clear_plate_metadata_cache()
+        missing = tmp_path / "nope.3mf"
+
+        with patch.object(tools, "_parse_plate_metadata_uncached", wraps=tools._parse_plate_metadata_uncached) as spy:
+            meta = tools.extract_plate_metadata_from_3mf(missing, plate_id=1)
+            tools.extract_plate_metadata_from_3mf(missing, plate_id=1)
+
+        assert meta.print_time_seconds is None
+        assert meta.filament_usage == []
+        # Missing file must not create a sticky cache entry (it may appear later).
+        assert spy.call_count == 2