Przeglądaj źródła

fix(backup): report the rows a failed K-profile step already committed (#2656)

`_apply` commits the database categories before the K-profile phase, and the
comment there is right about why: `get_kprofiles` is 3 x 5 s per printer per
nozzle and SQLite's `busy_timeout` is 15 s, so holding the writer across the
MQTT phase would fail every concurrent writer in the app.

But `run_restore`'s handler returns `{"success": False, ..., "results": {}}`
for anything raised after that point, and the per-call guards inside
`_restore_kprofiles` do not cover the whole phase. Two consequences, and the
second is worse:

* The user is told the restore failed and handed an empty `results` while the
  archive, spool and settings rows are durable on disk. The honest-reporting
  theme this whole feature is built on inverted on exactly the path where it
  matters most.
* `_reconfigure_mqtt_relay` sits inside the same `try`, downstream of the
  raise. A restore that rewrote the mqtt_* rows left the relay pointed at the
  pre-restore broker until something else reconfigured it.

`_apply` now contains the K-profile phase: fold the error into that category's
tally as `failed` plus a `kprofilesStepFailed` note, and let the results it has
already committed be returned and reported. Every profile the payload carried
and the phase did not account for is counted failed — silence would have been
the same lie in a smaller font. `_reconfigure_mqtt_relay` is reached again
because `_apply` returns normally. The rollback in the handler discards only
the phase's own read transaction, so a database error cannot leave the session
in a state that turns the caller's commit into the very report this prevents.

`kprofilesSendFailed` was the obvious note to reuse and is the wrong one: it
names a nozzle, a printer and a serial that a phase-level failure does not
have, and "failed to send" is untrue of a step that never got as far as
sending. One new leaf x 13 locales instead.

Belt-and-braces on the trigger that found this:
`sum(len(c.get("profiles") or []) ...)` raises TypeError on a hand-edited or
truncated backup whose `profiles` is not a list, and it runs before the guards.
Counting defensively makes that a skipped category rather than an exception
thrown over committed rows.

Control kept explicit: a failure *before* the commit still rolls back, still
reports nothing restored, and still does not touch the relay.

Tests: +5 (280 -> 285 across the three restore files, 328 -> 337 across
`-k github`). Fail-pre-fix 4 — 3 for the containment, 1 for the defensive
count, checked separately. i18n parity 13 locales at 5771 leaves.

Bundle rebuilt for the new leaf: index-CHCEEMgx.js -> index-DhOfNgMz.js. CSS
hash unchanged.
jmoore-skild 1 miesiąc temu
rodzic
commit
3bb087db54

+ 44 - 2
backend/app/services/github_restore.py

@@ -858,7 +858,32 @@ class GitHubRestoreService:
 
             self._progress = "Sending K-profiles to printers..."
             tally = _CategoryTally()
-            await self._restore_kprofiles(db, payload, tally)
+            try:
+                await self._restore_kprofiles(db, payload, tally)
+            except Exception as e:
+                # Everything above is committed and cannot be un-committed, so
+                # letting this reach run_restore's handler would report
+                # "nothing was restored" over durable archive, spool and
+                # settings rows — and skip the post-commit MQTT reconfigure,
+                # leaving the relay on the pre-restore broker. The K-profile
+                # phase is the last thing that runs, so containing it here is
+                # what keeps the result honest about what actually landed.
+                logger.exception("The K-profile step failed after the database categories were committed")
+                # Discards the phase's own read transaction. The rows above went
+                # in at the commit two statements up; this only stops a session
+                # left in a failed state by a database error from turning the
+                # caller's commit into that same false report.
+                await db.rollback()
+                outstanding = self._kprofile_profile_count(
+                    content for path, content in payload.items() if _KPROFILE_PATH_RE.match(path)
+                )
+                outstanding -= tally.restored + tally.skipped + tally.failed
+                tally.failed += max(outstanding, 0)
+                tally.note(
+                    "kprofilesStepFailed",
+                    f"The K-profile step could not be completed: {e}",
+                    reason=str(e)[:200],
+                )
             results[RestoreCategory.KPROFILES.value] = tally
 
         return results
@@ -1482,7 +1507,7 @@ class GitHubRestoreService:
         )
 
         for serial, entries in sorted(by_serial.items()):
