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

Archives: a file printing on several printers at once is one archive (archive redesign P1b)

- A library or project dispatch adds its run to the file's archive also
  while the file prints, is queued or finishes elsewhere; the idle-only
  claim and the run-finishing marks are gone
- While another print of the archive runs, the dispatch leaves the
  archive's plate and cost centre to that print
- A queued print's cost centre is its queue item's, none included, when
  its run opens and when it is charged
- A plate keeps its latest print's video: an earlier print's scan goes
  on until a later print of the plate has its own video, whose arrival
  removes the earlier ones
maziggy 1 день назад
Родитель
Сommit
022ea39a1a

+ 3 - 38
backend/app/main.py

@@ -568,35 +568,6 @@ _timelapse_baselines: dict[int, set[str]] = {}
 # so it is empty without the card being empty (#3412).
 # so it is empty without the card being empty (#3412).
 _timelapse_baselines_unread: set[int] = set()
 _timelapse_baselines_unread: set[int] = set()
 
 
-# Archives whose last run still has work after it completed: the timelapse
-# scan and the layer timelapse stitch. Both tell their run from a later one by
-# the archive's start time, so a queue dispatch of the same file must not start
-# a new run on the archive until they end (_claim_reusable_archive in the
-# scheduler). archive_id -> number of such tasks.
-_archive_runs_finishing: dict[int, int] = {}
-
-
-async def _while_run_finishes(archive_id: int, work):
-    """Run ``work`` with the archive marked as still finishing its last run.
-    Marked by the caller's spawn already: see ``_mark_run_finishing``."""
-    try:
-        return await work
-    finally:
-        left = _archive_runs_finishing.get(archive_id, 0) - 1
-        if left > 0:
-            _archive_runs_finishing[archive_id] = left
-        else:
-            _archive_runs_finishing.pop(archive_id, None)
-
-
-def _mark_run_finishing(archive_id: int, work):
-    """``work`` wrapped to keep the archive marked until it ends. Marked now,
-    before the task starts, so no dispatch between the spawn and its first
-    step can take the archive."""
-    _archive_runs_finishing[archive_id] = _archive_runs_finishing.get(archive_id, 0) + 1
-    return _while_run_finishes(archive_id, work)
-
-
 # Track printers waiting for bed to cool after print completion.
 # Track printers waiting for bed to cool after print completion.
 # Event-driven: fires when bed_temper arrives via MQTT below threshold.
 # Event-driven: fires when bed_temper arrives via MQTT below threshold.
 # {printer_id: {"threshold": float, "filename": str, "registered_at": float}}
 # {printer_id: {"threshold": float, "filename": str, "registered_at": float}}
