fix(backup): release the SQLite writer before the K-profile MQTT phase (#2656)

_apply ran archives and spools first, which autoflushes their INSERTs and so
opens SQLite's single write transaction, then called _restore_kprofiles —
which awaits get_kprofiles per printer per nozzle at timeout=5.0 with
max_retries=3, i.e. up to ~15 s each against a printer that ignores the
request. The commit only came afterwards, in run_restore. busy_timeout is 15 s
(core/database.py:21), so a restore covering a couple of unresponsive printers
held the writer past it and unrelated writes elsewhere in the app failed with
"database is locked".

Commit the database categories before the MQTT phase starts. The K-profile
work is not in that transaction anyway — it leaves over MQTT — so the only
thing lost is rolling those categories back when a K-profile send fails, and
that rollback was never the right behaviour: extrusion_cali_set has already
reached the printer by then, so undoing the database half would just make the
two disagree.
This commit is contained in:
jmoore-skild
2026-08-04 08:22:58 -04:00
parent 737258202b
commit 3ba89c60a1
2 changed files with 66 additions and 0 deletions
+45
View File
@@ -1090,6 +1090,51 @@ class TestMutex:
assert "restore is currently running" in result["message"]
class TestApplyOrdering:
"""_apply must not hold SQLite's write transaction across the MQTT phase."""
def _recording_service(self, calls: list[str]):
service = _service()
# Sync side effects on purpose: an AsyncMock returns a coroutine its
# side_effect hands back rather than awaiting it, so an async recorder
# would never run.
service._restore_archives = AsyncMock(side_effect=lambda *a, **k: calls.append("archives"))
service._restore_spools = AsyncMock(side_effect=lambda *a, **k: calls.append("spools"))
service._restore_settings = AsyncMock(side_effect=lambda *a, **k: calls.append("settings"))
service._restore_kprofiles = AsyncMock(side_effect=lambda *a, **k: calls.append("kprofiles"))
return service
@pytest.mark.asyncio
async def test_commits_database_categories_before_talking_to_printers(self):
"""get_kprofiles is 3 x 5 s per printer/nozzle; busy_timeout is 15 s."""
calls: list[str] = []
service = self._recording_service(calls)
db = MagicMock()
db.commit = AsyncMock(side_effect=lambda: calls.append("commit"))
await service._apply(
db,
{},
[RestoreCategory.ARCHIVES, RestoreCategory.SPOOLS, RestoreCategory.KPROFILES],
False,
)
assert calls == ["archives", "spools", "commit", "kprofiles"]
@pytest.mark.asyncio
async def test_does_not_split_the_transaction_without_kprofiles(self):
"""A database-only restore stays one transaction, committed by run_restore."""
calls: list[str] = []
service = self._recording_service(calls)
db = MagicMock()
db.commit = AsyncMock(side_effect=lambda: calls.append("commit"))
await service._apply(db, {}, [RestoreCategory.ARCHIVES, RestoreCategory.SETTINGS], False)
assert calls == ["archives", "settings"]
assert db.commit.await_count == 0
class TestResolveRef:
@pytest.mark.asyncio
async def test_concrete_sha_passes_through_without_an_api_call(self):