-            profile_total = sum(len(c.get("profiles") or []) for _, c in entries)
+            profile_total = self._kprofile_profile_count(c for _, c in entries)
 
             printer = printers.get(serial)
             if printer is None:
@@ -1604,6 +1629,23 @@ class GitHubRestoreService:
                         reason=detail,
                     )
 
+    @staticmethod
+    def _kprofile_profile_count(contents) -> int:
+        """Count the profiles across parsed K-profile files.
+
+        Defensive on purpose. A hand-edited or truncated backup can carry a
+        ``profiles`` value that is not a list, and this count runs *before* the
+        per-call guards in the loop below — after ``_apply`` has already
+        committed the database categories. A malformed file has to be a skipped
+        category, not an exception thrown over committed rows.
+        """
+        total = 0
+        for content in contents:
+            profiles = content.get("profiles") if isinstance(content, dict) else None
+            if isinstance(profiles, list):
+                total += len(profiles)
+        return total
+
     @staticmethod
     async def _kprofile_ack(client, seq: str, serial: str, nozzle: str) -> tuple[bool, str]:
         """Read the printer's verdict on one batch write.

+ 134 - 0
backend/tests/unit/test_github_restore.py

@@ -2322,6 +2322,140 @@ class TestApplyOrdering:
         assert db.commit.await_count == 0
 
 
+class TestKprofilePhaseFailure:
+    """The K-profile phase runs after _apply has committed everything else.
+
+    So an exception there used to reach run_restore's handler, which reports
+    ``success: False`` with an empty ``results`` — over archive, spool and
+    settings rows that are durable on disk. The honest-reporting theme of this
+    feature inverted on exactly the path where it matters, and the post-commit
+    MQTT reconfigure (downstream of the raise, inside the same try) was skipped,
+    leaving the relay pointed at the pre-restore broker.
+    """
+
+    _SETTINGS = {"version": "1.0", "settings": {"mqtt_broker": "restored.local", "currency": "EUR"}}
+
+    def _payload(self, profiles=None):
+        return {
+            SETTINGS_PATH: dict(self._SETTINGS),
+            "kprofiles/00M09A123456789/0.4.json": {
+                "profiles": [{"filament_id": "GFA00", "name": "Bambu PLA"}] if profiles is None else profiles
+            },
+        }
+
+    def _session_patch(self, db_session):
+        cm = AsyncMock()
+        cm.__aenter__ = AsyncMock(return_value=db_session)
+        cm.__aexit__ = AsyncMock(return_value=None)
+        return patch("backend.app.services.github_restore.async_session", return_value=cm)
+
+    async def _configured_service(self, db_session, payload):
+        from backend.app.models.github_backup import GitHubBackupConfig
+
+        config = GitHubBackupConfig(repository_url="https://github.com/o/r", access_token="tok", provider="github")
+        db_session.add(config)
+        await db_session.commit()
+
+        service = _service()
+        service._resolve_ref = AsyncMock(return_value=("a" * 40, "", None))
+        service._read_categories = AsyncMock(return_value=(payload, ""))
+        return service, config.id
+
+    @pytest.mark.asyncio
+    async def test_the_committed_categories_are_still_reported(self, db_session):
+        service = _service()
+        service._restore_kprofiles = AsyncMock(side_effect=RuntimeError("mqtt exploded"))
+
+        results = await service._apply(
+            db_session,
+            self._payload(),
+            [RestoreCategory.SETTINGS, RestoreCategory.KPROFILES],
+            False,
+        )
+
+        assert results[RestoreCategory.SETTINGS.value].restored == 2
+        rows = {s.key: s.value for s in (await db_session.execute(select(Settings))).scalars().all()}
+        assert rows == {"mqtt_broker": "restored.local", "currency": "EUR"}, "committed before the phase that failed"
+
+    @pytest.mark.asyncio
+    async def test_the_failure_is_counted_and_explained(self, db_session):
+        service = _service()
+        service._restore_kprofiles = AsyncMock(side_effect=RuntimeError("mqtt exploded"))
+
+        results = await service._apply(
+            db_session,
+            self._payload(profiles=[{"filament_id": "GFA00"}, {"filament_id": "GFB99"}]),
+            [RestoreCategory.SETTINGS, RestoreCategory.KPROFILES],
+            False,
+        )
+
+        tally = results[RestoreCategory.KPROFILES.value]
+        assert tally.failed == 2, "every profile the payload carried is unaccounted for"
+        assert tally.restored == 0
+        assert _codes(tally) == ["kprofilesStepFailed"]
+        assert tally.notes[0]["params"]["reason"] == "mqtt exploded"
+
+    @pytest.mark.asyncio
+    async def test_the_relay_is_reconfigured_even_though_the_phase_failed(self, db_session):
+        """The reconfigure sits downstream of the raise in run_restore's try."""
+        service, config_id = await self._configured_service(db_session, self._payload())
+        service._restore_kprofiles = AsyncMock(side_effect=RuntimeError("mqtt exploded"))
+        relay = MagicMock()
+        relay.configure = AsyncMock(return_value=True)
+
+        with self._session_patch(db_session), patch("backend.app.services.mqtt_relay.mqtt_relay", relay):
+            result = await service.run_restore(
+                config_id, "a" * 40, [RestoreCategory.SETTINGS, RestoreCategory.KPROFILES]
+            )
+
+        assert result["success"] is True
+        assert result["results"][RestoreCategory.SETTINGS.value]["restored"] == 2
+        assert result["results"][RestoreCategory.KPROFILES.value]["failed"] == 1
+        relay.configure.assert_awaited_once()
+        assert relay.configure.await_args.args[0]["mqtt_broker"] == "restored.local"
+
+    @pytest.mark.asyncio
+    async def test_a_failure_before_the_commit_still_reports_nothing_restored(self, db_session):
+        """Control: rolling back and saying so is right when nothing landed."""
+        service, config_id = await self._configured_service(db_session, self._payload())
+        service._restore_settings = AsyncMock(side_effect=RuntimeError("read failed"))
+        relay = MagicMock()
+        relay.configure = AsyncMock(return_value=True)
+
+        with self._session_patch(db_session), patch("backend.app.services.mqtt_relay.mqtt_relay", relay):
+            result = await service.run_restore(
+                config_id, "a" * 40, [RestoreCategory.SETTINGS, RestoreCategory.KPROFILES]
+            )
+
+        assert result["success"] is False
+        assert result["results"] == {}
+        assert (await db_session.execute(select(Settings))).scalars().first() is None
+        relay.configure.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_a_malformed_profiles_value_is_a_skipped_category_not_a_raise(self, db_session, printer_factory):
+        """Belt-and-braces: the pre-loop count ran ahead of the per-call guards.
+
+        ``sum(len(c.get("profiles") or []) ...)`` raises TypeError on a
+        hand-edited or truncated backup whose ``profiles`` is not a list — and it
+        raises after the database categories are already on disk.
+        """
+        await printer_factory(serial_number="00M09A123456789")
+        client = MagicMock()
+        client.state.connected = True
+        client.set_kprofiles_batch = MagicMock(return_value="7")
+        client.get_kprofiles = AsyncMock(return_value=[])
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(profiles=5), tally)
+
+        client.set_kprofiles_batch.assert_not_called()
+        assert (tally.restored, tally.failed) == (0, 0)
+        assert "kprofilesStepFailed" not in _codes(tally)
+
+
 class TestResolveRef:
     @pytest.mark.asyncio
     async def test_concrete_sha_passes_through_without_an_api_call(self):

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -4929,6 +4929,7 @@ export default {
         kprofilesUnmatched: '{{count}} Profile für {{nozzle}} hatten kein Gegenstück auf {{printer}} - als neue Profile hinzugefügt',
         kprofilesSendFailed: '{{nozzle}}-Profile konnten nicht an {{printer}} ({{serial}}) gesendet werden',
         kprofilesRefused: '{{printer}} ({{serial}}) hat die {{nozzle}}-Profile abgelehnt: {{reason}}',
+        kprofilesStepFailed: 'Der K-Profil-Schritt konnte nicht abgeschlossen werden - {{reason}}. Was zuvor wiederhergestellt wurde, ist trotzdem gespeichert.',
       },
     },
 

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -4977,6 +4977,7 @@ export default {
         kprofilesUnmatched: '{{count}} profile(s) for {{nozzle}} had no counterpart on {{printer}} - added as new profiles',
         kprofilesSendFailed: 'Failed to send {{nozzle}} profiles to {{printer}} ({{serial}})',
         kprofilesRefused: '{{printer}} ({{serial}}) refused the {{nozzle}} profiles: {{reason}}',
+        kprofilesStepFailed: 'The K-profile step could not be completed - {{reason}}. Anything restored before it is still saved.',
       },
     },
 

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -4937,6 +4937,7 @@ export default {
         kprofilesUnmatched: '{{count}} perfiles para {{nozzle}} no tenían equivalente en {{printer}} - añadidos como perfiles nuevos',
         kprofilesSendFailed: 'No se pudieron enviar los perfiles de {{nozzle}} a {{printer}} ({{serial}})',
         kprofilesRefused: '{{printer}} ({{serial}}) rechazó los perfiles de {{nozzle}}: {{reason}}',
+        kprofilesStepFailed: 'No se pudo completar el paso de los perfiles K - {{reason}}. Lo que se restauró antes sigue guardado.',
       },
     },
 

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -4918,6 +4918,7 @@ export default {
         kprofilesUnmatched: '{{count}} profils pour {{nozzle}} n\'avaient pas d\'équivalent sur {{printer}} - ajoutés comme nouveaux profils',
         kprofilesSendFailed: 'Impossible d\'envoyer les profils {{nozzle}} à {{printer}} ({{serial}})',
         kprofilesRefused: '{{printer}} ({{serial}}) a refusé les profils {{nozzle}} : {{reason}}',
+        kprofilesStepFailed: 'L\'étape des profils K n\'a pas pu être terminée - {{reason}}. Ce qui a été restauré auparavant reste enregistré.',
       },
     },
 

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -4917,6 +4917,7 @@ export default {
         kprofilesUnmatched: '{{count}} profili per {{nozzle}} non avevano corrispondenza su {{printer}} - aggiunti come nuovi profili',
         kprofilesSendFailed: 'Impossibile inviare i profili {{nozzle}} a {{printer}} ({{serial}})',
         kprofilesRefused: '{{printer}} ({{serial}}) ha rifiutato i profili {{nozzle}}: {{reason}}',
+        kprofilesStepFailed: 'Non è stato possibile completare il passaggio dei profili K - {{reason}}. Quanto ripristinato prima resta salvato.',
       },
     },
 

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -4929,6 +4929,7 @@ export default {
         kprofilesUnmatched: '{{nozzle}} 用のプロファイル {{count}} 件は {{printer}} に該当がありませんでした - 新規プロファイルとして追加しました',
         kprofilesSendFailed: '{{nozzle}} のプロファイルを {{printer}} ({{serial}}) に送信できませんでした',
         kprofilesRefused: '{{printer}} ({{serial}}) が {{nozzle}} のプロファイルを拒否しました: {{reason}}',
+        kprofilesStepFailed: 'K プロファイルの処理を完了できませんでした - {{reason}}。それまでに復元された内容は保存されています。',
       },
     },
 

