Browse Source

Give an Any-model job to a printer that has enough filament (#3137)

maziggy 1 day ago
parent
commit
e7f4ce0429

+ 1 - 0
CHANGELOG.md

@@ -138,6 +138,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **An "Any model" job was held for too little filament on one printer while another idle printer of that model had plenty (#3137, reported by @RambachTJ)** — The queue gave a job queued for **Any H2D Pro** (or any model) to the first idle printer with the right filament type, and only then checked whether its spools held enough. When they did not, the job waited on that printer for a manual start, even though another idle printer of the same model could run it; the only way out was to move it by hand. The queue now passes over a printer that would run short and gives the job to one that has enough. If every idle printer would run short, the job still waits on the first, where **Print Anyway** works as before, and moves by itself once another idle printer of its model has enough filament, on record in Inventory or Spoolman.
 - **Shutting Bambuddy down logged an error with a traceback when nothing was wrong (#3243, reported by @Thomansky)** — Stopping Bambuddy cancels the printer updates still waiting to be handled, and each cancelled one was logged as `ERROR ... Exception in scheduled callback` with a traceback, as the last line before the restart. In a log or a support bundle it looked like a crash. A cancelled update now ends quietly; one that actually fails is still logged as an error.
 - **H2D, H2D Pro and H2C with several AMS on one extruder marked the wrong tray as loaded on every filament change (#3242, reported by @Thomansky)** — These printers report the loading tray as a slot number (0-3) without saying which AMS it is in, and the report that says which AMS is read a moment later. In that gap Bambuddy assumed AMS 0: during a print feeding from AMS 2 slot 1, AMS 0 slot 1 showed as loaded for about a second, and that tray went into the list of tray changes that splits a print's filament between spools. Support packages show this on every filament change.
   - **Now:** while a print runs, the slot is matched against the trays the print uses, taken from the printer's own mapping. If that doesn't settle it, as in a multi-colour print with the same slot in two AMS, the tray already loaded stays marked until the printer says which one is feeding.

+ 49 - 8
backend/app/services/filament_deficit.py

@@ -565,9 +565,25 @@ async def build_slot_materials(db: AsyncSession, printer_id: int) -> list[SlotMa
 async def compute_deficit_for_queue_item(
     db: AsyncSession,
     item: PrintQueueItem,
+    *,
+    printer_id: int | None = None,
+    ams_mapping: list[int] | None = None,
+    require_known: bool = False,
 ) -> list[FilamentDeficit]:
     """Return per-slot filament shortfalls for ``item``, or [] when it's safe to dispatch.
 
+    ``printer_id`` and ``ams_mapping`` ask about a printer the item is not
+    assigned to yet, with the mapping it would get there: the model-based
+    matcher uses them to pass over a printer that would be short (#3137)
+    before it commits the item to one. Without them, the item's own columns
+    are read.
+
+    ``require_known`` also reports every printed slot whose remaining amount
+    cannot be determined (no tray mapped, no spool assigned, no label weight,
+    Spoolman not answering), with ``remaining_grams=None``. Everywhere else an
+    unknown amount lets the print through; a caller about to *start* a held
+    job on its own uses this so it only does so on a positive finding.
+
     Returns an empty list whenever any of the following hold:
 
     * The ``disable_filament_warnings`` setting is on.
@@ -591,7 +607,9 @@ async def compute_deficit_for_queue_item(
     """
     if await _warnings_disabled(db):
         return []
-    if item.printer_id is None:
+    if printer_id is None:
+        printer_id = item.printer_id
+    if printer_id is None:
         return []
 
     # Refresh the relationships we need without assuming the caller eagerly
@@ -627,12 +645,12 @@ async def compute_deficit_for_queue_item(
     if not requirements:
         return []
 
-    mapping = _parse_ams_mapping(item.ams_mapping)
+    mapping = ams_mapping if ams_mapping is not None else _parse_ams_mapping(item.ams_mapping)
     if not mapping:
         return []
 
     spoolman_mode = await _is_spoolman_mode(db)
-    backup_on, ams_extruder_map, is_dual = await _get_printer_backup_context(item.printer_id)
+    backup_on, ams_extruder_map, is_dual = await _get_printer_backup_context(printer_id)
 
     # ------------------------------------------------------------------ phase 1
     # Resolve each requirement to (ams_id, tray_id, identity, remaining_grams).
@@ -652,6 +670,21 @@ async def compute_deficit_for_queue_item(
         extruder: int
 
     resolved: list[_ReqRow] = []
+    # Printed slots whose remaining amount is unknown; only returned with
+    # ``require_known``.
+    undetermined: list[FilamentDeficit] = []
+
+    def _undetermined(req: dict, ams_id: int | None = None, tray_id: int | None = None) -> None:
+        undetermined.append(
+            FilamentDeficit(
+                slot_id=req["slot_id"],
+                ams_id=ams_id,
+                tray_id=tray_id,
+                filament_type=str(req.get("type", "")),
+                required_grams=float(req["used_grams"]),
+                remaining_grams=None,
+            )
+        )
 
     for req in requirements:
         slot_id = req.get("slot_id")
@@ -662,9 +695,11 @@ async def compute_deficit_for_queue_item(
             continue
         idx = slot_id - 1
         if idx >= len(mapping):
+            _undetermined(req)
             continue
         global_tray_id = mapping[idx]
         if global_tray_id is None or global_tray_id < 0:
+            _undetermined(req)
             continue
         ams_id, tray_id = _global_to_ams_key(global_tray_id)
 
@@ -673,13 +708,14 @@ async def compute_deficit_for_queue_item(
         if spoolman_mode:
             sm_result = await db.execute(
                 select(SpoolmanSlotAssignment).where(
-                    SpoolmanSlotAssignment.printer_id == item.printer_id,
+                    SpoolmanSlotAssignment.printer_id == printer_id,
                     SpoolmanSlotAssignment.ams_id == ams_id,
                     SpoolmanSlotAssignment.tray_id == tray_id,
                 )
             )
             sm_assignment = sm_result.scalar_one_or_none()
             if sm_assignment is None:
+                _undetermined(req, ams_id, tray_id)
                 continue
             # Live remaining_weight from Spoolman. The fetch also resolves the
             # filament identity for pooling (material + colour + name).
@@ -712,25 +748,28 @@ async def compute_deficit_for_queue_item(
                 select(SpoolAssignment)
                 .options(selectinload(SpoolAssignment.spool))
                 .where(
-                    SpoolAssignment.printer_id == item.printer_id,
+                    SpoolAssignment.printer_id == printer_id,
                     SpoolAssignment.ams_id == ams_id,
                     SpoolAssignment.tray_id == tray_id,
                 )
             )
             assignment = internal_result.scalar_one_or_none()
             if assignment is None or assignment.spool is None:
+                _undetermined(req, ams_id, tray_id)
                 continue
             spool = assignment.spool
             identity = _material_identity_internal(spool)
             label_weight = float(spool.label_weight or 0)
             weight_used = float(spool.weight_used or 0)
             if label_weight <= 0:
+                _undetermined(req, ams_id, tray_id)
                 continue
             remaining = max(0.0, label_weight - weight_used)
 
         if remaining is None:
             # Unable to determine remaining grams — preserve pre-#1762 behaviour
             # (don't block on undetermined data).
+            _undetermined(req, ams_id, tray_id)
             continue
 
         resolved.append(
@@ -751,8 +790,10 @@ async def compute_deficit_for_queue_item(
     # When backup is OFF, fall back to today's per-slot accounting (one-line
     # equivalence of the original loop), so this path is a strict no-op
     # behaviour-wise vs. the pre-#1762 code.
+    unknown = undetermined if require_known else []
+
     if not backup_on:
-        return [
+        return unknown + [
             FilamentDeficit(
                 slot_id=row.slot_id,
                 ams_id=row.ams_id,
@@ -773,7 +814,7 @@ async def compute_deficit_for_queue_item(
     pool_by_key: dict[tuple[str, int], float] = defaultdict(float)
     required_by_key: dict[tuple[str, int], float] = defaultdict(float)
 
-    for slot in await build_slot_materials(db, item.printer_id):
+    for slot in await build_slot_materials(db, printer_id):
         pool_by_key[(slot.material_key, slot.extruder)] += slot.remaining_grams
 
     for row in resolved:
@@ -797,7 +838,7 @@ async def compute_deficit_for_queue_item(
                 )
             )
 
-    return deficits
+    return unknown + deficits
 
 
 # Re-export the most useful pieces for callers that just want the data.

+ 214 - 7
backend/app/services/print_scheduler.py

@@ -7,7 +7,7 @@ import time
 import uuid
 import zipfile
 from collections import deque
-from collections.abc import Mapping
+from collections.abc import Awaitable, Callable, Mapping
 from dataclasses import dataclass
 from datetime import datetime, timedelta, timezone
 from pathlib import Path
@@ -604,6 +604,11 @@ def _is_tray_id(value: object) -> bool:
 # a staged item (#3074); this one is the reason it was staged, so it stays.
 _UNMATCHED_HOLD_PREFIX = "Needs "
 
+# How often an "any model" job held for low filament looks for another printer
+# of its model that has enough (#3137). Each look matches its filament on every
+# idle printer again, which a 30 s queue pass would otherwise repeat each time.
+_SHORT_HOLD_RECHECK_SECONDS = 120
+
 
 def _is_unmatched_hold_reason(reason: str | None) -> bool:
     """True for the reason the unmatched-filament hold wrote when it staged the item."""
@@ -1277,6 +1282,9 @@ class PrintScheduler:
         # pass. Cleared at the top of each pass so a re-sliced file is never
         # served from a previous tick.
         self._filament_req_memo: dict[tuple, list[dict] | None] = {}
+        # When each "any model" job held for low filament last looked for
+        # another printer (#3137), by queue item id.
+        self._short_hold_checked_at: dict[int, float] = {}
 
     async def run(self):
         """Main loop - check queue every interval."""
@@ -1543,6 +1551,11 @@ class PrintScheduler:
                 len(items),
                 [(i.id, i.printer_id, i.archive_id, i.library_file_id) for i in items],
             )
+            # Forget jobs that left the queue since they were last checked.
+            pending_ids = {i.id for i in items}
+            self._short_hold_checked_at = {
+                item_id: at for item_id, at in self._short_hold_checked_at.items() if item_id in pending_ids
+            }
 
             # Seed busy_printers with printers that already have an item in 'printing'
             # status. _is_printer_idle() alone is not sufficient as a dispatch gate —
@@ -1852,8 +1865,19 @@ class PrintScheduler:
                         skip_reasons["scheduled_future"] = skip_reasons.get("scheduled_future", 0) + 1
                         continue
 
-                # Skip items that require manual start
-                if item.manual_start:
+                # Skip items that require manual start, except an "any model"
+                # job held for low filament that another idle printer can now
+                # run: that one is moved there and carries on below (#3137).
+                if item.manual_start and not (
+                    item.filament_short
+                    and await self._release_short_model_hold(
+                        db,
+                        item,
+                        busy_printers | interlocked.keys(),
+                        require_plate_clear,
+                        await _creator_scope(item.created_by_id),
+                    )
+                ):
                     # Waiting on the user, not on a printer. Cleared here because
                     # this is the last pass that will look at the row: a staged
                     # item never reaches the branches below again, so a reason
@@ -1867,6 +1891,13 @@ class PrintScheduler:
                     skip_reasons["manual_start"] = skip_reasons.get("manual_start", 0) + 1
                     continue
 
+                if item.printer_id and item.printer_id not in pinned_printers:
+                    # Moved by the release above to a printer no row named when
+                    # the labels were read.
+                    moved_to = await self._get_printer(db, item.printer_id)
+                    if moved_to is not None:
+                        pinned_printers[item.printer_id] = (moved_to.name or "", moved_to.model or "")
+
                 if item.printer_id:
                     # Its creator may no longer use this printer (#1727): an
                     # admin took it away from their group after the job was
@@ -2166,6 +2197,17 @@ class PrintScheduler:
                             continue
 
                         wakeable_candidates.append(candidate)
+
+                        async def is_short(
+                            pid: int, item: PrintQueueItem = item, candidate: _ModelCandidate = candidate
+                        ) -> bool:
+                            # The check reads the file and plate off the item,
+                            # so it must hold this candidate's. Resolving is
+                            # safe to repeat, and the winner is resolved again
+                            # below.
+                            self._resolve_variant(item, candidate)
+                            return await self._filament_short_on(db, item, pid)
+
                         match_id, match_reason = await self._find_idle_printer_for_model(
                             db,
                             candidate.target_model,
@@ -2179,6 +2221,7 @@ class PrintScheduler:
                             require_plate_clear=require_plate_clear,
                             wakeable_ids=wakeable_printer_ids,
                             printer_scope=item_scope,
+                            is_short=is_short,
                         )
                         if match_id:
                             printer_id = match_id
@@ -3005,6 +3048,7 @@ class PrintScheduler:
         require_plate_clear: bool = True,
         wakeable_ids: set[int] | None = None,
         printer_scope: PrinterScope = ALL_PRINTERS,
+        is_short: Callable[[int], Awaitable[bool]] | None = None,
     ) -> tuple[int | None, str | None]:
         """Find an idle, connected printer matching the model with compatible filaments.
 
@@ -3022,6 +3066,11 @@ class PrintScheduler:
             wakeable_ids: Printers a smart plug can power on (#2786). Only changes how an
                           offline printer is worded: one Bambuddy will switch on reads
                           differently from one the user has to go and switch on themselves.
+            is_short: Says whether a printer's spools are too light for the job (#3137).
+                      A printer it flags is passed over while another eligible one
+                      is not. When every eligible printer is short, the first is
+                      returned all the same, so the deficit gate holds the job there
+                      and "Print Anyway" still has a printer to print on.
 
         Returns:
             Tuple of (printer_id, waiting_reason):
@@ -3045,6 +3094,9 @@ class PrintScheduler:
         printers_offline_no_plug = []
         printers_missing_filament: list[tuple[str, list[str]]] = []
         candidates: list[tuple[int, int]] = []  # (printer_id, color_match_count)
+        # The first eligible printer that is short on filament, kept in case
+        # every eligible printer is (#3137).
+        short_fallback: int | None = None
 
         for printer in printers:
             if printer.id in exclude_ids:
@@ -3141,18 +3193,36 @@ class PrintScheduler:
                     printers_missing_filament.append((printer.name, override_colors))
                     logger.debug("Skipping printer %s (%s) - no matching override colors", printer.id, printer.name)
                     continue
-            elif force_overrides:
-                # Passed all force checks — immediately eligible (no preference ordering needed)
-                return printer.id, None
             else:
-                # No overrides at all - take first available (existing behavior)
+                # No preference ordering needed: the first eligible printer
+                # wins, unless its spools are too light for the job (#3137).
+                if is_short is not None and await is_short(printer.id):
+                    if short_fallback is None:
+                        short_fallback = printer.id
+                    logger.info(
+                        "Model-based assignment: passing over printer %s (%s), not enough filament",
+                        printer.id,
+                        printer.name,
+                    )
+                    continue
                 return printer.id, None
 
         # If we have candidates from preference override matching, pick the one with most color matches
         if candidates:
             candidates.sort(key=lambda c: c[1], reverse=True)
+            if is_short is not None:
+                for candidate_id, _ in candidates:
+                    if not await is_short(candidate_id):
+                        return candidate_id, None
+                    logger.info(
+                        "Model-based assignment: passing over printer %s, not enough filament",
+                        candidate_id,
+                    )
             return candidates[0][0], None
 
+        if short_fallback is not None:
+            return short_fallback, None
+
         # Build waiting reason from what we found
         reasons = []
         if printers_missing_filament:
@@ -7630,6 +7700,143 @@ class PrintScheduler:
         except Exception:
             pass  # toast is best-effort
 
+    async def _filament_short_on(
+        self,
+        db: AsyncSession,
+        item: PrintQueueItem,
+        printer_id: int,
+        *,
+        require_known: bool = False,
+    ) -> bool:
+        """Would ``item`` be held for a filament deficit on ``printer_id`` (#3137)?
+
+        Asks what ``_block_on_filament_deficit`` would answer once the item is
+        assigned there, with the mapping the item would get there, but changes
+        nothing. The model-based matcher uses it to prefer a printer whose
+        spools can finish the job.
+
+        False whenever the gate would let the item through: "Print Anyway",
+        no mapping, or a check that fails. With ``require_known`` all three of
+        those, and any printed slot whose remaining amount is unknown, count
+        as short instead: releasing a held job starts it with nobody asked, so
+        it needs the amount on record, not merely nothing against it.
+        """
+        if item.skip_filament_check:
+            return False
+        try:
+            mapping = await self._compute_ams_mapping_for_printer(db, printer_id, item)
+            if not mapping:
+                return require_known
+            deficit = await compute_deficit_for_queue_item(
+                db, item, printer_id=printer_id, ams_mapping=mapping, require_known=require_known
+            )
+        except Exception as e:
+            logger.warning("Filament deficit check failed for item %s on printer %s: %s", item.id, printer_id, e)
+            return require_known
+        return bool(deficit)
+
+    async def _release_short_model_hold(
+        self,
+        db: AsyncSession,
+        item: PrintQueueItem,
+        unavailable: set[int],
+        require_plate_clear: bool,
+        printer_scope: PrinterScope,
+    ) -> bool:
+        """Free an "any model" job held for low filament once a printer can run it (#3137).
+
+        The deficit gate holds a model-based job on the printer the matcher
+        gave it, because "Print Anyway" needs a printer to print on. When
+        another idle printer of the job's model has the filament, the job is
+        moved to that printer and its hold lifted, and the fixed-printer
+        branch dispatches it in this same pass, through the same checks a
+        held job meets when the user presses Start. It is moved rather than
+        put back in the pool because the model-based branch would pick again
+        without requiring the amounts on record (below), and could pick a
+        different printer. Only the deficit gate sets ``filament_short``, so a
+        job held for any other reason stays held.
+
+        The printer it is held on is left out: a spool loaded there is started
+        with Start, as before, rather than while someone may still be loading
+        the next one. Each job is checked at most every
+        ``_SHORT_HOLD_RECHECK_SECONDS``, as every check matches its filament
+        on each idle printer again.
+
+        Cross-model jobs (#671) stay held: checking a variant writes its file
+        onto the item, which would leave a job that is not moved holding
+        another variant's file for the printer it waits on.
+
+        A printer qualifies only on a positive finding: every slot the plate
+        prints mapped to a spool whose remaining amount is on record and
+        enough. A printer whose spools Bambuddy does not track is not one.
+
+        Returns True when the job was moved. A check that fails keeps the hold
+        rather than stopping the queue pass.
+        """
+        if not item.filament_short or item.skip_filament_check or item.printer_id is None:
+            return False
+        if not item.target_model or item.variants:
+            return False
+        now = time.monotonic()
+        if now - self._short_hold_checked_at.get(item.id, float("-inf")) < _SHORT_HOLD_RECHECK_SECONDS:
+            return False
+        self._short_hold_checked_at[item.id] = now
+
+        try:
+            candidates = _candidates_for(item)
+            if not candidates:
+                return False
+            effective_types, filament_overrides = _filament_constraints(candidates[0])
+
+            async def is_short(pid: int) -> bool:
+                return await self._filament_short_on(db, item, pid, require_known=True)
+
+            match_id, _ = await self._find_idle_printer_for_model(
+                db,
+                item.target_model,
+                unavailable | {item.printer_id},
+                effective_types,
+                item.target_location,
+                filament_overrides=filament_overrides,
+                require_plate_clear=require_plate_clear,
+                printer_scope=printer_scope,
+                is_short=is_short,
+            )
+            # Every idle printer short comes back as the first of them.
+            if match_id is None or await is_short(match_id):
+                return False
+        except Exception as e:
+            logger.warning("Queue item %s: looking for another printer failed: %s", item.id, e)
+            return False
+
+        logger.info(
+            "Queue item %s: printer %s has the filament it was short of on printer %s — moving it there",
+            item.id,
+            match_id,
+            item.printer_id,
+        )
+        self._short_hold_checked_at.pop(item.id, None)
+        item.printer_id = match_id
+        # Resolved against the printer it leaves (#3239).
+        item.ams_mapping = None
+        item.manual_start = False
+        item.filament_short = False
+        item.waiting_reason = None
+        await db.commit()
+
+        try:
+            printer = await self._get_printer(db, match_id)
+            await notification_service.on_queue_job_assigned(
+                job_name=await self._get_job_name(db, item),
+                printer_id=match_id,
+                printer_name=printer.name if printer else "Unknown",
+                target_model=item.target_model,
+                db=db,
+            )
+        except Exception as e:
+            logger.debug("Assignment notification failed for item %s: %s", item.id, e)
+        return True
+
     async def _block_on_filament_deficit(
         self,
         db: AsyncSession,

+ 89 - 0
backend/tests/unit/services/test_filament_deficit.py

@@ -219,6 +219,95 @@ class TestFilamentDeficit:
 
         assert deficit == []
 
+    @pytest.mark.asyncio
+    async def test_checks_a_printer_the_item_is_not_assigned_to(self, db_session, printer_factory, tmp_path):
+        """The model-based matcher asks before assigning (#3137): the printer
+        and mapping it passes are read, not the item's empty columns."""
+        light = await printer_factory()
+        full = await printer_factory()
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
+        )
+        short_spool = await _spool(db_session, label_weight=1000, weight_used=970.0)  # 30g left
+        await _assign(db_session, printer_id=light.id, spool_id=short_spool.id, ams_id=0, tray_id=1)
+        full_spool = await _spool(db_session, label_weight=1000, weight_used=200.0)  # 800g left
+        await _assign(db_session, printer_id=full.id, spool_id=full_spool.id, ams_id=0, tray_id=1)
+        item = await _queue_item(db_session, printer_id=None, archive=archive, ams_mapping=None)
+
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            on_light = await compute_deficit_for_queue_item(db_session, item, printer_id=light.id, ams_mapping=[1])
+            on_full = await compute_deficit_for_queue_item(db_session, item, printer_id=full.id, ams_mapping=[1])
+
+        assert [d.remaining_grams for d in on_light] == [30.0]
+        assert on_full == []
+
+    @pytest.mark.asyncio
+    async def test_passed_mapping_wins_over_the_stored_one(self, db_session, printer_factory, tmp_path):
+        printer = await printer_factory()
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
+        )
+        short_spool = await _spool(db_session, label_weight=1000, weight_used=970.0)
+        await _assign(db_session, printer_id=printer.id, spool_id=short_spool.id, ams_id=0, tray_id=0)
+        full_spool = await _spool(db_session, label_weight=1000, weight_used=200.0)
+        await _assign(db_session, printer_id=printer.id, spool_id=full_spool.id, ams_id=0, tray_id=1)
+        item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
+
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            stored = await compute_deficit_for_queue_item(db_session, item)
+            passed = await compute_deficit_for_queue_item(db_session, item, ams_mapping=[1])
+
+        assert len(stored) == 1
+        assert passed == []
+
+    @pytest.mark.asyncio
+    async def test_require_known_reports_slots_it_cannot_measure(self, db_session, printer_factory, tmp_path):
+        """Unknown amounts let a print through, except where the caller asks for
+        them on record (#3137): a slot with no spool, and a slot with no tray."""
+        printer = await printer_factory()
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [
+                {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"},
+                {"id": "2", "type": "PLA", "color": "#000000", "used_g": "50.0"},
+                {"id": "3", "type": "PLA", "color": "#FF0000", "used_g": "20.0"},
+            ],
+        )
+        spool = await _spool(db_session, label_weight=1000, weight_used=200.0)
+        await _assign(db_session, printer_id=printer.id, spool_id=spool.id, ams_id=0, tray_id=0)
+        item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=None)
+
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            lenient = await compute_deficit_for_queue_item(db_session, item, ams_mapping=[0, 1, -1])
+            strict = await compute_deficit_for_queue_item(db_session, item, ams_mapping=[0, 1, -1], require_known=True)
+
+        assert lenient == []
+        assert sorted((d.slot_id, d.remaining_grams) for d in strict) == [(2, None), (3, None)]
+
+    @pytest.mark.asyncio
+    async def test_require_known_passes_a_printer_with_everything_on_record(
+        self, db_session, printer_factory, tmp_path
+    ):
+        printer = await printer_factory()
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
+        )
+        spool = await _spool(db_session, label_weight=1000, weight_used=200.0)
+        await _assign(db_session, printer_id=printer.id, spool_id=spool.id, ams_id=0, tray_id=0)
+        item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
+
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            strict = await compute_deficit_for_queue_item(db_session, item, require_known=True)
+
+        assert strict == []
+
     @pytest.mark.asyncio
     async def test_returns_empty_when_warnings_disabled(self, db_session, printer_factory, tmp_path):
         """Honour the disable_filament_warnings setting (#720 toggle)."""

+ 430 - 0
backend/tests/unit/test_scheduler_model_filament_short_3137.py

@@ -0,0 +1,430 @@
+"""An "any model" job goes to a printer that has the filament for it (#3137).
+
+The reporter's farm queues jobs for "any H2D Pro". The matcher took the first
+idle H2D Pro with the right filament *type*, and only then did the deficit gate
+look at the amount. It held the job on that printer, pinned there, while
+another idle H2D Pro had plenty. The job only ran once the user moved it there
+by hand.
+
+Now the matcher passes over a printer that would be short while another
+eligible one is not. When every idle printer is short the job is held on the
+first, as before, so "Print Anyway" still has a printer to print on; and once
+another printer of its model can run it, the hold is released and the job
+goes there.
+"""
+
+from contextlib import ExitStack
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+from backend.app.core.database import Base
+from backend.app.models.library import LibraryFile
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.services.filament_deficit import FilamentDeficit
+from backend.app.services.print_scheduler import PrintScheduler
+
+_DEFICIT = [
+    FilamentDeficit(
+        slot_id=1,
+        ams_id=0,
+        tray_id=0,
+        filament_type="PLA",
+        required_grams=120.0,
+        remaining_grams=40.0,
+    )
+]
+
+
+@pytest.fixture
+async def queue_db():
+    """Three X1Cs, in id order, so "the first idle printer" is well defined."""
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    session_maker = async_sessionmaker(engine, expire_on_commit=False)
+
+    async with session_maker() as db:
+        db.add_all(
+            [
+                Printer(
+                    id=pid,
+                    name=f"X1C-{pid}",
+                    serial_number=f"X1C000{pid}",
+                    ip_address=f"10.0.0.{pid}",
+                    access_code="x",
+                    model="X1C",
+                    is_active=True,
+                )
+                for pid in (1, 2, 3)
+            ]
+        )
+        await db.commit()
+
+    try:
+        yield SimpleNamespace(session_maker=session_maker)
+    finally:
+        await engine.dispose()
+
+
+async def _add_item(ctx, **fields):
+    async with ctx.session_maker() as db:
+        lib = LibraryFile(
+            filename="job.gcode.3mf",
+            file_path="/library/job.gcode.3mf",
+            file_size=10,
+            file_type="gcode.3mf",
+            file_metadata={"sliced_for_model": "X1C"},
+        )
+        db.add(lib)
+        await db.flush()
+        values = {"status": "pending", "position": 1, "target_model": "X1C", "library_file_id": lib.id}
+        values.update(fields)
+        item = PrintQueueItem(**values)
+        db.add(item)
+        await db.commit()
+        return item.id
+
+
+async def _get_item(ctx, item_id):
+    async with ctx.session_maker() as db:
+        return (await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
+
+
+def _short_on(short: set[int], unknown: frozenset[int] = frozenset()) -> AsyncMock:
+    """``_filament_short_on`` stand-in: the printers in ``short`` are too light,
+    and those in ``unknown`` have spools whose amount is not on record."""
+
+    async def check(db, item, printer_id, *, require_known=False):
+        return printer_id in short or (require_known and printer_id in unknown)
+
+    return AsyncMock(side_effect=check)
+
+
+async def _run(
+    ctx, scheduler, *, short: set[int], launched, idle=lambda pid: True, short_check=None, unknown=frozenset()
+):
+    """One queue pass. The deficit gate is real; only the deficit it reads is faked."""
+
+    async def deficit(db, item, *, printer_id=None, ams_mapping=None):
+        return _DEFICIT if (printer_id or item.printer_id) in short else []
+
+    patches = [
+        patch("backend.app.services.print_scheduler.async_session", ctx.session_maker),
+        patch("backend.app.core.database.async_session", ctx.session_maker),
+        patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
+        patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
+        patch(
+            "backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers",
+            AsyncMock(return_value={}),
+        ),
+        patch(
+            "backend.app.services.notification_service.notification_service.on_queue_job_waiting",
+            AsyncMock(),
+        ),
+        patch(
+            "backend.app.services.notification_service.notification_service.on_queue_job_assigned",
+            AsyncMock(),
+        ),
+        patch("backend.app.services.print_scheduler.compute_deficit_for_queue_item", AsyncMock(side_effect=deficit)),
+        patch.object(scheduler, "_filament_short_on", short_check or _short_on(short, unknown)),
+        patch.object(scheduler, "_is_printer_idle", MagicMock(side_effect=lambda pid, *a, **k: idle(pid))),
+        patch.object(scheduler, "_check_auto_drying", AsyncMock()),
+        patch.object(scheduler, "_ensure_ams_mapping", AsyncMock(return_value=None)),
+        patch.object(scheduler, "_block_on_unmatched_filament", AsyncMock(return_value=False)),
+        patch.object(scheduler, "_launch_uploads", launched),
+    ]
+    with ExitStack() as stack:
+        for p in patches:
+            stack.enter_context(p)
+        return await scheduler.check_queue()
+
+
+class TestMatcher:
+    """``_find_idle_printer_for_model`` with and without the shortfall check."""
+
+    async def _find(self, ctx, scheduler, *, short=None, **kwargs):
+        async def is_short(pid):
+            return pid in short
+
+        with (
+            patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
+            patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=True)),
+        ):
+            async with ctx.session_maker() as db:
+                return await scheduler._find_idle_printer_for_model(
+                    db, "X1C", set(), is_short=is_short if short is not None else None, **kwargs
+                )
+
+    @pytest.mark.asyncio
+    async def test_passes_over_a_short_printer(self, queue_db):
+        assert await self._find(queue_db, PrintScheduler(), short={1}) == (2, None)
+
+    @pytest.mark.asyncio
+    async def test_every_printer_short_returns_the_first(self, queue_db):
+        """The deficit gate then holds the job there, where Print Anyway can start it."""
+        assert await self._find(queue_db, PrintScheduler(), short={1, 2, 3}) == (1, None)
+
+    @pytest.mark.asyncio
+    async def test_without_the_check_the_first_idle_printer_wins(self, queue_db):
+        assert await self._find(queue_db, PrintScheduler()) == (1, None)
+
+    @pytest.mark.asyncio
+    async def test_force_colour_jobs_pass_over_a_short_printer(self, queue_db):
+        scheduler = PrintScheduler()
+        overrides = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "force_color_match": True}]
+        with patch.object(scheduler, "_get_missing_force_color_slots", MagicMock(return_value=[])):
+            result = await self._find(queue_db, scheduler, short={1}, filament_overrides=overrides)
+        assert result == (2, None)
+
+    @pytest.mark.asyncio
+    async def test_colour_ranking_takes_the_best_printer_that_is_not_short(self, queue_db):
+        scheduler = PrintScheduler()
+        overrides = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        matches = {1: 1, 2: 2, 3: 1}
+        with patch.object(
+            scheduler, "_count_override_color_matches", MagicMock(side_effect=lambda pid, _o: matches[pid])
+        ):
+            ranked_first = await self._find(queue_db, scheduler, short=set(), filament_overrides=overrides)
+            best_is_short = await self._find(queue_db, scheduler, short={2}, filament_overrides=overrides)
+            all_short = await self._find(queue_db, scheduler, short={1, 2, 3}, filament_overrides=overrides)
+
+        assert ranked_first == (2, None)
+        assert best_is_short == (1, None)
+        assert all_short == (2, None)
+
+
+class TestAssignment:
+    @pytest.mark.asyncio
+    async def test_goes_to_the_printer_with_enough_filament(self, queue_db):
+        """The reporter's case: printer 1 is idle but light, printer 2 idle and full."""
+        item_id = await _add_item(queue_db)
+        launched = MagicMock()
+
+        await _run(queue_db, PrintScheduler(), short={1}, launched=launched)
+
+        launched.assert_called_once()
+        assert launched.call_args[0][0] == [item_id]
+        item = await _get_item(queue_db, item_id)
+        assert item.printer_id == 2
+        assert item.manual_start is False
+        assert item.filament_short is False
+
+    @pytest.mark.asyncio
+    async def test_held_on_the_first_printer_when_every_idle_printer_is_short(self, queue_db):
+        item_id = await _add_item(queue_db)
+        launched = MagicMock()
+
+        await _run(queue_db, PrintScheduler(), short={1, 2, 3}, launched=launched)
+
+        launched.assert_not_called()
+        item = await _get_item(queue_db, item_id)
+        assert item.printer_id == 1
+        assert item.manual_start is True
+        assert item.filament_short is True
+
+    @pytest.mark.asyncio
+    async def test_print_anyway_is_not_second_guessed(self, queue_db):
+        """With Print Anyway set, no printer is passed over for being short."""
+        item_id = await _add_item(queue_db, skip_filament_check=True)
+        launched = MagicMock()
+        scheduler = PrintScheduler()
+
+        with patch(
+            "backend.app.services.print_scheduler.PrintScheduler._compute_ams_mapping_for_printer",
+            AsyncMock(return_value=[0]),
+        ):
+            await _run(
+                queue_db,
+                scheduler,
+                short={1},
+                launched=launched,
+                short_check=AsyncMock(side_effect=scheduler._filament_short_on),
+            )
+
+        launched.assert_called_once()
+        assert (await _get_item(queue_db, item_id)).printer_id == 1
+
+
+class TestHeldJob:
+    @pytest.mark.asyncio
+    async def test_moves_to_another_printer_that_has_the_filament(self, queue_db):
+        item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
+        launched = MagicMock()
+
+        await _run(queue_db, PrintScheduler(), short={1}, launched=launched)
+
+        launched.assert_called_once()
+        assert launched.call_args[0][0] == [item_id]
+        item = await _get_item(queue_db, item_id)
+        assert item.printer_id == 2
+        assert item.manual_start is False
+        assert item.filament_short is False
+
+    @pytest.mark.asyncio
+    async def test_stays_held_while_every_other_printer_is_short(self, queue_db):
+        item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
+        launched = MagicMock()
+
+        await _run(queue_db, PrintScheduler(), short={1, 2, 3}, launched=launched)
+
+        launched.assert_not_called()
+        item = await _get_item(queue_db, item_id)
+        assert item.printer_id == 1
+        assert item.manual_start is True
+        assert item.filament_short is True
+
+    @pytest.mark.asyncio
+    async def test_stays_held_while_the_other_printers_are_busy(self, queue_db):
+        """Item 2080 in the reporter's log: the printer it waits on was the only idle one."""
+        item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
+        launched = MagicMock()
+        short_check = _short_on({1})
+
+        await _run(
+            queue_db, PrintScheduler(), short={1}, launched=launched, idle=lambda pid: pid == 1, short_check=short_check
+        )
+
+        launched.assert_not_called()
+        assert (await _get_item(queue_db, item_id)).printer_id == 1
+        # The printer it is held on is not checked again: a spool loaded there
+        # is started with Start, as before.
+        short_check.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_a_job_the_user_put_on_a_printer_is_not_moved(self, queue_db):
+        item_id = await _add_item(queue_db, target_model=None, printer_id=1, manual_start=True, filament_short=True)
+        launched = MagicMock()
+
+        await _run(queue_db, PrintScheduler(), short={1}, launched=launched)
+
+        launched.assert_not_called()
+        item = await _get_item(queue_db, item_id)
+        assert item.printer_id == 1
+        assert item.manual_start is True
+
+    @pytest.mark.asyncio
+    async def test_a_job_held_for_another_reason_is_not_moved(self, queue_db):
+        """Only the deficit gate sets filament_short; a plain manual start waits for the user."""
+        item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=False)
+        launched = MagicMock()
+        short_check = _short_on(set())
+
+        await _run(queue_db, PrintScheduler(), short=set(), launched=launched, short_check=short_check)
+
+        launched.assert_not_called()
+        assert (await _get_item(queue_db, item_id)).manual_start is True
+        short_check.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_not_moved_to_a_printer_whose_spools_are_not_tracked(self, queue_db):
+        """Moving starts the job with nobody asked, so "nothing against it" is not enough."""
+        item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
+        launched = MagicMock()
+
+        await _run(queue_db, PrintScheduler(), short={1}, unknown={2, 3}, launched=launched)
+
+        launched.assert_not_called()
+        item = await _get_item(queue_db, item_id)
+        assert item.printer_id == 1
+        assert item.manual_start is True
+
+    @pytest.mark.asyncio
+    async def test_goes_to_the_printer_that_was_checked(self, queue_db):
+        """Printer 2 comes first and has nothing against it, but only printer 3
+        has the amount on record: the job goes to 3, not to whichever printer a
+        fresh pick would find first."""
+        item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
+        launched = MagicMock()
+
+        await _run(queue_db, PrintScheduler(), short={1}, unknown={2}, launched=launched)
+
+        launched.assert_called_once()
+        item = await _get_item(queue_db, item_id)
+        assert item.printer_id == 3
+        assert item.target_model == "X1C"
+        assert item.manual_start is False
+        assert item.filament_short is False
+        assert item.waiting_reason is None
+
+    @pytest.mark.asyncio
+    async def test_a_failing_check_keeps_the_hold_and_the_pass_going(self, queue_db):
+        held_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
+        other_id = await _add_item(queue_db, position=2, target_model=None, printer_id=3)
+        launched = MagicMock()
+        scheduler = PrintScheduler()
+
+        with patch.object(scheduler, "_find_idle_printer_for_model", AsyncMock(side_effect=RuntimeError("boom"))):
+            await _run(queue_db, scheduler, short={1}, launched=launched)
+
+        held = await _get_item(queue_db, held_id)
+        assert held.printer_id == 1
+        assert held.manual_start is True
+        launched.assert_called_once()
+        assert launched.call_args[0][0] == [other_id]
+
+    @pytest.mark.asyncio
+    async def test_looks_again_only_after_the_recheck_interval(self, queue_db):
+        item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
+        scheduler = PrintScheduler()
+        short_check = _short_on({1, 2, 3})
+
+        await _run(queue_db, scheduler, short={1, 2, 3}, launched=MagicMock(), short_check=short_check)
+        first_pass_calls = short_check.await_count
+        await _run(queue_db, scheduler, short={1, 2, 3}, launched=MagicMock(), short_check=short_check)
+        assert short_check.await_count == first_pass_calls
+
+        scheduler._short_hold_checked_at[item_id] -= 121
+        launched = MagicMock()
+        await _run(queue_db, scheduler, short={1}, launched=launched)
+
+        launched.assert_called_once()
+        assert (await _get_item(queue_db, item_id)).printer_id == 2
+
+
+class TestFilamentShortOn:
+    """``_filament_short_on``'s answer where it cannot see the amounts."""
+
+    @staticmethod
+    async def _ask(*, mapping=None, deficit=None, raises=None, skip=False, require_known=False):
+        scheduler = PrintScheduler()
+        item = PrintQueueItem(id=7, skip_filament_check=skip)
+        compute = AsyncMock(side_effect=raises) if raises else AsyncMock(return_value=mapping)
+        with (
+            patch.object(scheduler, "_compute_ams_mapping_for_printer", compute),
+            patch(
+                "backend.app.services.print_scheduler.compute_deficit_for_queue_item",
+                AsyncMock(return_value=deficit or []),
+            ),
+        ):
+            return await scheduler._filament_short_on(MagicMock(), item, 1, require_known=require_known)
+
+    @pytest.mark.asyncio
+    async def test_a_deficit_is_short(self):
+        assert await self._ask(mapping=[0], deficit=_DEFICIT) is True
+        assert await self._ask(mapping=[0], deficit=_DEFICIT, require_known=True) is True
+
+    @pytest.mark.asyncio
+    async def test_enough_is_not_short(self):
+        assert await self._ask(mapping=[0]) is False
+        assert await self._ask(mapping=[0], require_known=True) is False
+
+    @pytest.mark.asyncio
+    async def test_no_mapping_counts_against_the_printer_only_when_required_known(self):
+        assert await self._ask(mapping=None) is False
+        assert await self._ask(mapping=None, require_known=True) is True
+
+    @pytest.mark.asyncio
+    async def test_a_failing_check_counts_against_the_printer_only_when_required_known(self):
+        assert await self._ask(raises=RuntimeError("boom")) is False
+        assert await self._ask(raises=RuntimeError("boom"), require_known=True) is True
+
+    @pytest.mark.asyncio
+    async def test_print_anyway_is_never_short(self):
+        assert await self._ask(mapping=[0], deficit=_DEFICIT, skip=True) is False
+        assert await self._ask(mapping=[0], deficit=_DEFICIT, skip=True, require_known=True) is False