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.
This commit is contained in:
jmoore-skild
2026-08-04 08:57:34 -04:00
parent 0be6ccd090
commit 3bb087db54
17 changed files with 205 additions and 77 deletions
+44 -2
View File
@@ -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.