Ver Fonte

fix(queue): AMS drying applies to "Any <model>" jobs

- With "Wait for drying to complete" on, a model job passes over a drying
  printer for another of the model (also when a low-filament hold is moved),
  and waits only while every one of them dries.
- The printer a model job lands on stops drying before the print, as for a
  job pinned to it.
- Queue auto-drying counts a model job for every printer it could run on
  (each candidate model, its location, its creator's printers).
maziggy há 5 dias atrás
pai
commit
1ffcfa2600

+ 1 - 0
CHANGELOG.md

@@ -164,6 +164,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
+- **AMS drying now applies to "Any <model>" queue jobs** — With **Wait for drying to complete** on, a job for "Any X1C" could still start on an X1C whose AMS was drying, and its drying was never stopped for the print; queue auto-drying also never dried anything for such a job. Now a model job passes over a drying printer for another of the model, and waits only while every one of them is drying. The printer it lands on stops drying like it would for a job pinned to it, and a scheduled model job has queue auto-drying dry every printer it could run on.
 - **Every way of adding a queue job now works like the print dialog** — Jobs added from File Manager's **Add to queue**, the webhook, a virtual printer, a pipeline run or an order's **Dispatch remaining** sent no "Job added" notification and no MQTT `queue/job_added` event; now every path sends them. Several jobs added at once are one notification per printer, such as "3 jobs (bracket, hinge, lid)", and MQTT gets one event per job. File Manager, the webhook and pipeline runs ignored the default print options (timelapse, bed levelling and the rest) and now use them. The copies an order dispatches lost their cost centre, so they were billed to none and never checked against its budget; they now keep it, and with billing on, dispatching is refused when you may not print with that cost centre or its budget cannot cover every copy. A `scheduled_time` sent to the API or webhook with a UTC offset, such as `10:00+02:00`, was stored as 10:00 UTC and the job started two hours late; the offset is now converted.
 - **Long prints keep their layer timelapse** — Stitching a layer timelapse was stopped after 5 minutes and its frames were then deleted, so a print with more than a few hundred layers on a Raspberry Pi lost its timelapse for good. Stitching now gets half a second per layer, from 5 minutes up to an hour. If ffmpeg fails anyway, or Bambuddy stops while it runs, the frames are kept for about 24 hours with an ffmpeg command to make the video by hand; when ffmpeg fails, the log names the folder. Without ffmpeg the frames are still deleted.
 - **An in-app update that hangs no longer blocks further updates** — A stalled `git fetch`, `pip install` or `npm install` left the update showing "downloading" or "installing" forever, and every new attempt was refused with "Update already in progress" until Bambuddy was restarted. Each step now has a time limit (5 minutes for git, 15 for pip and npm); a git or pip step that runs out of time ends the update with an error, and checking for updates again lets you retry. When pip or `git reset` is the one that ran out of time the new code may already be in place, so run the update again before restarting. A frontend build that runs out of time keeps the files shipped with the release.

+ 65 - 5
backend/app/services/print_scheduler.py

@@ -1901,6 +1901,15 @@ class PrintScheduler:
             scope_auth_enabled = await is_auth_enabled(db)
             creator_scopes: dict[int, PrinterScope] = {}
 
+            # Printers a model job passes over while drying blocks the queue,
+            # as a pinned job waits for its own. A parked timer never ends,
+            # so it does not hold (#2896).
+            drying_held: set[int] = (
+                {pid for pid in self._drying_in_progress if not self._drying_is_only_parked(pid)}
+                if self._drying_in_progress and await self._get_bool_setting(db, "queue_drying_block")
+                else set()
+            )
+
             async def _creator_scope(user_id: int | None) -> PrinterScope:
                 if not scope_auth_enabled or user_id is None:
                     return ALL_PRINTERS
@@ -1928,7 +1937,7 @@ class PrintScheduler:
                     and await self._release_short_model_hold(
                         db,
                         item,
-                        busy_printers | interlocked.keys(),
+                        busy_printers | interlocked.keys() | drying_held,
                         require_plate_clear,
                         await _creator_scope(item.created_by_id),
                     )
@@ -2279,7 +2288,7 @@ class PrintScheduler:
                                 # Sensor-held printers are unavailable to the
                                 # matcher but stay out of busy_printers itself
                                 # (#1148) — see where `interlocked` is built.
-                                busy_printers | interlocked.keys() | gate_blocked,
+                                busy_printers | interlocked.keys() | gate_blocked | drying_held,
                                 effective_types,
                                 item.target_location,
                                 filament_overrides=filament_overrides,
@@ -2437,6 +2446,13 @@ class PrintScheduler:
                         if await self._block_on_unmatched_filament(db, item, release_assignment=True):
                             continue
 
+                        # As for a pinned job: drying stops once the print is
+                        # decided, unless it may run through it.
+                        if self._drying_in_progress.get(
+                            printer_id
+                        ) and not await self._drying_may_continue_through_print(db, printer_id):
+                            await self._stop_drying(printer_id)
+
                         _claim_library_row(item)
                         dispatch_ids.append(item.id)
                         claim_printer(printer_id)
@@ -5671,6 +5687,38 @@ class PrintScheduler:
         for key in [k for k in self._notified_stock_alerts if k not in forecasts]:
             del self._notified_stock_alerts[key]
 
+    @staticmethod
+    async def _creator_printer_scope(db: AsyncSession, user_id: int | None) -> PrinterScope:
+        """The printers a job's creator may use (#1727), as the queue pass reads them."""
+        from backend.app.core.auth import is_auth_enabled
+
+        if user_id is None or not await is_auth_enabled(db):
+            return ALL_PRINTERS
+        return await resolve_user_id_printer_scope(db, user_id)
+
+    async def _model_job_printer_ids(
+        self,
+        db: AsyncSession,
+        item: PrintQueueItem,
+        scope: PrinterScope,
+        cache: dict[tuple[str, str | None, int | None], set[int]],
+    ) -> set[int]:
+        """Every printer an "Any <model>" job could land on: the active printers
+        of each of its candidate models, in its location, that its creator may use.
+
+        ``cache`` holds one lookup per model, location and creator for the pass,
+        so a long queue of model jobs does not query once per job."""
+        printer_ids: set[int] = set()
+        for candidate in _candidates_for(item):
+            if not candidate.target_model:
+                continue
+            key = (candidate.target_model, item.target_location, item.created_by_id)
+            if key not in cache:
+                printers = await self._printers_for_model(db, candidate.target_model, item.target_location, scope)
+                cache[key] = {printer.id for printer in printers}
+            printer_ids |= cache[key]
+        return printer_ids
+
     async def _check_auto_drying(
         self,
         db: AsyncSession,
@@ -5715,13 +5763,25 @@ class PrintScheduler:
         self._sync_drying_state()
 
         # Find printers with scheduled items (for queue drying mode)
+        # An "Any <model>" job counts for every printer it could land on.
         printers_with_scheduled: set[int] = set()
         printers_with_items: set[int] = set()
+        creator_scopes: dict[int | None, PrinterScope] = {}
+        model_printers: dict[tuple[str, str | None, int | None], set[int]] = {}
         for item in queue_items:
             if item.printer_id:
-                printers_with_items.add(item.printer_id)
-                if item.scheduled_time and not item.manual_start:
-                    printers_with_scheduled.add(item.printer_id)
+                item_printers = {item.printer_id}
+            elif item.target_model or item.variants:
+                if item.created_by_id not in creator_scopes:
+                    creator_scopes[item.created_by_id] = await self._creator_printer_scope(db, item.created_by_id)
+                item_printers = await self._model_job_printer_ids(
+                    db, item, creator_scopes[item.created_by_id], model_printers
+                )
+            else:
+                continue
+            printers_with_items |= item_printers
+            if item.scheduled_time and not item.manual_start:
+                printers_with_scheduled |= item_printers
 
         # If only queue mode is on and no printers have scheduled items, stop drying
         # (but skip this short-circuit when print_drying_enabled is on — busy printers

+ 295 - 0
backend/tests/unit/test_scheduler_model_job_drying_c5.py

@@ -0,0 +1,295 @@
+"""Drying applies to "Any <model>" jobs too (SP-C1).
+
+The drying hold ("drying blocks the queue"), the stop before a print starts and
+queue-only drying all lived in the branch for jobs pinned to one printer. A job
+for "Any X1C" went straight to the matcher: it could start on an X1C whose AMS
+was mid-dry with the hold switched on, the dry cycle was never stopped for it,
+and queue-only drying never pre-dried anything for it.
+
+Now a drying printer is passed over for a model job while the hold is on (the
+job takes another printer of the model, or waits), the printer a model job
+lands on has its drying stopped like a pinned job's, and a model job counts for
+every printer of its model its creator may use when drying decides what to dry.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.core.printer_scope import PrinterScope
+from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
+from backend.app.models.printer import Printer
+from backend.app.services.print_scheduler import PrintScheduler
+from backend.tests.unit.test_scheduler_model_filament_short_3137 import queue_db  # noqa: F401 - fixture
+from backend.tests.unit.test_scheduler_pinned_waiting_reason_3074 import (
+    _add_item,
+    _item,
+    _run,
+    _set,
+    ctx,  # noqa: F401 - fixture
+)
+
+
+async def _add_printer(ctx, printer_id: int, model: str = "X1C") -> None:
+    async with ctx.session_maker() as db:
+        db.add(
+            Printer(
+                id=printer_id,
+                name=f"{model}-0{printer_id}",
+                serial_number=f"{model}000{printer_id}",
+                ip_address=f"10.0.0.{printer_id}",
+                access_code="x",
+                model=model,
+                is_active=True,
+            )
+        )
+        await db.commit()
+
+
+def _dispatched_to(launched) -> dict[int, int]:
+    return launched.call_args[0][1] if launched.called else {}
+
+
+class TestTheHoldCoversModelJobs:
+    @pytest.mark.asyncio
+    async def test_a_drying_printer_is_passed_over_for_another_of_the_model(self, ctx):
+        await _set(ctx, "queue_drying_block", "true")
+        await _add_printer(ctx, 2)
+        item_id = await _add_item(ctx, printer_id=None, target_model="X1C")
+        scheduler = PrintScheduler()
+        scheduler._drying_in_progress[1] = 1.0
+
+        launched = await _run(ctx, scheduler, idle=True)
+
+        assert _dispatched_to(launched).get(item_id) == 2
+
+    @pytest.mark.asyncio
+    async def test_it_waits_while_every_printer_of_the_model_dries(self, ctx):
+        await _set(ctx, "queue_drying_block", "true")
+        item_id = await _add_item(ctx, printer_id=None, target_model="X1C")
+        scheduler = PrintScheduler()
+        scheduler._drying_in_progress[1] = 1.0
+
+        launched = await _run(ctx, scheduler, idle=True)
+
+        launched.assert_not_called()
+        item = await _item(ctx, item_id)
+        assert item.printer_id is None
+        # Busy-shaped: the cycle ends on its own, nothing to alert about.
+        assert item.waiting_reason == "Busy: X1C-01"
+
+    @pytest.mark.asyncio
+    async def test_a_parked_timer_does_not_hold_it(self, ctx):
+        await _set(ctx, "queue_drying_block", "true")
+        item_id = await _add_item(ctx, printer_id=None, target_model="X1C")
+        scheduler = PrintScheduler()
+        scheduler._drying_in_progress[1] = 1.0
+
+        with (
+            patch.object(PrintScheduler, "_drying_is_only_parked", staticmethod(lambda printer_id: True)),
+            patch.object(scheduler, "_stop_drying", AsyncMock()),
+            patch.object(scheduler, "_drying_may_continue_through_print", AsyncMock(return_value=True)),
+        ):
+            launched = await _run(ctx, scheduler, idle=True)
+
+        assert _dispatched_to(launched).get(item_id) == 1
+
+    @pytest.mark.asyncio
+    async def test_without_the_hold_it_may_take_a_drying_printer(self, ctx):
+        item_id = await _add_item(ctx, printer_id=None, target_model="X1C")
+        scheduler = PrintScheduler()
+        scheduler._drying_in_progress[1] = 1.0
+
+        with (
+            patch.object(scheduler, "_stop_drying", AsyncMock()),
+            patch.object(scheduler, "_drying_may_continue_through_print", AsyncMock(return_value=True)),
+        ):
+            launched = await _run(ctx, scheduler, idle=True)
+
+        assert _dispatched_to(launched).get(item_id) == 1
+
+
+class TestDryingStopsForTheModelJobsPrint:
+    @pytest.mark.asyncio
+    async def test_the_printer_it_lands_on_stops_drying(self, ctx):
+        await _add_item(ctx, printer_id=None, target_model="X1C")
+        scheduler = PrintScheduler()
+        scheduler._drying_in_progress[1] = 1.0
+        stop = AsyncMock()
+
+        with (
+            patch.object(scheduler, "_stop_drying", stop),
+            patch.object(scheduler, "_drying_may_continue_through_print", AsyncMock(return_value=False)),
+        ):
+            launched = await _run(ctx, scheduler, idle=True)
+
+        launched.assert_called_once()
+        stop.assert_awaited_once_with(1)
+
+    @pytest.mark.asyncio
+    async def test_drying_that_may_continue_through_the_print_is_left_alone(self, ctx):
+        await _add_item(ctx, printer_id=None, target_model="X1C")
+        scheduler = PrintScheduler()
+        scheduler._drying_in_progress[1] = 1.0
+        stop = AsyncMock()
+
+        with (
+            patch.object(scheduler, "_stop_drying", stop),
+            patch.object(scheduler, "_drying_may_continue_through_print", AsyncMock(return_value=True)),
+        ):
+            await _run(ctx, scheduler, idle=True)
+
+        stop.assert_not_awaited()
+
+
+async def _items(ctx) -> list[PrintQueueItem]:
+    from sqlalchemy import select
+    from sqlalchemy.orm import selectinload
+
+    async with ctx.session_maker() as db:
+        query = select(PrintQueueItem).options(
+            selectinload(PrintQueueItem.variants).selectinload(PrintQueueVariant.library_file),
+            selectinload(PrintQueueItem.archive),
+            selectinload(PrintQueueItem.library_file),
+        )
+        return list((await db.execute(query)).scalars().all())
+
+
+async def _considered_for_drying(ctx, scheduler, *, scope=None) -> set[int]:
+    """The printers auto-drying looked at past the queue gates: each one it
+    reaches is asked whether it is idle (answered no, so nothing starts)."""
+    reached: set[int] = set()
+
+    def idle(pid, require_plate_clear=False):
+        reached.add(pid)
+        return False
+
+    state = SimpleNamespace(state="IDLE", firmware_version="01.09.00.00", raw_data={"ams": []})
+    pm = MagicMock()
+    pm.get_status.return_value = state
+    pm.get_model.return_value = "X1C"
+    pm.is_connected.return_value = True
+    items = await _items(ctx)
+    async with ctx.session_maker() as db:
+        with (
+            patch("backend.app.services.print_scheduler.printer_manager", pm),
+            patch.object(scheduler, "_is_printer_idle", idle),
+            patch("backend.app.core.auth.is_auth_enabled", AsyncMock(return_value=scope is not None)),
+            patch(
+                "backend.app.services.print_scheduler.resolve_user_id_printer_scope",
+                AsyncMock(return_value=scope),
+            ),
+        ):
+            await scheduler._check_auto_drying(db, items, set())
+    return reached
+
+
+class TestQueueOnlyDryingCountsModelJobs:
+    @pytest.mark.asyncio
+    async def test_a_scheduled_model_job_pre_dries_every_printer_of_its_model(self, ctx):
+        from datetime import datetime, timedelta
+
+        await _set(ctx, "queue_drying_enabled", "true")
+        await _add_printer(ctx, 2)
+        await _add_printer(ctx, 3, model="P1S")
+        item_id = await _add_item(ctx, printer_id=None, target_model="X1C")
+        async with ctx.session_maker() as db:
+            item = await db.get(PrintQueueItem, item_id)
+            item.scheduled_time = datetime.utcnow() + timedelta(hours=2)
+            await db.commit()
+
+        assert await _considered_for_drying(ctx, PrintScheduler()) == {1, 2}
+
+    @pytest.mark.asyncio
+    async def test_only_the_printers_its_creator_may_use(self, ctx):
+        from datetime import datetime, timedelta
+
+        await _set(ctx, "queue_drying_enabled", "true")
+        await _add_printer(ctx, 2)
+        item_id = await _add_item(ctx, printer_id=None, target_model="X1C")
+        async with ctx.session_maker() as db:
+            item = await db.get(PrintQueueItem, item_id)
+            item.scheduled_time = datetime.utcnow() + timedelta(hours=2)
+            item.created_by_id = 42
+            await db.commit()
+
+        reached = await _considered_for_drying(ctx, PrintScheduler(), scope=PrinterScope(frozenset({2})))
+
+        assert reached == {2}
+
+    @pytest.mark.asyncio
+    async def test_a_cross_model_job_counts_every_model_it_was_sliced_for(self, ctx):
+        from datetime import datetime, timedelta
+
+        from backend.app.models.library import LibraryFile
+
+        await _set(ctx, "queue_drying_enabled", "true")
+        await _add_printer(ctx, 3, model="P1S")
+        await _add_printer(ctx, 4, model="A1")
+        item_id = await _add_item(ctx, printer_id=None, target_model=None)
+        async with ctx.session_maker() as db:
+            item = await db.get(PrintQueueItem, item_id)
+            item.scheduled_time = datetime.utcnow() + timedelta(hours=2)
+            for position, model in enumerate(("X1C", "P1S")):
+                lib = LibraryFile(
+                    filename=f"{model}.gcode.3mf", file_path=f"/library/{model}.gcode.3mf", file_size=1, file_type="3mf"
+                )
+                db.add(lib)
+                await db.flush()
+                db.add(
+                    PrintQueueVariant(
+                        queue_item_id=item_id, position=position, library_file_id=lib.id, target_model=model
+                    )
+                )
+            await db.commit()
+
+        assert await _considered_for_drying(ctx, PrintScheduler()) == {1, 3}
+
+    @pytest.mark.asyncio
+    async def test_an_unscheduled_model_job_pre_dries_nothing(self, ctx):
+        await _set(ctx, "queue_drying_enabled", "true")
+        await _add_item(ctx, printer_id=None, target_model="X1C")
+
+        assert await _considered_for_drying(ctx, PrintScheduler()) == set()
+
+    @pytest.mark.asyncio
+    async def test_the_hold_keeps_drying_from_starting_where_a_model_job_waits(self, ctx):
+        """As for a pinned job: with drying set to block the queue, a printer a
+        pending job could take does not start a new cycle."""
+        await _set(ctx, "ambient_drying_enabled", "true")
+        await _set(ctx, "queue_drying_block", "true")
+        await _add_printer(ctx, 3, model="P1S")
+        await _add_item(ctx, printer_id=None, target_model="X1C")
+
+        assert await _considered_for_drying(ctx, PrintScheduler()) == {3}
+
+
+class TestALowFilamentHoldIsNotMovedOntoADryingPrinter:
+    """The #3137 release moves a held model job to another printer of the
+    model: not to one that drying holds, where it would wait the whole cycle."""
+
+    @pytest.mark.asyncio
+    async def test_it_goes_to_the_printer_that_is_not_drying(self, queue_db):
+        from backend.app.models.settings import Settings
+        from backend.tests.unit.test_scheduler_model_filament_short_3137 import (
+            _add_item as add_held,
+            _get_item,
+            _run as run_held,
+        )
+
+        async with queue_db.session_maker() as db:
+            db.add(Settings(key="queue_drying_block", value="true"))
+            await db.commit()
+        item_id = await add_held(queue_db, printer_id=1, manual_start=True, filament_short=True)
+        scheduler = PrintScheduler()
+        scheduler._drying_in_progress[2] = 1.0
+        launched = MagicMock()
+
+        with (
+            patch.object(scheduler, "_stop_drying", AsyncMock()),
+            patch.object(scheduler, "_drying_may_continue_through_print", AsyncMock(return_value=True)),
+        ):
+            await run_held(queue_db, scheduler, short={1}, launched=launched)
+
+        assert (await _get_item(queue_db, item_id)).printer_id == 3