+ 1 - 0
frontend/src/i18n/locales/ko.ts

@@ -4694,6 +4694,7 @@ export default {
         kprofilesUnmatched: '{{nozzle}}용 프로파일 {{count}}개가 {{printer}}에 대응 항목이 없습니다 - 새 프로파일로 추가했습니다',
         kprofilesSendFailed: '{{nozzle}} 프로파일을 {{printer}}({{serial}})에 보내지 못했습니다',
         kprofilesRefused: '{{printer}}({{serial}})이(가) {{nozzle}} 프로파일을 거부했습니다: {{reason}}',
+        kprofilesStepFailed: 'K 프로파일 단계를 완료하지 못했습니다 - {{reason}}. 그 전에 복원된 항목은 그대로 저장되어 있습니다.',
       },
     },
     history: '기록',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -4917,6 +4917,7 @@ export default {
         kprofilesUnmatched: '{{count}} perfis para {{nozzle}} não tinham correspondente em {{printer}} - adicionados como novos perfis',
         kprofilesSendFailed: 'Não foi possível enviar os perfis de {{nozzle}} para {{printer}} ({{serial}})',
         kprofilesRefused: '{{printer}} ({{serial}}) recusou os perfis de {{nozzle}}: {{reason}}',
+        kprofilesStepFailed: 'Não foi possível concluir a etapa dos perfis K - {{reason}}. O que foi restaurado antes continua salvo.',
       },
     },
 

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -4686,6 +4686,7 @@ export default {
         kprofilesUnmatched: 'Профилей для {{nozzle}} без соответствия на {{printer}}: {{count}} - добавлены как новые профили',
         kprofilesSendFailed: 'Не удалось отправить профили {{nozzle}} на {{printer}} ({{serial}})',
         kprofilesRefused: '{{printer}} ({{serial}}) отклонил профили {{nozzle}}: {{reason}}',
+        kprofilesStepFailed: 'Не удалось завершить этап K-профилей - {{reason}}. Всё, что было восстановлено до него, сохранено.',
       },
     },
     history: "История",

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -4907,6 +4907,7 @@ export default {
         kprofilesUnmatched: '{{nozzle}} için {{count}} profilin {{printer}} üzerinde karşılığı yoktu - yeni profil olarak eklendi',
         kprofilesSendFailed: '{{nozzle}} profilleri {{printer}} ({{serial}}) yazıcısına gönderilemedi',
         kprofilesRefused: '{{printer}} ({{serial}}) {{nozzle}} profillerini reddetti: {{reason}}',
+        kprofilesStepFailed: 'K profili adımı tamamlanamadı - {{reason}}. Bundan önce geri yüklenenler yine de kaydedildi.',
       },
     },
 