@@ -6797,11 +6768,8 @@ def _schedule_timelapse_scan(
     _timelapse_baselines_unread.discard(printer_id)
     _timelapse_baselines_unread.discard(printer_id)
     baseline = _timelapse_baselines.pop(printer_id, None)
     baseline = _timelapse_baselines.pop(printer_id, None)
     spawn_background_task(
     spawn_background_task(
-        _mark_run_finishing(
-            archive_id,
-            _scan_for_timelapse_with_retries(
-                archive_id, baseline, plate_id=plate_id, baseline_unread=unread, run_id=run_id
-            ),
+        _scan_for_timelapse_with_retries(
+            archive_id, baseline, plate_id=plate_id, baseline_unread=unread, run_id=run_id
         ),
         ),
         name=f"scan-timelapse-{archive_id}",
         name=f"scan-timelapse-{archive_id}",
     )
     )
@@ -10518,10 +10486,7 @@ async def _close_out_print(printer_id: int, data: dict):
                 except Exception:
                 except Exception:
                     pass  # Best-effort timelapse session cancellation on error
                     pass  # Best-effort timelapse session cancellation on error
 
 
-    layer_timelapse = _background_layer_timelapse()
-    if archive_id and print_status == "completed":
-        layer_timelapse = _mark_run_finishing(archive_id, layer_timelapse)
-    spawn_background_task(layer_timelapse, name="background-layer-timelapse")
+    spawn_background_task(_background_layer_timelapse(), name="background-layer-timelapse")
 
 
     log_timing("All background tasks scheduled")
     log_timing("All background tasks scheduled")
 
 

+ 11 - 1
backend/app/services/archive.py

@@ -1916,7 +1916,7 @@ class ArchiveService:
         """
         """
         import asyncio
         import asyncio
 
 
-        from backend.app.services.print_log import is_newest_run, newest_run, run_of
+        from backend.app.services.print_log import drop_earlier_plate_videos, is_newest_run, newest_run, run_of
 
 
         archive = await self.get_archive(archive_id)
         archive = await self.get_archive(archive_id)
         if not archive:
         if not archive:
@@ -1974,10 +1974,20 @@ class ArchiveService:
         relpath = str(timelapse_file.relative_to(settings.base_dir))
         relpath = str(timelapse_file.relative_to(settings.base_dir))
         if run is not None:
         if run is not None:
             run.timelapse_path = relpath
             run.timelapse_path = relpath
+        # A plate keeps its latest print's video: earlier prints of it that
+        # got theirs meanwhile (printing on other printers at once) lose them.
+        replaced = await drop_earlier_plate_videos(self.db, run) if run_id is not None and run is not None else []
         if run_id is None or (run is not None and await is_newest_run(self.db, run)):
         if run_id is None or (run is not None and await is_newest_run(self.db, run)):
             archive.timelapse_path = relpath
             archive.timelapse_path = relpath
             archive.timelapse_plate_id = plate_id
             archive.timelapse_plate_id = plate_id
         await self.db.commit()
         await self.db.commit()
+        for old_relpath in replaced:
+            try:
+                old_file = safe_join_under(settings.base_dir, old_relpath, http=False)
+            except PathTraversalError:
+                continue
+            await asyncio.to_thread(old_file.unlink, missing_ok=True)
+            logger.info("Replaced plate %s's earlier timelapse %s on archive %s", plate_id, old_relpath, archive_id)
         await _announce_timelapse(archive_id, attached=True)
         await _announce_timelapse(archive_id, attached=True)
 
 
         # For non-MP4 videos (e.g. AVI from P1S), kick off background conversion
         # For non-MP4 videos (e.g. AVI from P1S), kick off background conversion

+ 4 - 1
backend/app/services/finance_billing.py

@@ -224,7 +224,10 @@ async def apply_print_charge_for_archive(
             actual_user_id = archive.created_by_id
             actual_user_id = archive.created_by_id
         if cost_center_id is not None:
         if cost_center_id is not None:
             actual_cost_center_id = cost_center_id
             actual_cost_center_id = cost_center_id
-        elif run is not None and run.cost_center_id is not None:
+        elif run is not None and (run.cost_center_id is not None or run.queue_item_id is not None):
+            # A queued print's is its queue item's, none included: the
+            # archive's is another print's when the same file prints on
+            # several printers at once (archive redesign).
             actual_cost_center_id = run.cost_center_id
             actual_cost_center_id = run.cost_center_id
         else:
         else:
             actual_cost_center_id = archive.cost_center_id
             actual_cost_center_id = archive.cost_center_id

+ 48 - 8
backend/app/services/print_log.py

@@ -10,7 +10,7 @@ from datetime import datetime, timezone
 from pathlib import Path
 from pathlib import Path
 from types import SimpleNamespace
 from types import SimpleNamespace
 
 
-from sqlalchemy import delete, func, or_, select
+from sqlalchemy import and_, delete, func, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.models.print_log import INCLUDE_OPEN_RUNS, RUN_OPEN, PrintLogEntry
 from backend.app.models.print_log import INCLUDE_OPEN_RUNS, RUN_OPEN, PrintLogEntry
@@ -447,7 +447,9 @@ async def open_run(
         # Not the archive's: that is the latest dispatch's, or the id an
         # Not the archive's: that is the latest dispatch's, or the id an
         # earlier print of the archive was charged under.
         # earlier print of the archive was charged under.
         billing_run_id=(item.billing_run_id if item is not None else None) or str(uuid.uuid4()),
         billing_run_id=(item.billing_run_id if item is not None else None) or str(uuid.uuid4()),
-        cost_center_id=(item.cost_center_id if item is not None else None) or archive.cost_center_id,
+        # The queue item's, none included: the archive's is another print's
+        # when the same file prints on several printers at once.
+        cost_center_id=item.cost_center_id if item is not None else archive.cost_center_id,
         confirm_requested=archive.confirm_requested,
         confirm_requested=archive.confirm_requested,
         quantity=archive.quantity,
         quantity=archive.quantity,
         # Its own finish photos from here on (``owns_media``).
         # Its own finish photos from here on (``owns_media``).
@@ -625,16 +627,17 @@ def owns_media(run: PrintLogEntry) -> bool:
 
 
 
 
 async def superseded(db: AsyncSession, run: PrintLogEntry) -> bool:
 async def superseded(db: AsyncSession, run: PrintLogEntry) -> bool:
-    """Whether a later print of the run's archive has started on its printer,
-    or of its plate: a video landing now is not the run's to take. The one on
-    its printer is that print's own, and a plate keeps one video, the latest
-    print's (a reprint replaces it). Another plate on another printer leaves
-    the run's video to it."""
+    """Whether a later print of the run's archive took over its video: one
+    started on its printer, whose video a video landing now may be, or one of
+    its plate that has its video already: a plate keeps one video, its latest
+    print's (a reprint replaces it, ``drop_earlier_plate_videos``). A later
+    print of the plate still running on another printer leaves the run its
+    video until it has one of its own, which it may never get."""
     if run.archive_id is None or run.started_at is None:
     if run.archive_id is None or run.started_at is None:
         return False
         return False
     same = [PrintLogEntry.printer_id == run.printer_id] if run.printer_id is not None else []
     same = [PrintLogEntry.printer_id == run.printer_id] if run.printer_id is not None else []
     if run.plate_id is not None:
     if run.plate_id is not None:
-        same.append(PrintLogEntry.plate_id == run.plate_id)
+        same.append(and_(PrintLogEntry.plate_id == run.plate_id, PrintLogEntry.timelapse_path.is_not(None)))
     if not same:
     if not same:
         return False
         return False
     later = await db.scalar(
     later = await db.scalar(
@@ -651,6 +654,43 @@ async def superseded(db: AsyncSession, run: PrintLogEntry) -> bool:
     return later is not None
     return later is not None
 
 
 
 
+async def drop_earlier_plate_videos(db: AsyncSession, run: PrintLogEntry) -> list[str]:
+    """Take the videos of the archive's earlier prints of ``run``'s plate off
+    their runs, now that ``run`` has its own: a plate keeps one video, its
+    latest print's. The one the archive shows too, when it is one of them.
+    Returns their paths, for the caller to delete once committed; a path
+    another run (of another plate) still holds is left. Does not commit."""
+    from backend.app.models.archive import PrintArchive
+
+    if run.archive_id is None or run.plate_id is None or run.started_at is None:
+        return []
+    runs = list(
+        await db.scalars(
+            select(PrintLogEntry)
+            .where(PrintLogEntry.archive_id == run.archive_id, PrintLogEntry.timelapse_path.is_not(None))
+            .execution_options(**{INCLUDE_OPEN_RUNS: True})
+        )
+    )
+    earlier = [
+        r
+        for r in runs
+        if r.id != run.id
+        and r.plate_id == run.plate_id
+        and r.started_at is not None
+        and r.started_at < run.started_at
+        and r.timelapse_path != run.timelapse_path
+    ]
+    kept = {r.timelapse_path for r in runs if r not in earlier}
+    paths = {r.timelapse_path for r in earlier} - kept
+    for r in earlier:
+        r.timelapse_path = None
+    archive = await db.get(PrintArchive, run.archive_id)
+    if archive is not None and archive.timelapse_path in paths:
+        archive.timelapse_path = None
+        archive.timelapse_plate_id = None
+    return sorted(p for p in paths if p)
+
+
 def add_run_photo(run: PrintLogEntry | None, filename: str, *, first: bool = False) -> None:
 def add_run_photo(run: PrintLogEntry | None, filename: str, *, first: bool = False) -> None:
     """Put a finish photo on its run's list too: last, or ``first``."""
     """Put a finish photo on its run's list too: last, or ``first``."""
     if run is None:
     if run is None:

+ 31 - 55
backend/app/services/print_scheduler.py

@@ -1378,12 +1378,6 @@ class PrintScheduler:
         # event-loop thread, so this dict needs no lock.
         # event-loop thread, so this dict needs no lock.
         # item_id -> (task, printer_id)
         # item_id -> (task, printer_id)
         self._inflight: dict[int, tuple[asyncio.Task, int | None]] = {}
         self._inflight: dict[int, tuple[asyncio.Task, int | None]] = {}
-        # Archives a library dispatch still in flight is adding its run to:
-        # archive_id -> item_id. Uploads run in parallel, and an archive holds
-        # one active run, so a second dispatch of the same file must not take
-        # it before the first one's print shows as running. Released by
-        # `_dispatch_one` when the dispatch ends; same threading as above.
-        self._archive_claims: dict[int, int] = {}
         # Expected prints registered by `_start_print` that have not yet had a
         # Expected prints registered by `_start_print` that have not yet had a
         # print command sent. Populated at registration, dropped once
         # print command sent. Populated at registration, dropped once
         # `start_print()` succeeds, and rolled back by `_dispatch_one` on every
         # `start_print()` succeeds, and rolled back by `_dispatch_one` on every
@@ -2853,9 +2847,6 @@ class PrintScheduler:
                 # Likewise the print user it set: the printer's next print is
                 # Likewise the print user it set: the printer's next print is
                 # not this item's.
                 # not this item's.
                 self._rollback_unconfirmed_print_user(item_id)
                 self._rollback_unconfirmed_print_user(item_id)
-                # A sent print now shows as running on its archive; an unsent
-                # one leaves it free again.
-                self._release_archive_claims(item_id)
                 # Mirror the pre-#1625 background-dispatch lifecycle: a
                 # Mirror the pre-#1625 background-dispatch lifecycle: a
                 # reservation survives only after start_print() accepted the
                 # reservation survives only after start_print() accepted the
                 # command. Failure, cancellation, deferral, and exceptions all
                 # command. Failure, cancellation, deferral, and exceptions all
@@ -8063,24 +8054,33 @@ class PrintScheduler:
 
 
         return prev_item.status in ("completed", "cancelled")
         return prev_item.status in ("completed", "cancelled")
 
 
-    def _release_archive_claims(self, item_id: int) -> None:
-        for archive_id in [a for a, owner in self._archive_claims.items() if owner == item_id]:
-            del self._archive_claims[archive_id]
+    @staticmethod
+    async def _printing_elsewhere(db: AsyncSession, archive_id: int) -> bool:
+        """Whether a print of the archive is running (its run open)."""
+        from backend.app.models.print_log import INCLUDE_OPEN_RUNS, RUN_OPEN, PrintLogEntry
+
+        return (
+            await db.scalar(
+                select(PrintLogEntry.id)
+                .where(PrintLogEntry.archive_id == archive_id, PrintLogEntry.status == RUN_OPEN)
+                .limit(1)
+                .execution_options(**{INCLUDE_OPEN_RUNS: True})
+            )
+            is not None
+        )
 
 
     async def _claim_reusable_archive(
     async def _claim_reusable_archive(
         self, db: AsyncSession, item: PrintQueueItem, file_path: Path
         self, db: AsyncSession, item: PrintQueueItem, file_path: Path
     ) -> PrintArchive | None:
     ) -> PrintArchive | None:
-        """The archive a library dispatch adds its run to, claimed for it, or
-        None for a new one.
+        """The archive a library dispatch adds its run to, or None for a new one.
 
 
         The same file of the same owner, in the same project, archived from
         The same file of the same owner, in the same project, archived from
         this library file or from none (one since deleted, or a print started
         this library file or from none (one since deleted, or a print started
-        elsewhere), with its copy of the file still on disk. Only an idle
-        archive: it holds one active run, so while one of its prints is
-        queued, being sent, running, or still finishing (its timelapse),
-        another print of the file gets an archive of its own, as before.
+        elsewhere), with its copy of the file still on disk. Also while it is
+        printing, queued or finishing elsewhere: each print of an archive is
+        its own run, with its own start, end, charge, energy, outcome,
+        timelapse and photos (archive redesign), as for the copies of a batch.
         """
         """
-        from backend.app.main import _active_prints, _archive_runs_finishing
         from backend.app.services.archive import ArchiveService
         from backend.app.services.archive import ArchiveService
 
 
         # Off the file as it is now: the stored hash is not refreshed when the
         # Off the file as it is now: the stored hash is not refreshed when the
@@ -8103,42 +8103,13 @@ class PrintScheduler:
                     project,
                     project,
                     or_(PrintArchive.library_file_id == item.library_file_id, PrintArchive.library_file_id.is_(None)),
                     or_(PrintArchive.library_file_id == item.library_file_id, PrintArchive.library_file_id.is_(None)),
                     PrintArchive.deleted_at.is_(None),
                     PrintArchive.deleted_at.is_(None),
-                    PrintArchive.status != "printing",
                 )
                 )
                 .order_by(PrintArchive.id.desc())
                 .order_by(PrintArchive.id.desc())
             )
             )
         ).all()
         ).all()
