fix(queue): cancel pending items and hide stale archive surface when archive soft-deleted (#1348)

Opening the Print Queue page fired 404s on /archives/{id}/thumbnail,
  /archives/{id}/plates, and /archives/{id}/plate-thumbnail/{n} for any
  row pointing at a soft-deleted archive. Two underlying problems wearing
  one mask:

  1. Cosmetic: the queue API was copying item.archive.thumbnail_path into
     archive_thumbnail without checking deleted_at. Soft-delete leaves the
     row (so the relationship resolves) but removes the file from disk, so
     the cached path was always stale.

  2. Functional: a queue item whose 3MF was removed can never dispatch.
     Without an explicit cancel, the item sits in 'pending' forever with
     no indication to the user about why nothing is printing.

  Fix in three parts:

  - New _cancel_pending_queue_items() helper, called from soft_delete_archive
    alongside the existing print-log thumbnail cleanup. Sets status='cancelled'
    + waiting_reason='Source archive deleted' on every pending queue item
    linked to the archive. Only 'pending' is touched - completed/failed/
    cancelled rows are historical and untouched. Hard-delete is already
    covered by ON DELETE CASCADE on print_queue.archive_id.

  - Queue API serializer now checks item.archive.deleted_at before
    populating any archive-derived field. New archive_deleted: bool field
    on PrintQueueItemResponse signals the soft-deleted state.

  - Frontend's getArchivePlates query in QueuePage was gated on archive_id
    only - archive_id is the real FK and stays exposed for dispatch/audit,
    so added an explicit && !item.archive_deleted clause to respect the
    new flag. Thumbnail render and CompactHistoryRow/QueueTimelineView
    already gate on archive_thumbnail so the backend suppression alone
    covers them.

  Regression tests pin cancel-only-pending behavior, soft-deleted
  suppression + archive_deleted=True flag, and the sanity guard that
  live-archive fields keep flowing through unchanged.
This commit is contained in:
maziggy
2026-05-16 12:58:21 +02:00
parent cad63500a8
commit 84ed28d5fa
9 changed files with 156 additions and 22 deletions
+2
View File
@@ -18,6 +18,8 @@ All notable changes to Bambuddy will be documented in this file.
- **Slice modal: pick the build plate (#1337, reported by @digitalskies)** — Slicing a plain STL through the integrated slicer always defaulted to whatever `curr_bed_type` lived in the chosen process preset (typically `Cool Plate`), which the slicer CLI then rejected for high-temp filaments with `Plate 1: Cool Plate does not support filament 1`. The user had no way to switch plates short of cloning the process preset in BambuStudio, which defeats the point of the in-app slicer. The Slice modal now exposes a `Build plate` dropdown with the six canonical BambuStudio / OrcaSlicer plates (Cool Plate, Cool Plate SuperTack, Engineering Plate, High Temp Plate, Textured PEI Plate, Smooth PEI Plate) plus an explicit `Auto (use process preset)` option that preserves the previous behavior. The dropdown sits between Process profile and Filament rows so it stays visible regardless of how many filament slots the picked plate uses (a long filament list would otherwise push it off the modal's `max-h-[85vh]` scroll viewport) and is **always enabled** — including when the user picks a Printer Preset Bundle from the top BundlePicker. When the user picks a specific plate, the new `bed_type` field on `SliceRequest` ([`backend/app/schemas/slicer.py`](backend/app/schemas/slicer.py)) flows through the dispatcher via two paths: (1) **resolved-preset path** — the route helper `_patch_process_bed_type` in [`backend/app/api/routes/library.py`](backend/app/api/routes/library.py) overwrites `curr_bed_type` on the resolved process JSON before forwarding to the sidecar (no preset cloning required); (2) **bundle dispatch path** — `slice_with_bundle` in [`backend/app/services/slicer_api.py`](backend/app/services/slicer_api.py) adds a `bedType` form field to the sidecar multipart so the sidecar can pass `--curr_bed_type` through to the CLI, which lets the override take effect even though Bambuddy can't patch the bundle's process JSON locally (the sidecar materialises it from the stored .bbscfg). Sidecar versions that don't recognise the field silently no-op — the slice still runs, just with the bundle's default plate; the slicer-API fork at maziggy/orca-slicer-api will need the matching change for the bundle path to take full effect. **i18n parity:** 8 new keys (`slice.bedType.{label,auto,coolPlate,coolPlateSuperTack,engineering,highTemp,texturedPEI,smoothPEI}`) added to all 8 locales — full German translation, English fallbacks elsewhere per project convention. **Regression tests:** 4 in [`test_slice_request_bed_type.py`](backend/tests/unit/test_slice_request_bed_type.py) (`bed_type` defaults to None, accepts the six canonical strings, rejects overlong input via the schema's `max_length=64`; `_patch_process_bed_type` overwrites an existing value, adds the field when missing, and returns the input unchanged for malformed JSON or non-dict roots), 4 in [`test_library_slice_api.py`](backend/tests/integration/test_library_slice_api.py) (resolved-preset path: with `bed_type` set, the sidecar receives `"curr_bed_type": "Textured PEI Plate"` in the presetProfile multipart part; without it, `curr_bed_type` stays out of the body entirely. bundle dispatch path: `bedType` form field carries the override through to the sidecar; omitting `bed_type` keeps the form field out of the request so the bundle's own `curr_bed_type` is preserved), 2 in [`SliceModal.test.tsx`](frontend/src/__tests__/components/SliceModal.test.tsx) (dropdown selection puts `bed_type` on the request; leaving it on Auto omits the field). 59 backend slice tests + 34 SliceModal tests pass; build and i18n parity script clean.
### Fixed
- **Print Queue page no longer 404-storms thumbnail / plates / plate-thumbnail when an item points at a soft-deleted archive, and pending queue items for a soft-deleted archive are now cancelled with a clear reason instead of silently stuck-pending forever** ([#1348](https://github.com/maziggy/bambuddy/issues/1348) follow-up) — Symptom: opening the Queue page or any of its sub-views fired `GET /archives/{id}/thumbnail`, `GET /archives/{id}/plates`, and `GET /archives/{id}/plate-thumbnail/{n}` for queue rows pointing at archives that had been soft-deleted (#1343 leaves the row but removes files from disk), all returning 404. Frontend's `onError` handler hid the broken `<img>` so it was visually clean, but the network tab and any pending-print logic still saw three 404s per affected row. Two underlying problems wearing one mask: cosmetic 404 storm, AND functional — a queue item whose 3MF was removed from disk can never actually dispatch, so it sits in `pending` forever with no clue to the user about why. **Two-part fix in the same shape as the print-log followup above:** (1) New helper `_cancel_pending_queue_items(db, archive_id)` in [`backend/app/services/archive.py`](backend/app/services/archive.py) sets `status='cancelled'` + `waiting_reason='Source archive deleted'` on every pending queue item linked to the archive; called from `soft_delete_archive` alongside the existing `_null_print_log_thumbnail_paths` cleanup. Only `pending` is touched — `printing` is a rare race that the printer-side fail-path catches anyway, and completed / failed / cancelled rows are historical audit-trail. Hard-delete is already covered by `ON DELETE CASCADE` on `print_queue.archive_id`. (2) Queue API serializer in [`backend/app/api/routes/print_queue.py:224`](backend/app/api/routes/print_queue.py) now checks `item.archive.deleted_at` before populating any archive-derived field — when soft-deleted, the whole block (`archive_name`, `archive_thumbnail`, `print_time_seconds`, `filament_used_grams`, plate-specific re-reads, …) is skipped and the new `archive_deleted: bool = True` flag on [`PrintQueueItemResponse`](backend/app/schemas/print_queue.py) signals the soft-deleted state. The `archive_thumbnail` suppression alone covers the thumbnail render in [`QueuePage.tsx:434`](frontend/src/pages/QueuePage.tsx), [`CompactHistoryRow.tsx:45`](frontend/src/components/CompactHistoryRow.tsx), and [`QueueTimelineView.tsx:72`](frontend/src/components/QueueTimelineView.tsx) because they all gate on it. The `/plates` query at [`QueuePage.tsx:329`](frontend/src/pages/QueuePage.tsx) was gated on `archive_id` only — `archive_id` is the real FK and stays exposed in the response (the queue scheduler still needs it for audit / dispatch checks), so the query was added a new `&& !item.archive_deleted` clause to respect the new flag. **Regression tests** in [`backend/tests/integration/test_print_queue_api.py`](backend/tests/integration/test_print_queue_api.py): `test_soft_delete_archive_cancels_pending_queue_items` pins the cancel-only-pending behavior (completed rows untouched), `test_queue_api_hides_archive_surface_when_soft_deleted` pins the suppression + `archive_deleted=True` for soft-deleted archives, `test_queue_api_still_exposes_archive_surface_when_live` pins the sanity guard that live archives' fields keep flowing through. All 3 new tests + 146 in the queue/archives/obico sweep pass; ruff clean.
- **Print log no longer 404-storms on the thumbnail endpoint for entries whose archive was deleted or whose print failed before a thumbnail was extracted** ([#1348](https://github.com/maziggy/bambuddy/issues/1348) follow-up) — Symptom in DevTools when opening Archives → Print Log: a 404 per orphaned entry per render. Visually clean (the `<img onError>` handler in [`frontend/src/pages/ArchivesPage.tsx:3763`](frontend/src/pages/ArchivesPage.tsx) hides the broken image), but noisy and wasteful. **Root cause** in [`backend/app/api/routes/print_log.py:91-113`](backend/app/api/routes/print_log.py): `PrintLogEntry.thumbnail_path` is copied by value from `archive.thumbnail_path` at write-time ([`main.py:3615`](backend/app/main.py)) but the FK on `archive_id` is `ON DELETE SET NULL` (#1378) — so log entries survive archive deletion to preserve stats history, but the cached thumbnail_path string keeps pointing at a file that was removed when the archive's directory was deleted. Same shape for failed prints that recorded an expected thumbnail path before the extractor wrote (or skipped writing) the file. **Two-part fix:** (1) `get_print_log_thumbnail` self-heals when the file is missing — it NULLs `thumbnail_path` on the entry and commits before returning 404, so the frontend's `entry.thumbnail_path && <img>` gate keeps the next page render from re-requesting. (2) New helper `_null_print_log_thumbnail_paths(db, archive_id)` in [`backend/app/services/archive.py`](backend/app/services/archive.py) is called from `soft_delete_archive` and `delete_archive` before the on-disk files are removed — eager clear so future deletes don't cause the one-time storm at all. The route handler covers stragglers (failed prints, files manually moved, etc.). **Regression tests** in [`backend/tests/integration/test_archives_api.py`](backend/tests/integration/test_archives_api.py): `test_soft_delete_clears_thumbnail_path_on_linked_log_entries` pins the eager clear on the soft-delete route, `test_hard_delete_clears_thumbnail_path_before_fk_cascade` pins it on `ArchiveService.delete_archive` (used by the auto-purge sweeper), `test_print_log_thumbnail_route_lazy_nulls_missing_file` pins the route's self-heal for failed-print orphans where the file was never written. All 4 new tests + 61 in the archives/print-log sweep pass; ruff clean.
- **Camera stream no longer freezes every ~30 s when Obico fault detection is enabled on the same printer the user is viewing** ([#1348](https://github.com/maziggy/bambuddy/issues/1348), reported by @SL666) — Symptom on an X1-class printer running firmware that allows only one concurrent camera connection: opening the live camera worked initially, then the stream hung within seconds and "cancelled" — clicking the in-UI refresh restored it for another few seconds before it hung again, ad infinitum. Disabling Obico fault detection made the stream stable. **Root cause** in `_capture_frame` at [`backend/app/services/obico_detection.py:209-220`](backend/app/services/obico_detection.py): the buffer-reuse path that was supposed to make Obico polling free (#1271) only worked when `_last_frames[printer_id]` was populated. In every race window where `_active_streams` had a registered entry but the JPEG buffer was empty — stream startup before the first frame lands (1–3 s on RTSP), or any moment the upstream ffmpeg was mid-reconnect after a 30 s read timeout — `try_get_active_buffered_frame(printer_id)` returned `None` and the caller fell through to `capture_camera_frame_bytes(...)`, which spawned its own ffmpeg + TLS proxy and opened a competing RTSP socket on the printer. On firmwares that only allow one camera connection, that second socket forced the printer to drop the live fan-out connection — viewers' ffmpeg hit its 30 s read timeout, looped through 30 reconnect attempts at 0.2 s each (all racing the next Obico poll 10 s later), exhausted retries, and the broadcaster pump exited. The user's viewer disconnected with no obvious cause in the log because the Obico capture itself looked successful (`Successfully captured camera frame bytes: 83484 bytes`). **Fix:** the buffer-reuse gate was widened from "do we have a frame in the buffer?" to "is any fan-out stream registered for this printer?" — even when the buffer is momentarily empty. New helper `is_stream_active(printer_id) -> bool` at [`backend/app/api/routes/camera.py:82`](backend/app/api/routes/camera.py) checks `_active_streams` / `_active_chamber_streams` independently of buffer state. `_capture_frame` now consults `is_stream_active` first: if True, it returns the buffered frame when available or `None` (skip this poll cycle) when not — it **never** opens a competing socket while a viewer is attached, regardless of buffer state. The poll loop retries 10 s later, by which time the buffer is virtually guaranteed to be populated. The fresh-socket path still fires unchanged when no viewer is connected (Obico's primary use case: detection on idle/headless prints). Cost of the fix: at most one missed Obico detection cycle per viewer-attach (~10 s lag); benefit: zero competing-socket events while any viewer is connected. `try_get_active_buffered_frame` was refactored to delegate to `is_stream_active` so the two helpers stay in lockstep — its `/camera/snapshot` caller at [`camera.py:859`](backend/app/api/routes/camera.py) is unchanged behaviorally (snapshot is user-initiated single-shot; falling through to a fresh capture if buffer is momentarily empty is the desired behavior there). **Regression tests** in [`backend/tests/unit/test_obico_detection.py`](backend/tests/unit/test_obico_detection.py): new `test_skips_poll_when_stream_active_but_buffer_empty` reproduces the exact race (viewer registered, buffer empty) and pins that `_capture_frame` returns `None` and `capture_camera_frame_bytes` is NOT called; existing `test_returns_buffered_frame_when_stream_active` and `test_falls_back_to_fresh_capture_when_no_stream` were updated to patch the new `is_stream_active` helper alongside `try_get_active_buffered_frame`. All 31 obico tests + 113 in the wider camera/obico sweep pass; ruff clean.
+30 -18
View File
@@ -222,24 +222,36 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
}
response = PrintQueueItemResponse(**item_dict)
if item.archive:
response.archive_name = item.archive.print_name or item.archive.filename
response.archive_thumbnail = item.archive.thumbnail_path
response.print_time_seconds = item.archive.print_time_seconds
response.filament_used_grams = item.archive.filament_used_grams
response.filament_type = item.archive.filament_type
response.filament_color = item.archive.filament_color
response.layer_height = item.archive.layer_height
response.nozzle_diameter = item.archive.nozzle_diameter
response.sliced_for_model = item.archive.sliced_for_model
if item.plate_id:
archive_path = settings.base_dir / item.archive.file_path
if archive_path.exists():
plate_time = _extract_print_time_from_3mf(archive_path, item.plate_id)
plate_weight = sum(f["used_g"] for f in extract_filament_usage_from_3mf(archive_path, item.plate_id))
if plate_time is not None:
response.print_time_seconds = plate_time
if plate_weight > 0:
response.filament_used_grams = plate_weight
# Soft-deleted archive: files are gone from disk but the row stays
# (its filament/cost contribution still flows into stats per #1343).
# Suppress the archive-derived UI surface so the queue page doesn't
# 404-storm the thumbnail / plates / plate-thumbnail endpoints — the
# frontend's existing truthy gate on archive_thumbnail covers it
# (#1348 follow-up). The archive_deleted flag lets the UI render a
# "source deleted" badge on these rows.
if item.archive.deleted_at is not None:
response.archive_deleted = True
else:
response.archive_name = item.archive.print_name or item.archive.filename
response.archive_thumbnail = item.archive.thumbnail_path
response.print_time_seconds = item.archive.print_time_seconds
response.filament_used_grams = item.archive.filament_used_grams
response.filament_type = item.archive.filament_type
response.filament_color = item.archive.filament_color
response.layer_height = item.archive.layer_height
response.nozzle_diameter = item.archive.nozzle_diameter
response.sliced_for_model = item.archive.sliced_for_model
if item.plate_id:
archive_path = settings.base_dir / item.archive.file_path
if archive_path.exists():
plate_time = _extract_print_time_from_3mf(archive_path, item.plate_id)
plate_weight = sum(
f["used_g"] for f in extract_filament_usage_from_3mf(archive_path, item.plate_id)
)
if plate_time is not None:
response.print_time_seconds = plate_time
if plate_weight > 0:
response.filament_used_grams = plate_weight
if item.library_file:
response.library_file_name = (
item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
+7
View File
@@ -104,6 +104,13 @@ class PrintQueueItemResponse(BaseModel):
# Nested info for UI (populated in route)
archive_name: str | None = None
archive_thumbnail: str | None = None
# True when the linked archive has been soft-deleted (its files are gone
# from disk). In that case the *archive_name* / *archive_thumbnail* /
# downstream metadata fields are intentionally left None so the frontend
# doesn't 404-storm the now-missing thumbnail / plates / plate-thumbnail
# endpoints (#1348 follow-up). Frontends can render a "source deleted"
# badge based on this flag.
archive_deleted: bool = False
library_file_name: str | None = None # Name of library file (if library_file_id is set)
library_file_thumbnail: str | None = None # Thumbnail of library file
printer_name: str | None = None
+25
View File
@@ -845,6 +845,30 @@ async def _null_print_log_thumbnail_paths(db: AsyncSession, archive_id: int) ->
await db.execute(sa_update(PrintLogEntry).where(PrintLogEntry.archive_id == archive_id).values(thumbnail_path=None))
async def _cancel_pending_queue_items(db: AsyncSession, archive_id: int) -> None:
"""Cancel pending queue items pointing at *archive_id* (#1348 follow-up).
Called from ``soft_delete_archive`` only — hard-delete is covered by the
``ON DELETE CASCADE`` on ``print_queue.archive_id``. A queue item
pointing at an archive whose 3MF has been removed from disk can never
actually dispatch, so cancelling at delete time both (a) tells the user
why the item disappeared from the pending list, and (b) stops the queue
page from 404-storming the archive thumbnail / plates / plate-thumbnail
endpoints when the row is rendered. Only ``pending`` items are touched;
``printing`` is a rare race the printer-side fail-path catches, and
completed / failed / cancelled rows are historical and untouched.
"""
from sqlalchemy import update as sa_update
from backend.app.models.print_queue import PrintQueueItem
await db.execute(
sa_update(PrintQueueItem)
.where(PrintQueueItem.archive_id == archive_id, PrintQueueItem.status == "pending")
.values(status="cancelled", waiting_reason="Source archive deleted")
)
class ArchiveService:
"""Service for archiving print jobs."""
@@ -1271,6 +1295,7 @@ class ArchiveService:
dir_to_delete = self._resolve_archive_dir_for_delete(archive)
await _null_print_log_thumbnail_paths(self.db, archive_id)
await _cancel_pending_queue_items(self.db, archive_id)
archive.deleted_at = datetime.now(timezone.utc)
await self.db.commit()
@@ -1833,3 +1833,85 @@ class TestAbortedStatusNormalisation:
"""Verify 404 for non-existent batch."""
response = await async_client.get("/api/v1/queue/batches/9999")
assert response.status_code == 404
# ========================================================================
# Soft-deleted archive handling (#1348 follow-up)
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_soft_delete_archive_cancels_pending_queue_items(
self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
):
"""Soft-deleting an archive cancels its pending queue items with a
clear reason. The 3MF is gone from disk so the item can never
dispatch — leaving it in 'pending' would 404-storm the queue page
and confuse the user about why nothing prints."""
from backend.app.services.archive import ArchiveService
printer = await printer_factory()
archive = await archive_factory(thumbnail_path="archives/test/test/thumbnail.png")
pending = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="pending")
completed = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="completed")
service = ArchiveService(db_session)
assert await service.soft_delete_archive(archive.id) is True
await db_session.refresh(pending)
await db_session.refresh(completed)
assert pending.status == "cancelled"
assert pending.waiting_reason == "Source archive deleted"
# Historical rows untouched — they're audit-trail.
assert completed.status == "completed"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_queue_api_hides_archive_surface_when_soft_deleted(
self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
):
"""Queue serializer must NOT populate archive_thumbnail / archive_name
when the archive is soft-deleted — otherwise the frontend renders a
broken <img> and 404-storms the thumbnail / plates / plate-thumbnail
endpoints. archive_deleted=True signals the soft-deleted state so
the UI can render a 'source deleted' badge."""
from datetime import datetime, timezone
printer = await printer_factory()
archive = await archive_factory(
print_name="Test Print",
thumbnail_path="archives/test/test/thumbnail.png",
deleted_at=datetime.now(timezone.utc), # Pre-soft-deleted
)
item = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="cancelled")
resp = await async_client.get("/api/v1/queue/")
assert resp.status_code == 200
body = resp.json()
row = next((r for r in body if r["id"] == item.id), None)
assert row is not None
assert row["archive_deleted"] is True
assert row["archive_thumbnail"] is None, "must not expose stale thumbnail path for soft-deleted archive"
assert row["archive_name"] is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_queue_api_still_exposes_archive_surface_when_live(
self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
):
"""Sanity guard: the soft-delete suppression must not affect live
archives. archive_name / archive_thumbnail still flow through and
archive_deleted stays False."""
printer = await printer_factory()
archive = await archive_factory(
print_name="Live Archive",
thumbnail_path="archives/test/live/thumbnail.png",
)
item = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="pending")
resp = await async_client.get("/api/v1/queue/")
assert resp.status_code == 200
row = next((r for r in resp.json() if r["id"] == item.id), None)
assert row is not None
assert row["archive_deleted"] is False
assert row["archive_name"] == "Live Archive"
assert row["archive_thumbnail"] == "archives/test/live/thumbnail.png"
+4
View File
@@ -1691,6 +1691,10 @@ export interface PrintQueueItem {
created_at: string;
archive_name?: string | null;
archive_thumbnail?: string | null;
// True when the linked archive has been soft-deleted; archive_name /
// archive_thumbnail / downstream metadata are left null in that case so
// the UI doesn't 404-storm the now-missing endpoints (#1348 follow-up).
archive_deleted?: boolean;
library_file_name?: string | null;
library_file_thumbnail?: string | null;
printer_name?: string | null;
+4 -2
View File
@@ -325,11 +325,13 @@ function SortableQueueItem({
// Determine if we're printing a library file
const isLibraryFile = !!item.library_file_id && !item.archive_id;
// Fetch archive plate details
// Fetch archive plate details. Skip when the linked archive has been
// soft-deleted (#1348 follow-up): its 3MF is gone from disk so the
// /plates endpoint just 404-storms the queue page.
const { data: archivePlatesData } = useQuery({
queryKey: ['archive-plates', item.archive_id],
queryFn: () => api.getArchivePlates(item.archive_id!),
enabled: !!item.archive_id && !isLibraryFile,
enabled: !!item.archive_id && !isLibraryFile && !item.archive_deleted,
});
// Fetch library file plate details
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CRZ2F5mH.js"></script>
<script type="module" crossorigin src="/assets/index-CFzqkJEl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Baw5c3Hn.css">
</head>
<body>