diff --git a/CHANGELOG.md b/CHANGELOG.md index 94bc60575..987fc386b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,8 @@ All notable changes to Bambuddy will be documented in this file. - **AMS drying now enabled for H2C starting at firmware 01.02.00.00** — H2C was previously in `_DRYING_UNSUPPORTED_MODELS` alongside the A1 family. Moved to `_DRYING_MIN_FIRMWARE` with the same `01.02.00.00` floor as H2S / P2S. Both SSDP model codes the H2C advertises (`O1C`, `O1C2` — single- and dual-nozzle variants) get the same firmware gate so the `supports_drying()` check fires correctly regardless of which form is in the printer record. Test coverage extended in `TestSupportsDrying`: H2C / O1C / O1C2 cases added to the with-firmware pass set, the old-firmware fail set, and removed from the unsupported-models loop. ### Fixed +- **PostgreSQL restore from a SQLite backup no longer deadlocks against the print scheduler (reproduced 2026-06-09 restoring a native install's backup into a fresh Docker+Postgres deploy)** — Reporter (Maziggy) backed up the native install, brought up the new Docker image against an external Postgres, hit Restore in Settings → Backup. ~2 seconds in, the restore aborted with `asyncpg.exceptions.DeadlockDetectedError: Process X waits for AccessExclusiveLock on relation 109940; Process Y waits for RowExclusiveLock on relation 110182`. **Root cause: the existing `close_all_connections()` step before the DB swap only disposes the SQLAlchemy engine's connection POOL — the asyncio tasks that USE the engine keep running.** The `print_scheduler.run()` loop (30 s cadence) and `smart_plug_manager._snapshot_loop()` (30 s cadence) wake up after the dispose, call `async_session()`, lazily reopen a pool connection, and start a normal transaction that grabs `RowExclusiveLock` on `print_queue` / `smart_plug_energy_snapshots`. The restore's `DROP TABLE IF EXISTS public. CASCADE` pass in `_import_sqlite_to_postgres` needs `AccessExclusiveLock` on every public table — AB/BA lock-order conflict, classic Postgres deadlock, restore transaction rolled back. The log confirms: `13:44:53,669` restore begins → `13:44:53,680` print_scheduler fires queue check → `13:44:55,607` smart_plug_manager fires snapshot → `13:44:55,607` deadlock detected. The existing code already paused `virtual_printer_manager` before restore for file-lock reasons; the other timer-based DB writers were missed. **Fix — two layers.** (1) Before `close_all_connections()`, pause the four most active timer-based DB writers via their existing stop affordances: `print_scheduler.stop()`, `smart_plug_manager.stop_scheduler()`, `notification_service.stop_digest_scheduler()`, `await background_dispatch.stop()`. Then `await asyncio.sleep(1.0)` to let in-flight loop iterations commit and release their sessions before the engine pool gets disposed. We don't restart the services on success because the restore handler already tells the user to restart Bambuddy to pick up the new DB. (2) Belt-and-braces inside `_import_sqlite_to_postgres`: prepend `SET LOCAL lock_timeout = '10s'` to the begin-block before the `DROP TABLE CASCADE` pass, so any residual writer that slips through the pause window (per-printer MQTT clients writing reactively to state changes, the hourly AMS history recorder firing inside the restore window, etc.) surfaces a fast `lock_timeout` error instead of producing a fresh deadlock or hanging the restore for 30+ seconds. `SET LOCAL` is transaction-scoped so the global default applies to every other DB caller. Scope clarification: there are ~12 background services started at lifespan startup; the four paused here are the ones with the tightest cadences. Slower-cadence services (`github_backup_service`, `local_backup_service`, `library_trash_service`, `archive_purge_service`, AMS history, runtime tracking, SpoolBuddy watchdog, camera cleanup) all fire on hour-or-longer intervals and are statistically very unlikely to land inside a few-second restore window; the lock_timeout layer catches them if they do. **Tests**: `test_restore_sqlite_wal_safety.py` and `test_settings_api.py` integration suites (53 tests) stay green on the edited handler; ruff clean; runtime smoke (`from backend.app.services.X import Y` + `hasattr` + `iscoroutinefunction` check) confirms all four stop signatures match the patch's sync/async mix. + - **Configure Slot now keeps the active K-profile on reopen for assigned-but-unconfigured slots (#1689 follow-up, reported and patched by @Spionkiller01)** — After the original #1689 fix shipped, Spionkiller01 found a residual case: on a slot that's *physically loaded but unconfigured* (filament inserted, but the printer hasn't bound a preset yet — `tray_type=""`, `tray_info_idx=""`, no `slot_preset_mappings` row), the first open of Configure Slot showed the right K-profile, but closing it with the X and reopening it dropped back to "default 0.020". Clicking "Configure slot" (Apply) once persisted it, but the user shouldn't have to. **Root cause: the original #1689 cali_idx safety net was unreachable on this code path.** `matchingKProfiles` in `ConfigureAmsSlotModal.tsx:751` early-returned `[]` when `selectedPresetInfo` was null — and `selectedPresetInfo` resolves to null exactly when there's no resolvable slot preset (unconfigured slot, no mapping row). The "always include the slot's currently-active K-profile by cali_idx" branch lives *past* the main name+id matcher, so it never ran from the no-preset path. On first open a freshly-cached preset briefly let the safety net trigger; on reopen the live slot state had no preset, returned `[]`, the auto-select effect saw no candidates, the modal fell back to default 0.020. **Fix (verbatim from Spionkiller01's H2C-tested diff, with the existing extruder guard):** split the early return into two — still short-circuit on missing kprofilesData, but when `selectedPresetInfo` is null and `slotInfo.caliIdx > 0`, find the active profile by `slot_id === activeIdx` (extruder-matched when known) and return it as a single-item list. The auto-select effect downstream then pre-selects it on reopen with no extra change. Strictly additive: with a resolvable preset present the existing matcher runs untouched; with `caliIdx === 0 || null` the function still returns `[]` (no unrelated profiles leak in). **Tests:** new vitest case `surfaces the slot's active K-profile when no preset is resolvable (#1689 follow-up)` exercises the path with `trayType=''`, no `savedPresetId`, and `caliIdx=6` against a K-profile fixture at `slot_id=6` — asserts the dropdown surfaces it. Verified the test fails without the patch (stash → run filter → fail; pop → run → pass). The existing `caliIdx === 0` guard test continues to pass under the new branch. Full ConfigureAmsSlotModal vitest 24/24 green. **Credit:** @Spionkiller01 for spotting the residual edge case after merge, producing the diff, and testing live on an H2C — `Co-Authored-By` on the commit. - **K-profile matching now prefers filament_id over parsed names — surfaces custom profiles in the spool form AND fixes Configure Slot showing "default 0.020" for an actively-bound K-profile (#1688 + #1689, both reported and diagnosed by @Spionkiller01 with concrete H2C testing; #1689 also reported by @IndividualGhost1905)** — Two related symptoms on different UI surfaces, same root cause. **#1688: spool form's PA-profile suggester** (`frontend/src/components/spool-form/PAProfileSection.tsx` via `isMatchingCalibration` in `spool-form/utils.ts`) only matched K-profiles by parsing the profile *name* for material/brand/variant. Spools already store `slicer_filament` (the slicer preset's id) and K-profiles already carry `filament_id`, but both were ignored — so a user's custom K-profile whose name doesn't agree with the slicer preset's name got silently dropped from the suggestion list even when the underlying filament_id was identical. **#1689: ConfigureAmsSlotModal's K-profile filter** (`matchingKProfiles`) ran the same name-only logic on the slot's selected preset — a spool assigned under "Generic PLA" with a custom K-profile actively bound on the printer landed in the modal as "K profile not assigned, default 0.020 will be used", while the printer-card hover-card correctly showed the active profile. The hover-card and the Configure Slot modal disagreed because they used different lookup paths; the modal's path was the one with the name-parse filter. **The shared root cause: spool preset ids and K-profile filament_ids look different but are equivalent after normalisation.** Spools store `slicer_filament` as the cloud setting_id form ("GFSG98_09" — `_09` is the variant suffix, the "S" infix marks it as a setting_id); K-profiles store `filament_id` as the bare form ("GFG98"). Plain `===` doesn't match; both need normalising first. This conversion already exists *in the other direction* at `buildFilamentOptions` (filament_id → "GFS" + filament_id.slice(2) for setting_id), so the inverse `toFilamentId` helper isn't speculative — it's just the matching reverse. **Fix — one shared helper, two surfaces:** new exports in `frontend/src/components/spool-form/utils.ts` — `toFilamentId(id)` normalises both shapes by dropping the "_NN" variant suffix and stripping the "S" in "GFS" (so both "GFSG98_09" and "GFG98" yield "GFG98"); `isGenericFilamentId(id)` flags Bambu's generic `GFx99` ids (GFL99 = generic PLA, GFG99 = generic PETG, etc.) which are shared across many physical filaments and must NOT id-match (they over-match and obscure brand-specific profiles — name fallback handles those correctly). Then: (1) `isMatchingCalibration` accepts a new `slicer_filament?: string` formData field, tries id-match first (with generic exclusion), falls through to the existing name parse — `PAProfileSection` already passes the full `formData` so no caller edit needed. (2) `ConfigureAmsSlotModal.selectedPresetInfo` now also resolves a `filamentId` (via `toFilamentId(cp.setting_id)` for cloud presets; `toFilamentId(builtinFilamentId)` for builtin; empty for local/orca paths that fall through to name match); `matchingKProfiles` adds the id-match check at the top of the per-profile predicate, then keeps the existing name logic, then *always* unshifts the slot's currently-active K-profile (by `slot_id === slotInfo.caliIdx`, gated on `activeIdx > 0` so caliIdx=0/null doesn't leak unrelated profiles in, and extruder-matched when known) — covers the #1689 case where the spool was bound under a generic preset but the active profile lives under a different filament_id entirely. The "always include active" branch is Spionkiller01's #1689 diff verbatim, gated more tightly. **SpoolBuddy coverage:** both K-profile surfaces in the kiosk UI reuse the shared components — `SpoolBuddyWriteTagPage` renders `` (auto-fixed via `isMatchingCalibration`), `SpoolBuddyAmsPage` renders `` (auto-fixed via `matchingKProfiles`). No kiosk-specific edits required; the shared helpers carry the fixes through. (`SpoolBuddyCalibrationPage` is scale calibration, unrelated; `InventorySpoolInfoCard` is display-only.) **What this does NOT change**: spools without a slicer_filament, K-profiles without a filament_id, and generic GFx99 ids all fall through to the existing name-based matching path — strictly additive precedence, no behaviour change for the name-only cases that already worked. The new id-match never causes a *miss* the old code would have caught. **Tests:** 21 new vitest cases — `isMatchingCalibration.test.ts` (18 cases) pins the `toFilamentId` round-trip in both directions (GFSG98_09 → GFG98 and back is identity-preserving for the cloud→K-profile flow), the generic `GFx99` exclusion, falsy/non-Bambu id pass-through (numeric local-preset id, Orca UUID), and the id-match-wins-over-name behaviour including the spool's reported `"GFSG98_09" ↔ K-profile "GFG98"` real-data scenario. `ConfigureAmsSlotModal.test.tsx` (3 cases) pins the modal-level behaviour: a custom K-profile name surfaces when filament_id matches (#1688 in-modal), the slot's active profile is always included even with no name/id match (#1689), and the `caliIdx == 0` guard prevents unrelated profiles from leaking in via the safety net. Full frontend vitest suite: 2108 / 2108 green. ESLint clean on touched files; frontend build clean. **Credit & dispatch:** @Spionkiller01 diagnosed both issues with concrete data (the `GFSG98_09 ↔ GFG98` normalisation case is theirs), tested both patches live on an H2C, and explicitly offered to PR. Landed verbatim with adjustments (shared helper, tighter active-profile guard) and `Co-Authored-By`. @IndividualGhost1905 also reported #1689 independently and identified its connection to #1688. diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 87cc3bef0..201646707 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -694,6 +694,15 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str): table.constraints.discard(fk) async with pg_engine.begin() as conn: + # Cap how long DROP TABLE will wait for AccessExclusiveLock so + # any residual concurrent writer (per-printer MQTT clients + # writing reactively, an AMS history recorder firing on its + # hourly cadence) surfaces a fast `lock_timeout` error instead + # of blocking the restore for 30 s or producing a deadlock. + # SET LOCAL scopes to this transaction only; outside this + # restore path the global default (no timeout) applies. + await conn.execute(text("SET LOCAL lock_timeout = '10s'")) + # Drop every existing table in the public schema with CASCADE # rather than `metadata.drop_all`. Two reasons: # 1. The user's live DB may carry orphan tables from removed @@ -910,6 +919,35 @@ async def restore_backup( except Exception as e: logger.warning("Failed to stop virtual printer: %s", e) + # 3b. Pause timer-based background services BEFORE the DB swap. + # close_all_connections() below only disposes the engine's pool, + # not the asyncio tasks that opened sessions from it. The print + # scheduler (30 s cadence), smart-plug snapshot loop (30 s), + # notification digest loop, and background dispatch worker all + # wake up and call async_session(), which lazily re-creates a + # pool connection holding RowExclusiveLock on print_queue / + # smart_plug_energy_snapshots / etc. The DROP TABLE CASCADE + # pass in the PostgreSQL restore path needs AccessExclusiveLock + # on every public table, producing an AB/BA deadlock and a + # full restore rollback. Successful restore already requires a + # container restart, so we don't restart the services here. + try: + from backend.app.services.background_dispatch import background_dispatch + from backend.app.services.notification_service import notification_service + from backend.app.services.print_scheduler import scheduler as print_scheduler + from backend.app.services.smart_plug_manager import smart_plug_manager + + logger.info("Pausing background services for restore...") + print_scheduler.stop() + smart_plug_manager.stop_scheduler() + notification_service.stop_digest_scheduler() + await background_dispatch.stop() + # In-flight loop iterations need a moment to commit + release + # their DB sessions before we dispose() the engine pool. + await asyncio.sleep(1.0) + except Exception as e: + logger.warning("Could not cleanly pause background services: %s", e) + # 4. Close current database connections logger.info("Closing database connections...") await close_all_connections()