-        if not candidates:
-            return None
-        # A dispatch sets the item's archive while the item is still pending
-        # and keeps it through printing, so this also covers a print being
-        # sent or waiting to start. (The expected-print table can't say that:
-        # a print start leaves one of its names there for two hours.)
-        queued = set(
-            (
-                await db.scalars(
-                    select(PrintQueueItem.archive_id).where(
-                        PrintQueueItem.archive_id.in_([c.id for c in candidates]),
-                        PrintQueueItem.status.in_(("pending", "printing")),
-                        PrintQueueItem.id != item.id,
-                    )
-                )
-            ).all()
-        )
         for candidate in candidates:
         for candidate in candidates:
-            if not candidate.file_path or not (settings.base_dir / candidate.file_path).is_file():
-                continue
-            # No await from here to the claim: two parallel dispatches of the
-            # file cannot both take it.
-            if (
-                candidate.id in queued
-                or candidate.id in self._archive_claims
-                or candidate.id in _active_prints.values()
-                or candidate.id in _archive_runs_finishing
-            ):
-                continue
-            self._archive_claims[candidate.id] = item.id
-            return candidate
+            if candidate.file_path and (settings.base_dir / candidate.file_path).is_file():
+                return candidate
         return None
         return None
 
 
     async def _repoint_siblings_at_archive(
     async def _repoint_siblings_at_archive(
@@ -9099,11 +9070,16 @@ class PrintScheduler:
                     if archive.library_file_id is None:
                     if archive.library_file_id is None:
                         archive.library_file_id = item.library_file_id
                         archive.library_file_id = item.library_file_id
                     # This run's, as a new archive would have them: the
                     # This run's, as a new archive would have them: the
-                    # archive shows its latest run's plate, and billing falls
-                    # back to the archive's cost centre.
-                    if item.plate_id is not None:
-                        archive.plate_id = item.plate_id
-                    archive.cost_center_id = item.cost_center_id
+                    # archive shows its latest run's plate, and a print
+                    # without a queue item falls back to the archive's plate
+                    # and cost centre. Not while another print of it runs: they
+                    # are that print's (this one's run has its own, from the
+                    # dispatch), and the archive takes the latest run's plate
+                    # once none prints (print_log.mirror_latest_run).
+                    if not await self._printing_elsewhere(db, archive.id):
+                        if item.plate_id is not None:
+                            archive.plate_id = item.plate_id
+                        archive.cost_center_id = item.cost_center_id
                 else:
                 else:
                     archive_service = ArchiveService(db)
                     archive_service = ArchiveService(db)
                     archive = await archive_service.archive_print(
                     archive = await archive_service.archive_print(

+ 51 - 2
backend/tests/integration/test_run_lifecycle.py

@@ -12,6 +12,7 @@ made here on purpose.
 from __future__ import annotations
 from __future__ import annotations
 
 
 import asyncio
 import asyncio
+from datetime import datetime
 from types import SimpleNamespace
 from types import SimpleNamespace
 
 
 import pytest
 import pytest
@@ -69,10 +70,8 @@ def _clean():
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.printer_manager import printer_manager
 
 
     main_module._print_plate_ids.clear()
     main_module._print_plate_ids.clear()
-    main_module._archive_runs_finishing.clear()
     yield
     yield
     main_module._print_plate_ids.clear()
     main_module._print_plate_ids.clear()
-    main_module._archive_runs_finishing.clear()
     printer_manager._awaiting_plate_clear.clear()
     printer_manager._awaiting_plate_clear.clear()
 
 
 
 
@@ -1525,3 +1524,53 @@ async def test_a_restart_keeps_the_first_printers_run_while_another_plate_prints
     [after] = [r for r in await _all_runs(db_session, farm.archive) if r.printer_id == farm.printer]
     [after] = [r for r in await _all_runs(db_session, farm.archive) if r.printer_id == farm.printer]
     assert (after.id, after.started_at, after.billing_run_id) == kept
     assert (after.id, after.started_at, after.billing_run_id) == kept
     assert after.energy_start_kwh == 10.0
     assert after.energy_start_kwh == 10.0
+
+
+async def test_a_queued_prints_cost_centre_is_its_queue_items_none_included(db_session, farm):
+    """The same file printing on another printer under a cost centre left it
+    on the archive: a queued print without one is not charged to it."""
+    from backend.app.services.finance_billing import apply_print_charge_for_archive
+
+    archive = await db_session.get(PrintArchive, farm.archive)
+    archive.cost_center_id = farm.center
+    archive.extra_data = {"filament_grams_total": 100.0}
+    item = PrintQueueItem(printer_id=farm.printer, archive_id=farm.archive, status="printing", position=1)
+    db_session.add(item)
+    await db_session.flush()
+    run = PrintLogEntry(
+        archive_id=farm.archive,
+        printer_id=farm.printer,
+        status="printing",
+        billing_run_id="run-1",
+        queue_item_id=item.id,
+        cost_center_id=None,
+    )
+    db_session.add(run)
+    await db_session.commit()
+
+    assert await apply_print_charge_for_archive(
+        db_session,
+        farm.archive,
+        charged_user_id=farm.user,
+        run_entry_id=run.id,
+        run_status="completed",
+        filament_usage=(10.0, 100.0),
+    )
+    await db_session.commit()
+
+    [charge] = await _charges(db_session)
+    assert charge.cost_center_id is None
+
+
+async def test_a_run_opened_for_a_queue_item_takes_its_cost_centre_none_included(db_session, farm):
+    from backend.app.services.print_log import open_run
+
+    archive = await db_session.get(PrintArchive, farm.archive)
+    archive.cost_center_id = farm.center
+    archive.started_at = datetime(2026, 10, 31, 9)
+    db_session.add(PrintQueueItem(printer_id=farm.printer, archive_id=farm.archive, status="printing", position=1))
+    await db_session.commit()
+
+    run = await open_run(db_session, archive, printer_id=farm.printer)
+
+    assert run is not None and run.cost_center_id is None

+ 59 - 6
backend/tests/integration/test_timelapse_per_run.py

@@ -162,6 +162,46 @@ class TestAttach:
         first = await _row(db_session, PrintLogEntry, two_plates.first)
         first = await _row(db_session, PrintLogEntry, two_plates.first)
         assert first.timelapse_path == f"{ARCHIVE_DIR}/layer_timelapse_2.mp4"
         assert first.timelapse_path == f"{ARCHIVE_DIR}/layer_timelapse_2.mp4"
 
 
+    async def test_a_plates_video_replaces_its_earlier_prints(self, db_session, two_plates):
+        """Plate 1 printed again on B while A's copy ran: once B's video is
+        in, A's goes (a plate keeps its latest print's)."""
+        (two_plates.files / ARCHIVE_DIR / "video_1.mp4").write_bytes(b"one")
+        await _set(db_session, PrintLogEntry, two_plates.first, timelapse_path=f"{ARCHIVE_DIR}/video_1.mp4")
+        later = PrintLogEntry(
+            archive_id=two_plates.archive,
+            printer_id=two_plates.b,
+            plate_id=1,
+            status="completed",
+            started_at=T2 + timedelta(hours=1),
+            photos=[],
+        )
+        db_session.add(later)
+        await db_session.commit()
+        later_id = later.id
+
+        assert await _attach(two_plates.archive, b"again", "video_3.mp4", plate_id=1, run_id=later_id)
+
+        assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path is None
+        assert not (two_plates.files / ARCHIVE_DIR / "video_1.mp4").exists()
+        assert (await _row(db_session, PrintLogEntry, later_id)).timelapse_path == f"{ARCHIVE_DIR}/video_3.mp4"
+
+    async def test_an_earlier_prints_late_video_leaves_the_later_ones(self, db_session, two_plates):
+        later = PrintLogEntry(
+            archive_id=two_plates.archive,
+            printer_id=two_plates.b,
+            plate_id=1,
+            status="completed",
+            started_at=T2 + timedelta(hours=1),
+            timelapse_path=f"{ARCHIVE_DIR}/video_3.mp4",
+        )
+        (two_plates.files / ARCHIVE_DIR / "video_3.mp4").write_bytes(b"again")
+        db_session.add(later)
+        await db_session.commit()
+
+        assert await _attach(two_plates.archive, b"one", "video_1.mp4", plate_id=1, run_id=two_plates.first)
+
+        assert (two_plates.files / ARCHIVE_DIR / "video_3.mp4").exists()
+
 
 
 class TestConversion:
 class TestConversion:
     async def _convert(self, archive_id, source):
     async def _convert(self, archive_id, source):
@@ -297,26 +337,38 @@ class TestScan:
 
 
         assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path == f"{ARCHIVE_DIR}/new.mp4"
         assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path == f"{ARCHIVE_DIR}/new.mp4"
 
 
-    async def _later_print(self, db_session, plates, *, printer_id, plate_id):
+    async def _later_print(self, db_session, plates, *, printer_id, plate_id, video=None):
         db_session.add(
         db_session.add(
             PrintLogEntry(
             PrintLogEntry(
                 archive_id=plates.archive,
                 archive_id=plates.archive,
                 printer_id=printer_id,
                 printer_id=printer_id,
                 plate_id=plate_id,
                 plate_id=plate_id,
-                status=RUN_OPEN,
+                status=RUN_OPEN if video is None else "completed",
                 started_at=T2 + timedelta(hours=1),
                 started_at=T2 + timedelta(hours=1),
+                timelapse_path=video,
             )
             )
         )
         )
         await db_session.commit()
         await db_session.commit()
 
 
-    async def test_a_reprint_of_its_plate_ends_it(self, db_session, two_plates):
+    async def test_a_reprint_of_its_plate_with_its_video_ends_it(self, db_session, two_plates):
         """The reprint's video is the plate's one (it replaces this one)."""
         """The reprint's video is the plate's one (it replaces this one)."""
-        await self._later_print(db_session, two_plates, printer_id=two_plates.b, plate_id=1)
+        await self._later_print(
+            db_session, two_plates, printer_id=two_plates.b, plate_id=1, video=f"{ARCHIVE_DIR}/later.mp4"
+        )
 
 
         await self._scan(two_plates, [[_video("new.mp4")]], baseline=set())
         await self._scan(two_plates, [[_video("new.mp4")]], baseline=set())
 
 
         assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path is None
         assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path is None
 
 
+    async def test_a_reprint_of_its_plate_still_printing_elsewhere_does_not_end_it(self, db_session, two_plates):
+        """The same plate printing on two printers at once: until the later
+        print has a video, which it may never get, this one keeps its own."""
+        await self._later_print(db_session, two_plates, printer_id=two_plates.b, plate_id=1)
+
+        await self._scan(two_plates, [[_video("new.mp4")]], baseline=set())
+
+        assert (await _row(db_session, PrintLogEntry, two_plates.first)).timelapse_path == f"{ARCHIVE_DIR}/new.mp4"
+
     async def test_the_next_print_on_its_printer_ends_it(self, db_session, two_plates):
     async def test_the_next_print_on_its_printer_ends_it(self, db_session, two_plates):
         """A video landing now can be that print's."""
         """A video landing now can be that print's."""
         await self._later_print(db_session, two_plates, printer_id=two_plates.a, plate_id=3)
         await self._later_print(db_session, two_plates, printer_id=two_plates.a, plate_id=3)
@@ -406,7 +458,7 @@ class TestLayerTimelapse:
         assert not stitched.exists()
         assert not stitched.exists()
         assert (await _row(db_session, PrintArchive, two_plates.archive)).timelapse_path is None
         assert (await _row(db_session, PrintArchive, two_plates.archive)).timelapse_path is None
 
 
-    async def test_a_reprint_of_its_plate_drops_it(self, db_session, two_plates):
+    async def test_a_reprint_of_its_plate_with_its_video_drops_it(self, db_session, two_plates):
         from backend.app.main import _attach_layer_timelapse
         from backend.app.main import _attach_layer_timelapse
 
 
         db_session.add(
         db_session.add(
@@ -414,8 +466,9 @@ class TestLayerTimelapse:
                 archive_id=two_plates.archive,
                 archive_id=two_plates.archive,
                 printer_id=two_plates.b,
                 printer_id=two_plates.b,
                 plate_id=1,
                 plate_id=1,
-                status=RUN_OPEN,
+                status="completed",
                 started_at=T2 + timedelta(hours=1),
                 started_at=T2 + timedelta(hours=1),
+                timelapse_path=f"{ARCHIVE_DIR}/later.mp4",
             )
             )
         )
         )
         await db_session.commit()
         await db_session.commit()

+ 0 - 86
backend/tests/unit/test_archive_run_finishing.py

@@ -1,86 +0,0 @@
-"""An archive stays marked while its last run's timelapse work goes on.
-
-The timelapse scan and the layer timelapse stitch run after the print
-completed and tell their run from a later one by the archive's start time. A
-queue dispatch of the same file adds its run to the archive only once that
-work is over (``_claim_reusable_archive``), or the work would stop short and
-the video never be attached.
-"""
-
-import asyncio
-from unittest.mock import patch
-
-import pytest
-
-from backend.app import main as main_module
-from backend.tests.unit.test_run_estimate_plate import _complete
-
-
-@pytest.fixture(autouse=True)
-def _clean_state():
-    main_module._archive_runs_finishing.clear()
-    main_module._active_prints.clear()
-    main_module._print_plate_ids.clear()
-    yield
-    main_module._archive_runs_finishing.clear()
-    main_module._active_prints.clear()
-    main_module._print_plate_ids.clear()
-
-
-@pytest.mark.asyncio
-async def test_marked_from_the_spawn_until_the_work_ends():
-    release = asyncio.Event()
-
-    async def work():
-        await release.wait()
-
-    wrapped = main_module._mark_run_finishing(7, work())
-    assert main_module._archive_runs_finishing == {7: 1}
-    second = main_module._mark_run_finishing(7, asyncio.sleep(0))
-    await second
-    assert main_module._archive_runs_finishing == {7: 1}, "the other task is still running"
-
-    task = asyncio.create_task(wrapped)
-    release.set()
-    await task
-    assert main_module._archive_runs_finishing == {}
-
-
-@pytest.mark.asyncio
-async def test_a_failing_task_unmarks_too():
-    async def work():
-        raise RuntimeError("scan failed")
-
-    with pytest.raises(RuntimeError):
-        await main_module._mark_run_finishing(7, work())
-    assert main_module._archive_runs_finishing == {}
-
-
-def test_the_timelapse_scan_marks_its_archive():
-    spawned = []
-    with patch("backend.app.main.spawn_background_task", side_effect=lambda coro, name: spawned.append(coro)):
-        main_module._schedule_timelapse_scan(12, 3, plate_id=None)
-    assert main_module._archive_runs_finishing == {12: 1}
-    for coro in spawned:
-        coro.close()
-
-
-@pytest.mark.asyncio
-async def test_the_layer_timelapse_of_a_completed_print_marks_its_archive(
-    test_engine, db_session, printer_factory, archive_factory
-):
-    printer = await printer_factory()
-    archive = await archive_factory(printer.id, filename="multi.gcode.3mf", status="printing", with_run=False)
-    main_module._active_prints[(printer.id, "multi.gcode.3mf")] = archive.id
-    spawned: list[str] = []
-    real_spawn = main_module.spawn_background_task
-
-    def spawn(coro, name=None, **kwargs):
-        if name == "background-layer-timelapse":
-            spawned.append(main_module._archive_runs_finishing.get(archive.id, 0))
-        return real_spawn(coro, name=name, **kwargs)
-
-    with patch("backend.app.main.spawn_background_task", side_effect=spawn):
-        await _complete(test_engine, printer.id)
-
-    assert spawned == [1]

+ 0 - 1
backend/tests/unit/test_forget_printer.py

@@ -26,7 +26,6 @@ NOT_FORGOTTEN = {
     "_print_cost_center_ids": "keyed by (printer, archive); checked below",
     "_print_cost_center_ids": "keyed by (printer, archive); checked below",
     "_print_plate_ids": "keyed by (printer, archive); checked below",
     "_print_plate_ids": "keyed by (printer, archive); checked below",
     "_owned_print_archive_ids": "keyed by archive (dropped with the printer's expected/active prints)",
     "_owned_print_archive_ids": "keyed by archive (dropped with the printer's expected/active prints)",
-    "_archive_runs_finishing": "keyed by archive, cleared by the task it counts when that task ends",
     "_ams_assignment_locks": "a lock carries nothing over; popping one a task holds would let a second be made",
     "_ams_assignment_locks": "a lock carries nothing over; popping one a task holds would let a second be made",
     "_fallback_recovery_locks": "a lock carries nothing over (see its comment on popping)",
     "_fallback_recovery_locks": "a lock carries nothing over (see its comment on popping)",
     "_HMS_FAILURE_REASONS": "constant",
     "_HMS_FAILURE_REASONS": "constant",

+ 49 - 50
backend/tests/unit/test_library_dispatch_reuses_archive.py

@@ -6,10 +6,10 @@ same file, flagged as duplicates of each other. The dispatch now adds its run
 to the archive the file already has: same file, same owner, same project, from
 to the archive the file already has: same file, same owner, same project, from
 this library file (or one since deleted).
 this library file (or one since deleted).
 
 
-An archive holds one active run, and uploads run in parallel, so it is only
-taken while idle: not printing, not sent or waiting to start, not queued, not
-claimed by another dispatch still uploading. A print of the file running at
-the same time gets an archive of its own, as before.
+Also while the file prints, is queued or still finishing elsewhere: each
+print of an archive is its own run, with its own start, end, charge, energy,
+outcome, timelapse and photos (archive redesign), as the copies of a batch
+always were.
 """
 """
 
 
 import hashlib
 import hashlib
@@ -41,11 +41,9 @@ HASH = hashlib.sha256(CONTENT).hexdigest()
 def _clean_print_state():
 def _clean_print_state():
     main_module._active_prints.clear()
     main_module._active_prints.clear()
     main_module._expected_prints.clear()
     main_module._expected_prints.clear()
-    main_module._archive_runs_finishing.clear()
     yield
     yield
     main_module._active_prints.clear()
     main_module._active_prints.clear()
     main_module._expected_prints.clear()
     main_module._expected_prints.clear()
-    main_module._archive_runs_finishing.clear()
 
 
 
 
 @pytest.fixture
 @pytest.fixture
@@ -153,7 +151,7 @@ async def test_an_archive_whose_library_file_was_deleted_is_reused(case):
 
 
 
 
 @pytest.mark.asyncio
 @pytest.mark.asyncio
-async def test_the_newest_idle_archive_is_taken(case):
+async def test_the_newest_archive_is_taken(case):
     await case.archive()
     await case.archive()
     newest = await case.archive(tags="newest")
     newest = await case.archive(tags="newest")
     assert await _claim(case, PrintScheduler(), await case.item()) == newest
     assert await _claim(case, PrintScheduler(), await case.item()) == newest
@@ -169,7 +167,6 @@ async def test_the_newest_idle_archive_is_taken(case):
         pytest.param({}, {"project_id": 3}, id="project-print-of-a-loose-archive"),
         pytest.param({}, {"project_id": 3}, id="project-print-of-a-loose-archive"),
         pytest.param({"library_file_id": 999}, {}, id="another-library-file"),
         pytest.param({"library_file_id": 999}, {}, id="another-library-file"),
         pytest.param({"deleted_at": datetime(2026, 10, 1)}, {}, id="deleted"),
         pytest.param({"deleted_at": datetime(2026, 10, 1)}, {}, id="deleted"),
-        pytest.param({"status": "printing"}, {}, id="printing"),
         pytest.param({"on_disk": False}, {}, id="its-copy-is-gone"),
         pytest.param({"on_disk": False}, {}, id="its-copy-is-gone"),
     ],
     ],
 )
 )
@@ -180,23 +177,17 @@ async def test_no_archive_that_is_not_this_files_or_not_usable(case, archive_ove
 
 
 
 
 @pytest.mark.asyncio
 @pytest.mark.asyncio
-async def test_not_while_another_queue_item_prints_or_waits_on_it(case):
+async def test_also_while_another_queue_item_prints_or_waits_on_it(case):
     existing = await case.archive()
     existing = await case.archive()
     await case.item(archive_id=existing, library_file_id=None, status="pending")
     await case.item(archive_id=existing, library_file_id=None, status="pending")
-    assert await _claim(case, PrintScheduler(), await case.item()) is None
+    assert await _claim(case, PrintScheduler(), await case.item()) == existing
 
 
 
 
 @pytest.mark.asyncio
 @pytest.mark.asyncio
-async def test_not_while_one_of_its_prints_runs_or_finishes(case):
-    existing = await case.archive()
+async def test_also_while_one_of_its_prints_runs(case):
+    existing = await case.archive(status="printing")
     main_module._active_prints[(case.printer_id, "planter.3mf")] = existing
     main_module._active_prints[(case.printer_id, "planter.3mf")] = existing
-    assert await _claim(case, PrintScheduler(), await case.item()) is None
-
-    # Completed, but its timelapse is still being looked for: that scan tells
-    # its run by the archive's start time, which a new run would change.
-    main_module._active_prints.clear()
-    main_module._archive_runs_finishing[existing] = 1
-    assert await _claim(case, PrintScheduler(), await case.item()) is None
+    assert await _claim(case, PrintScheduler(), await case.item()) == existing
 
 
 
 
 @pytest.mark.asyncio
 @pytest.mark.asyncio
@@ -209,16 +200,13 @@ async def test_a_name_left_from_an_earlier_dispatch_does_not_block_it(case):
 
 
 
 
 @pytest.mark.asyncio
 @pytest.mark.asyncio
-async def test_two_dispatches_in_flight_never_share_it(case):
+async def test_two_dispatches_in_flight_share_it(case):
+    """The same file sent to two printers at once: one archive, two runs."""
     existing = await case.archive()
     existing = await case.archive()
     scheduler = PrintScheduler()
     scheduler = PrintScheduler()
     first, second = await case.item(), await case.item()
     first, second = await case.item(), await case.item()
 
 
     assert await _claim(case, scheduler, first) == existing
     assert await _claim(case, scheduler, first) == existing
-    assert await _claim(case, scheduler, second) is None
-
-    # The first dispatch ended: the archive is free for the next print.
-    scheduler._release_archive_claims(first)
     assert await _claim(case, scheduler, second) == existing
     assert await _claim(case, scheduler, second) == existing
 
 
 
 
@@ -304,31 +292,42 @@ async def test_a_reprint_of_another_plate_shows_that_plate(case):
     assert archive.plate_id == 3
     assert archive.plate_id == 3
 
 
 
 
-@pytest.mark.parametrize("fails", [False, True], ids=["sent", "failed"])
 @pytest.mark.asyncio
 @pytest.mark.asyncio
-async def test_the_dispatch_gives_its_claim_back_when_it_ends(fails):
-    """Sent, its print shows as running; failed, the archive is free again.
-    Another dispatch's claim is left alone."""
-    scheduler = PrintScheduler()
+async def test_the_file_sent_to_two_printers_at_once_has_one_archive(case):
+    """The first copy is printing when the second is sent: both are the
+    archive's, each printer's print its own run."""
+    existing = await case.archive()
+    async with case.session_maker() as db:
+        other = Printer(name="P1S", serial_number="SERIAL-2", ip_address="127.0.0.2", access_code="code", model="P1S")
+        db.add(other)
+        await db.commit()
+        other_id = other.id
+    archive_print = AsyncMock()
 
 
-    async def start_print(db, item):
-        scheduler._archive_claims[298] = 597
-        if fails:
-            raise RuntimeError("upload failed")
-
-    scheduler._archive_claims[300] = 600
-    db = MagicMock()
-    db.get = AsyncMock(return_value=MagicMock(id=597))
-    session = MagicMock()
-    session.__aenter__ = AsyncMock(return_value=db)
-    session.__aexit__ = AsyncMock(return_value=False)
-    with ExitStack() as stack:
-        stack.enter_context(patch("backend.app.services.print_scheduler.async_session", return_value=session))
-        stack.enter_context(patch.object(scheduler, "_claim_for_dispatch", AsyncMock(return_value=True)))
-        stack.enter_context(patch.object(scheduler, "_start_print", side_effect=start_print))
-        stack.enter_context(patch.object(scheduler, "_clear_dispatch_claim", AsyncMock()))
-        if fails:
-            stack.enter_context(pytest.raises(RuntimeError))
-        await scheduler._dispatch_one(597)
-
-    assert scheduler._archive_claims == {300: 600}
+    first, _ = await _start(case, await case.item(), archive_print)
+    async with case.session_maker() as db:
+        (await db.get(PrintArchive, existing)).status = "printing"
+        (await db.get(PrintQueueItem, first.id)).status = "printing"
+        await db.commit()
+    main_module._active_prints[(case.printer_id, "planter.3mf")] = existing
+    second, _ = await _start(case, await case.item(printer_id=other_id), archive_print)
+
+    archive_print.assert_not_called()
+    assert (first.archive_id, second.archive_id) == (existing, existing)
+
+
+@pytest.mark.asyncio
+async def test_while_it_prints_elsewhere_its_plate_and_cost_centre_stay_that_prints(case):
+    """A print without a queue item falls back to the archive's plate and cost
+    centre at its end; the new dispatch's run has its own."""
+    from backend.app.models.print_log import RUN_OPEN, PrintLogEntry
+
+    existing = await case.archive(cost_center_id=5, plate_id=1, status="printing")
+    async with case.session_maker() as db:
+        db.add(PrintLogEntry(archive_id=existing, printer_id=case.printer_id, status=RUN_OPEN, plate_id=1))
+        await db.commit()
+
+    item, archive = await _start(case, await case.item(plate_id=2), AsyncMock())
+
+    assert item.archive_id == existing
+    assert (archive.plate_id, archive.cost_center_id) == (1, 5)