fix(maintenance): never remove the kept copy; move records before unlink

Final review of #313:

- C1: the duplicate find tells files apart by (st_dev, st_ino), so a
  source listed twice, a symlinked source inside another and a hardlink
  are one file. The preview and the job refuse a removal that
  os.path.samestat matches with its kept copy.
- I1: each file's records move to its verified kept copy in one short
  transaction before that file is unlinked; a failed move deletes
  nothing and a failed unlink leaves the records on the identical kept
  copy. The status line counts files removed and not removed.
- I2: links are looked up under every spelling of the source (the real
  path, the configured and monitored folders, ComfyUI's output).
- I5: image tags join the kept copy as their union and the removed
  copy's pending, undecided Needs review items are settled; the plan
  says so.
- I3: the VACUUM disk check measures SQLite's real temporary folder.
- M1: the token is looked at again with no await before the job starts.
- M2: the backup decision is made once and handed to the job.
- M3: backups are linked into place; startup sweeps interrupted temps.
- M4: Optimize runs alone (new "exclusive" admission) and its plan says
  the database is locked while VACUUM runs.
- M5: the reviewed duplicate merge is strict: collisions and Library
  titles are protected, and errors roll back. The legacy cleanup is
  unchanged.
- M7: 2,000 keep entries within a 1 MiB body. M8: event waits in tests.
- M9: the preview binds files by size, mtime and inode without hashing;
  the job hashes right before each unlink.
This commit is contained in:
Vito Sansevero
2026-10-23 19:11:01 -07:00
parent 237a97d7cb
commit 58ededde6d
15 changed files with 1422 additions and 340 deletions
+221 -2
View File
@@ -588,11 +588,22 @@ class TestSafetyBackup(ApplyTestCase):
async def test_a_failed_replace_leaves_no_file(self):
from py.api import maintenance
with patch.object(maintenance.os, "replace", side_effect=OSError("busy")):
# A filesystem without hard links falls back to reserve-and-replace.
with (
patch.object(maintenance.os, "link", side_effect=OSError("no hard links")),
patch.object(maintenance.os, "replace", side_effect=OSError("busy")),
):
with self.assertRaises(maintenance.SafetyBackupFailed):
maintenance.take_safety_backup(self.api.db.model)
self.assertEqual(self.backups(), [])
self.assertEqual(self.strays(), [])
with patch.object(maintenance.os, "link", side_effect=FileExistsError):
with self.assertRaises(maintenance.SafetyBackupFailed):
maintenance.take_safety_backup(self.api.db.model)
with patch.object(maintenance.os, "link", side_effect=OSError("no hard links")):
name = maintenance.take_safety_backup(self.api.db.model)
self.assertEqual(self.backups(), [name])
self.assertEqual(self.strays(), [])
async def test_a_backup_that_cannot_be_finished_leaves_no_file(self):
from py.api import maintenance
@@ -689,7 +700,11 @@ class TestBackupPoolAndDiskSpace(ApplyTestCase):
from py.api import maintenance
self.save("Orphan")
with patch.object(maintenance, "_free_bytes", return_value=10 * 2**30):
# SQLite's temporary folder on the database's disk: the needs add up.
with (
patch.object(maintenance, "_free_bytes", return_value=10 * 2**30),
patch.object(maintenance, "sqlite_temp_dir", return_value=self.tmpdir),
):
plan = await self.preview("unused_components")
optimize = await self.preview("optimize")
size = self.database_bytes()
@@ -846,3 +861,207 @@ if __name__ == "__main__": # pragma: no cover
import unittest
unittest.main()
class TestFinalReviewFixes(ApplyTestCase):
"""PR #313 final review: I3, M1-M5."""
def duplicate_of(self, kept_text, copy_text):
kept = self.save(kept_text)
with self.api.db.model.get_connection() as conn:
copy = conn.execute(
"INSERT INTO prompts(text, created_at) VALUES(?, '2999-01-01')",
(copy_text,),
).lastrowid
return kept, copy
def test_sqlite_temp_dir_follows_sqlite_search_order(self):
from py.api.maintenance import sqlite_temp_dir
def usable(*good):
return lambda path: path in good
posix = {"windows": False}
self.assertEqual(
sqlite_temp_dir({}, usable=usable("/var/tmp", "/tmp"), **posix), "/var/tmp"
)
self.assertEqual(
sqlite_temp_dir({}, usable=usable("/usr/tmp", "/tmp"), **posix), "/usr/tmp"
)
env = {"SQLITE_TMPDIR": "/fast", "TMPDIR": "/t"}
self.assertEqual(
sqlite_temp_dir(env, usable=usable("/fast", "/t", "/tmp"), **posix),
"/fast",
)
self.assertEqual(
sqlite_temp_dir(env, usable=usable("/t", "/tmp"), **posix), "/t"
)
self.assertEqual(
sqlite_temp_dir({}, usable=usable(), **posix), os.path.abspath(".")
)
windows = {"windows": True, "usable": usable()}
env = {"TMP": "C:\\a", "TEMP": "C:\\b", "USERPROFILE": "C:\\u"}
self.assertEqual(sqlite_temp_dir(env, **windows), "C:\\a")
self.assertEqual(sqlite_temp_dir({"TEMP": "C:\\b"}, **windows), "C:\\b")
self.assertEqual(sqlite_temp_dir({"USERPROFILE": "C:\\u"}, **windows), "C:\\u")
async def test_optimize_measures_sqlites_temp_folder(self):
from py.api import maintenance
measured = []
def free(path):
measured.append(path)
return 10 * 2**30
other = os.path.join(self.tmpdir, "sqlite-temp")
os.mkdir(other)
with (
patch.object(maintenance, "sqlite_temp_dir", return_value=other),
patch.object(maintenance, "_free_bytes", side_effect=free),
patch.object(
maintenance, "_device", side_effect=lambda path: path # another disk
),
):
plan = await self.preview("optimize")
self.assertIn(other, measured)
size = self.database_bytes()
self.assertEqual(
plan["disk"]["required_bytes"], 2 * size + maintenance.DISK_MARGIN_BYTES
)
async def test_a_plan_used_during_the_disk_check_is_plan_used(self):
from py.api import maintenance
self.save("Orphan")
plan = await self.preview("unused_components")
real = maintenance.disk_plan
def racing(*args, **kwargs):
# A parallel apply consumed the token while this one measured.
self.api._maintenance_plans.consume(
plan["token"], "unused_components", job_id="f" * 32
)
return real(*args, **kwargs)
with patch.object(maintenance, "disk_plan", side_effect=racing):
reply = await self.apply(plan["token"], 409)
self.assertEqual(reply["code"], "plan-used")
self.assertEqual(self.api._jobs.list(), [])
async def test_the_backup_decision_is_frozen_when_the_job_starts(self):
PromptManagerConfig.SAFETY_BACKUP = False
self.save("Orphan")
plan = await self.preview("unused_components")
real = self.api._jobs.start
def start(*args, **kwargs):
job = real(*args, **kwargs)
PromptManagerConfig.SAFETY_BACKUP = True # switched on meanwhile
return job
with patch.object(self.api._jobs, "start", side_effect=start):
body = await self.apply(plan["token"])
job = self.api._jobs.get(body["job"]["id"])
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
self.assertEqual(self.backups(), [])
self.assertTrue(job.status_line.endswith("No safety backup"))
async def test_startup_sweeps_interrupted_backup_copies(self):
stray = os.path.join(self.tmpdir, ".pm-safety-crashed.tmp")
with open(stray, "wb") as handle:
handle.write(b"half a backup")
PromptManagerAPI(db=self.api.db)
self.assertFalse(os.path.exists(stray))
async def test_a_backup_is_linked_into_place_without_a_reserved_name(self):
from py.api import maintenance
linked = []
real = os.link
def link(source, target):
linked.append(target)
return real(source, target)
with (
patch.object(maintenance.os, "link", side_effect=link),
patch.object(
maintenance, "_reserve_backup_name", side_effect=AssertionError
),
):
name = maintenance.take_safety_backup(self.api.db.model)
self.assertEqual([os.path.basename(p) for p in linked], [name])
self.assertEqual(self.strays(), [])
async def test_optimize_runs_alone_and_says_the_database_is_locked(self):
from py.api.jobs import JobBusy
plan = await self.preview("optimize")
self.assertIn(
"Runs alone: the database is locked while it runs",
" ".join(row["detail"] for row in plan["details"]),
)
gate = asyncio.Event()
async def held(job):
await gate.wait()
read = self.api._jobs.start(
"health", "Health check", admission="read", runner=held
)
busy = await self.apply(plan["token"], 409)
self.assertEqual(busy["running"]["id"], read.id)
gate.set()
await asyncio.wait_for(asyncio.shield(read.task), WAIT)
gate.clear()
body = await self.apply(plan["token"])
self.assertEqual(body["job"]["admission"], "exclusive")
with self.assertRaises(JobBusy):
self.api._jobs.start(
"health", "Health check", admission="read", runner=held
)
job = self.api._jobs.get(body["job"]["id"])
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
async def test_a_link_collision_is_protected_not_swallowed(self):
kept, copy = self.duplicate_of("A cat", "A CAT")
path = os.path.join(str(self.output_dir), "both.png")
self.api.db.link_image_to_prompt(kept, path)
self.api.db.link_image_to_prompt(copy, path)
plan, _summary, job = await self.run_apply("duplicate_prompts")
self.assertEqual(plan["scope"]["count"], 0)
self.assertEqual(
plan["protected"]["reasons"], ["1 group links one image twice"]
)
self.assertIsNotNone(self.api.db.get_prompt_by_id(copy))
self.assertEqual(len(self.api.db.get_prompt_images(copy)), 1)
async def test_a_duplicate_with_a_library_title_is_protected(self):
kept, copy = self.duplicate_of("A dog", "A DOG")
with self.api.db.model.get_connection() as conn:
conn.execute(
"INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)",
(copy, "My dog recipe"),
)
plan, _summary, _job = await self.run_apply("duplicate_prompts")
self.assertEqual(plan["scope"]["count"], 0)
self.assertEqual(
plan["protected"]["reasons"], ["1 group has its own Library title"]
)
self.assertIsNotNone(self.api.db.get_prompt_by_id(copy))
def test_the_strict_merge_raises_instead_of_dropping_a_link(self):
from database.maintenance import MergeConflict
kept, copy = self.duplicate_of("A bird", "A BIRD")
path = os.path.join(str(self.output_dir), "bird.png")
self.api.db.link_image_to_prompt(kept, path)
self.api.db.link_image_to_prompt(copy, path)
with self.assertRaises(MergeConflict):
with self.api.db._pair_transaction() as conn:
self.api.db._merge_duplicate_group(conn, [kept, copy], strict=True)
self.assertEqual(len(self.api.db.get_prompt_images(copy)), 1)
# The legacy cleanup keeps its old behaviour (it logs and goes on).
with self.assertLogs("prompt_manager.database", "ERROR"):
self.assertEqual(self.api.db.cleanup_duplicates(), 1)