From 56ef28cd719d74437d729b4d07dcf205e830f736 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sun, 25 Oct 2026 06:32:15 -0700 Subject: [PATCH] fix(backup): drop stale restore previews off the event loop A restore drops the previews reviewed against the old library; their stage files are now removed in the executor, with the old database's connections. --- py/api/backup_restore.py | 22 ++++++++++++---------- tests/test_restore_plans.py | 9 +++++++++ 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/py/api/backup_restore.py b/py/api/backup_restore.py index 5b177e10..bf35a3d2 100644 --- a/py/api/backup_restore.py +++ b/py/api/backup_restore.py @@ -405,13 +405,18 @@ class BackupRestoreRoutesMixin: await self._prune_after(model.db_path, failed=True) raise - def _swap_database(self, db_path): - """Open the restored library as ``self.db``; the previous one to close.""" + async def _switch_to_restored(self, db_path): + """Open the restored library as ``self.db`` and close the previous one. + + Its connections would otherwise linger (a file lock on Windows). The + health report and every held restore preview described the old + library, so they are dropped (the stage files off the event loop). + """ previous = self.db self.db = PromptDatabase(db_path) - self._database_replaced() # a health report described the old one - self._restore_plans.discard_all() # stages reviewed against the old one - return previous + self._database_replaced() + await self._run_in_executor(self._restore_plans.discard_all) + await self._run_in_executor(previous.close_all) # ── legacy one-step restore ─────────────────────────────────────── @@ -457,9 +462,7 @@ class BackupRestoreRoutesMixin: finally: await self._run_in_executor(staged.discard) await self._prune_after(model.db_path) - previous = self._swap_database(model.db_path) - # Its connections would otherwise linger (a file lock on Windows) - await self._run_in_executor(previous.close_all) + await self._switch_to_restored(model.db_path) prompt_count = self.db.model.get_database_info().get("total_prompts", 0) message = f"Database restored successfully. Found {prompt_count} prompts." body = { @@ -695,8 +698,7 @@ class BackupRestoreRoutesMixin: self.logger.error(f"Restore failed: {error}") return _fail_job(job, "restore-failed", NOT_RESTORED + kept) await self._prune_after(model.db_path) - previous = self._swap_database(model.db_path) - await self._run_in_executor(previous.close_all) + await self._switch_to_restored(model.db_path) if backup_path: job.log_line(f"Safety backup {os.path.basename(backup_path)}") else: diff --git a/tests/test_restore_plans.py b/tests/test_restore_plans.py index 73415d7a..de6079a2 100644 --- a/tests/test_restore_plans.py +++ b/tests/test_restore_plans.py @@ -295,6 +295,15 @@ class TestApply(RestoreTestCase): self.assertEqual([p["text"] for p in self.api.db.search_prompts()], ["live"]) self.assertEqual(self.stages(), []) + async def test_a_restore_drops_previews_of_the_old_library(self): + first = await self.preview(self.upload(["first"])) + other = await self.preview(self.upload(["other"], "other.db")) + job = await self.finish((await self.apply(first["token"]))["job"]["id"]) + self.assertEqual(job.state, "finished", job.log) + self.assertEqual(self.stages(), []) + refused = await self.apply(other["token"], status=410) + self.assertEqual(refused["code"], "plan-expired") + async def test_jobs_survive_db_swap(self): earlier, gate = self.gated_job("health", "read", "Health check") gate.set()