| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251 |
- """One queue order for pinned and "Any <model>" jobs (#3200).
- Three P2S printers. The user dragged a job pinned to printer 1 above two
- "Any P2S" jobs, printer 1 finished, and it started one of the "Any" jobs from
- further down the queue. The pinned job sat at the top saying "Busy".
- The scheduler read the queue ``ORDER BY printer_id, position``, which put the
- lane ahead of the position. A model-based job has ``printer_id`` NULL, which
- SQLite sorts first and PostgreSQL sorts last, so which job won a printer both
- wanted was decided by the database, never by where the user put it.
- These run on SQLite, where the "Any" jobs used to win.
- """
- 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.models.settings import Settings
- from backend.app.services.print_scheduler import PrintScheduler
- @pytest.fixture
- async def ctx():
- 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:
- for pid in (1, 2, 3):
- db.add(
- Printer(
- id=pid,
- name=f"3D printer 0{pid}",
- serial_number=f"P2S000{pid}",
- ip_address=f"10.0.0.{pid}",
- access_code="x",
- model="P2S",
- is_active=True,
- )
- )
- await db.commit()
- try:
- yield SimpleNamespace(session_maker=session_maker)
- finally:
- await engine.dispose()
- async def _add(ctx, *, position, printer_id=None, target_model=None, print_time=None, manual_start=False):
- 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": "P2S"},
- )
- db.add(lib)
- await db.flush()
- item = PrintQueueItem(
- status="pending",
- position=position,
- printer_id=printer_id,
- target_model=target_model,
- library_file_id=lib.id,
- print_time_seconds=print_time,
- manual_start=manual_start,
- )
- db.add(item)
- await db.commit()
- return item.id
- async def _pinned(ctx, position, printer_id=1, **kw):
- return await _add(ctx, position=position, printer_id=printer_id, **kw)
- async def _any(ctx, position, **kw):
- return await _add(ctx, position=position, target_model="P2S", **kw)
- async def _set(ctx, key, value):
- async with ctx.session_maker() as db:
- db.add(Settings(key=key, value=value))
- await db.commit()
- async def _item(ctx, item_id):
- async with ctx.session_maker() as db:
- return (await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
- async def _run(ctx, *, idle_printers):
- """One check_queue pass; returns {item_id: printer_id} for what went out."""
- scheduler = PrintScheduler()
- launched = MagicMock()
- 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.printer_manager.is_awaiting_plate_clear",
- MagicMock(return_value=False),
- ),
- 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.object(
- scheduler,
- "_is_printer_idle",
- MagicMock(side_effect=lambda pid, *_a, **_k: pid in idle_printers),
- ),
- patch.object(scheduler, "_check_auto_drying", AsyncMock()),
- patch.object(scheduler, "_ensure_ams_mapping", AsyncMock(return_value=None)),
- patch.object(scheduler, "_block_on_filament_deficit", AsyncMock(return_value=False)),
- patch.object(scheduler, "_get_smart_plugs", AsyncMock(return_value=[])),
- patch.object(scheduler, "_launch_uploads", launched),
- ]
- with ExitStack() as stack:
- for p in patches:
- stack.enter_context(p)
- await scheduler.check_queue()
- if not launched.called:
- return {}
- dispatched = {}
- for item_id in launched.call_args[0][0]:
- dispatched[item_id] = (await _item(ctx, item_id)).printer_id
- return dispatched
- class TestPositionDecidesWhoGetsThePrinter:
- @pytest.mark.asyncio
- async def test_the_reporters_queue(self, ctx):
- """Pinned job dragged to the top, two "Any P2S" jobs below it, only
- printer 1 free: the pinned job starts."""
- shuttle_a = await _any(ctx, 2)
- shuttle_b = await _any(ctx, 3)
- coral = await _pinned(ctx, 1)
- assert await _run(ctx, idle_printers={1}) == {coral: 1}
- assert (await _item(ctx, shuttle_a)).status == "pending"
- assert (await _item(ctx, shuttle_b)).status == "pending"
- @pytest.mark.asyncio
- async def test_an_any_job_above_a_pinned_one_goes_first(self, ctx):
- """The other direction: position still decides, so a model-based job
- the user put higher takes the printer."""
- pinned = await _pinned(ctx, 2)
- any_job = await _any(ctx, 1)
- assert await _run(ctx, idle_printers={1}) == {any_job: 1}
- assert (await _item(ctx, pinned)).status == "pending"
- @pytest.mark.asyncio
- async def test_a_pinned_job_that_cannot_start_does_not_hold_up_other_printers(self, ctx):
- """The top job is pinned to a busy printer; the "Any" job below it
- still takes the free one."""
- await _pinned(ctx, 1, printer_id=2)
- any_job = await _any(ctx, 2)
- assert await _run(ctx, idle_printers={1}) == {any_job: 1}
- @pytest.mark.asyncio
- async def test_several_free_printers_each_get_the_next_job(self, ctx):
- coral = await _pinned(ctx, 1)
- shuttle_a = await _any(ctx, 2)
- shuttle_b = await _any(ctx, 3)
- dispatched = await _run(ctx, idle_printers={1, 2, 3})
- assert dispatched[coral] == 1
- assert {dispatched[shuttle_a], dispatched[shuttle_b]} == {2, 3}
- @pytest.mark.asyncio
- async def test_a_staged_job_at_the_top_does_not_block_the_printer(self, ctx):
- """A manual-start job waits for the user, so the next job takes the
- printer rather than the queue stalling behind it."""
- await _pinned(ctx, 1, manual_start=True)
- any_job = await _any(ctx, 2)
- assert await _run(ctx, idle_printers={1}) == {any_job: 1}
- class TestShortestJobFirstAcrossLanes:
- @pytest.mark.asyncio
- async def test_a_shorter_any_job_beats_a_longer_pinned_one(self, ctx):
- await _set(ctx, "queue_shortest_first", "true")
- pinned = await _pinned(ctx, 1, print_time=7200)
- short_any = await _any(ctx, 2, print_time=600)
- assert await _run(ctx, idle_printers={1}) == {short_any: 1}
- assert (await _item(ctx, pinned)).status == "pending"
- @pytest.mark.asyncio
- async def test_the_pinned_job_it_jumped_is_marked_and_goes_next(self, ctx):
- """The starvation guard has to look across lanes, or a stream of short
- "Any" jobs holds the pinned one back forever."""
- await _set(ctx, "queue_shortest_first", "true")
- pinned = await _pinned(ctx, 1, print_time=7200)
- await _any(ctx, 2, print_time=600)
- await _run(ctx, idle_printers={1})
- assert (await _item(ctx, pinned)).been_jumped is True
- await _any(ctx, 3, print_time=300)
- async with ctx.session_maker() as db:
- for row in (await db.execute(select(PrintQueueItem).where(PrintQueueItem.status != "pending"))).scalars():
- row.status = "completed"
- await db.commit()
- assert await _run(ctx, idle_printers={1}) == {pinned: 1}
- @pytest.mark.asyncio
- async def test_a_jumped_any_job_is_marked_when_a_pinned_job_goes_first(self, ctx):
- await _set(ctx, "queue_shortest_first", "true")
- long_any = await _any(ctx, 1, print_time=7200)
- short_pinned = await _pinned(ctx, 2, print_time=600)
- assert await _run(ctx, idle_printers={1}) == {short_pinned: 1}
- assert (await _item(ctx, long_any)).been_jumped is True
- @pytest.mark.asyncio
- async def test_an_any_job_for_another_model_is_not_marked(self, ctx):
- await _set(ctx, "queue_shortest_first", "true")
- other_model = await _add(ctx, position=1, target_model="X1C", print_time=7200)
- await _pinned(ctx, 2, print_time=600)
- await _run(ctx, idle_printers={1})
- assert (await _item(ctx, other_model)).been_jumped is False
|