+ 1 - 0
frontend/src/i18n/locales/uk.ts

@@ -4972,6 +4972,7 @@ export default {
         kprofilesUnmatched: "Профілів для {{nozzle}} без відповідника на {{printer}}: {{count}} - додано як нові профілі",
         kprofilesSendFailed: "Не вдалося надіслати профілі {{nozzle}} на {{printer}} ({{serial}})",
         kprofilesRefused: "{{printer}} ({{serial}}) відхилив профілі {{nozzle}}: {{reason}}",
+        kprofilesStepFailed: "Не вдалося завершити етап K-профілів - {{reason}}. Усе, що було відновлено до нього, збережено.",
       },
     },
 

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -4917,6 +4917,7 @@ export default {
         kprofilesUnmatched: '{{nozzle}} 的 {{count}} 个配置在 {{printer}} 上没有对应项 - 已作为新配置添加',
         kprofilesSendFailed: '无法将 {{nozzle}} 的配置发送到 {{printer}}({{serial}})',
         kprofilesRefused: '{{printer}}({{serial}})拒绝了 {{nozzle}} 的配置:{{reason}}',
+        kprofilesStepFailed: 'K 值配置步骤未能完成 - {{reason}}。在此之前恢复的内容仍已保存。',
       },
     },
 

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -4917,6 +4917,7 @@ export default {
         kprofilesUnmatched: '{{nozzle}} 的 {{count}} 個設定檔在 {{printer}} 上沒有對應項 - 已新增為新設定檔',
         kprofilesSendFailed: '無法將 {{nozzle}} 的設定檔傳送到 {{printer}}({{serial}})',
         kprofilesRefused: '{{printer}}({{serial}})拒絕了 {{nozzle}} 的設定檔:{{reason}}',
+        kprofilesStepFailed: 'K 值設定檔步驟未能完成 - {{reason}}。在此之前還原的內容仍已儲存。',
       },
     },
 

Plik diff jest za duży
+ 0 - 1
static/assets/index-DhOfNgMz.js


+ 1 - 1
static/index.html

@@ -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-CHCEEMgx.js"></script>
+    <script type="module" crossorigin src="/assets/index-DhOfNgMz.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-C_6BSgrK.css">
   </head>
   <body>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików