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:
+205
-94
@@ -57,11 +57,12 @@ from .duplicate_review import ( # noqa: F401 (CHANGED, OUTSIDE_SOURCE: public)
|
||||
KEEPER,
|
||||
KEEPER_CHANGED,
|
||||
KEPT_COPY,
|
||||
LINKS_FAILED,
|
||||
LOCKED_FILE,
|
||||
NOT_ATTEMPTED,
|
||||
OUTSIDE_SOURCE,
|
||||
RECORDS_FAILED,
|
||||
ROW_DETAILS,
|
||||
SAME_FILE,
|
||||
SOURCES_CHANGED,
|
||||
SOURCES_CHANGED_REVIEW,
|
||||
HeldFind,
|
||||
@@ -88,9 +89,9 @@ from .jobs import (
|
||||
from .maintenance import DUPLICATE_FILES, _fail_job, _failure, _invalid, backup_note
|
||||
|
||||
try:
|
||||
from ...database.maintenance import FILE_LINK_SQL
|
||||
from ...database.duplicate_copies import FILE_LINK_SQL
|
||||
except ImportError:
|
||||
from database.maintenance import FILE_LINK_SQL
|
||||
from database.duplicate_copies import FILE_LINK_SQL
|
||||
|
||||
DUPLICATE_FIND_TITLE = "Find duplicate image files"
|
||||
DUPLICATE_CLEANUP_TITLE = "Remove duplicate image files"
|
||||
@@ -432,10 +433,14 @@ class DuplicateFilesMixin:
|
||||
)
|
||||
|
||||
def _review_duplicate_files(self, db, selection):
|
||||
"""Check every keeper and removal now (blocking); the plan's parts."""
|
||||
"""Check every keeper and removal now (blocking); the plan's parts.
|
||||
|
||||
No file is read: each copy is bound to what the search saw by its
|
||||
size, mtime and inode, and never to its kept copy itself. The removal
|
||||
job hashes each file again right before it unlinks it.
|
||||
"""
|
||||
listed = self._listed_sources()
|
||||
targets, kept, protected = [], [], {}
|
||||
hasher = self._calculate_file_hash
|
||||
for group, keepers in selection.values():
|
||||
removals = [r for r in group["files"] if r not in keepers]
|
||||
for record in removals:
|
||||
@@ -444,15 +449,7 @@ class DuplicateFilesMixin:
|
||||
):
|
||||
raise SourcesChanged(record["root"])
|
||||
keeper = next(
|
||||
(
|
||||
k
|
||||
for k in keepers
|
||||
if file_problem(
|
||||
k["abs"], k["root"], group["hash"], k["size"], hasher
|
||||
)
|
||||
is None
|
||||
),
|
||||
None,
|
||||
(k for k in keepers if _bound_problem(k, group) is None), None
|
||||
)
|
||||
reviewed = []
|
||||
for record in removals:
|
||||
@@ -461,13 +458,7 @@ class DuplicateFilesMixin:
|
||||
elif record["root_index"] is None:
|
||||
problem = OUTSIDE_SOURCE
|
||||
else:
|
||||
problem = file_problem(
|
||||
record["abs"],
|
||||
record["root"],
|
||||
group["hash"],
|
||||
record["size"],
|
||||
hasher,
|
||||
)
|
||||
problem = _bound_problem(record, group, keeper["abs"])
|
||||
if problem:
|
||||
protected[problem] = protected.get(problem, 0) + 1
|
||||
continue
|
||||
@@ -475,14 +466,55 @@ class DuplicateFilesMixin:
|
||||
if reviewed:
|
||||
targets.extend(reviewed)
|
||||
kept.append(self._copy_row(keeper, KEPT_COPY))
|
||||
links = db.count_file_links([t["abs"] for t in targets]) if targets else {}
|
||||
records = {}
|
||||
if targets:
|
||||
spellings = self._source_spellings()
|
||||
records = db.count_copy_records(
|
||||
{
|
||||
t["abs"]: {
|
||||
"spellings": _file_spellings(spellings, t["root"], t["rel"]),
|
||||
"locator": t["locator"],
|
||||
"sha256": t["hash"],
|
||||
}
|
||||
for t in targets
|
||||
}
|
||||
)
|
||||
return {
|
||||
"targets": targets,
|
||||
"kept": kept,
|
||||
"fingerprint": _fingerprint(targets),
|
||||
"public": _public_plan(targets, protected, links),
|
||||
"public": _public_plan(targets, protected, records),
|
||||
}
|
||||
|
||||
def _source_spellings(self):
|
||||
"""``{source identity: [spellings]}`` the monitor and config use (blocking).
|
||||
|
||||
The search walks each source's real path, while the image monitor
|
||||
links a file under the folder it watches: the configured spelling,
|
||||
ComfyUI's output folder or a symlink to it. Each absolute candidate
|
||||
is listed under the identity (``normcase(realpath)``) it resolves to.
|
||||
"""
|
||||
from ..config import GalleryConfig
|
||||
|
||||
candidates = list(self._monitored_directories())
|
||||
candidates += list(GalleryConfig.MONITORING_DIRECTORIES)
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
candidates.append(folder_paths.get_output_directory())
|
||||
except Exception: # nosec B110 - outside ComfyUI there is none
|
||||
pass
|
||||
candidates.append(self._find_comfyui_output_dir())
|
||||
spellings = {}
|
||||
for candidate in candidates:
|
||||
if not candidate or not os.path.isabs(str(candidate)):
|
||||
continue
|
||||
spelled = os.path.abspath(str(candidate))
|
||||
listed = spellings.setdefault(_identity(spelled), [])
|
||||
if spelled not in listed:
|
||||
listed.append(spelled)
|
||||
return spellings
|
||||
|
||||
def _target(self, group, record, keeper):
|
||||
return {
|
||||
"group_id": group["group_id"],
|
||||
@@ -497,6 +529,8 @@ class DuplicateFilesMixin:
|
||||
"keeper_rel": keeper["rel"],
|
||||
"keeper_root": keeper["root"],
|
||||
"keeper_size": keeper["size"],
|
||||
"locator": os.path.normcase(record["abs"]),
|
||||
"keeper_locator": os.path.normcase(keeper["abs"]),
|
||||
}
|
||||
|
||||
def _copy_row(self, record, detail):
|
||||
@@ -509,7 +543,7 @@ class DuplicateFilesMixin:
|
||||
|
||||
# ── removal ───────────────────────────────────────────────────────
|
||||
|
||||
def _start_duplicate_cleanup(self, db, plan):
|
||||
def _start_duplicate_cleanup(self, db, plan, backup_on):
|
||||
"""Register the removal job for a reviewed plan (on the loop)."""
|
||||
find = self._jobs.active(DUPLICATES_JOB_KIND)
|
||||
if find is not None:
|
||||
@@ -521,7 +555,7 @@ class DuplicateFilesMixin:
|
||||
cancellable=False,
|
||||
scope={"operation": DUPLICATE_FILES},
|
||||
groups=DUPLICATE_CLEANUP_GROUP_NAMES,
|
||||
runner=lambda job: self._run_duplicate_cleanup(job, db, plan),
|
||||
runner=lambda job: self._run_duplicate_cleanup(job, db, plan, backup_on),
|
||||
)
|
||||
|
||||
def _sources_unchanged(self, targets):
|
||||
@@ -530,12 +564,12 @@ class DuplicateFilesMixin:
|
||||
self._source_listed(listed, t["root_index"], t["root"]) for t in targets
|
||||
)
|
||||
|
||||
async def _run_duplicate_cleanup(self, job, db, plan):
|
||||
async def _run_duplicate_cleanup(self, job, db, plan, backup_on):
|
||||
targets = plan.payload["targets"]
|
||||
unchanged = await self._run_in_executor(self._sources_unchanged, targets)
|
||||
if not unchanged:
|
||||
return _fail_job(job, "sources-changed", SOURCES_CHANGED)
|
||||
ok, backup = await self._safety_backup_step(job, db, plan)
|
||||
ok, backup = await self._safety_backup_step(job, db, backup_on)
|
||||
if not ok:
|
||||
return
|
||||
try:
|
||||
@@ -549,36 +583,60 @@ class DuplicateFilesMixin:
|
||||
for row in plan.payload["kept"]:
|
||||
job.record("kept", row, f"= {row['root']}/{row['path']} · {KEPT_COPY}")
|
||||
job.update(total=len(targets), status_line="Removing duplicate files")
|
||||
removed, failure, keepers = [], None, {}
|
||||
spellings = await self._run_in_executor(self._source_spellings)
|
||||
removed, failure, keepers = 0, None, {}
|
||||
totals = dict.fromkeys(("moved", "merged", "favorites", "tags", "review"), 0)
|
||||
for done, target in enumerate(targets, start=1):
|
||||
if failure is not None:
|
||||
self._record_target(job, "kept", target, NOT_ATTEMPTED)
|
||||
continue
|
||||
outcome, code = await self._run_in_executor(
|
||||
self._remove_one_duplicate, target, keepers
|
||||
outcome, detail, counts = await self._run_in_executor(
|
||||
self._remove_one_duplicate, db, target, keepers, spellings
|
||||
)
|
||||
for key, value in (counts or {}).items():
|
||||
totals[key] += value
|
||||
if outcome == "removed":
|
||||
removed.append(target)
|
||||
detail = f"A copy of {target['keeper_rel']}"
|
||||
self._record_target(job, "removed", target, detail, "-")
|
||||
removed += 1
|
||||
text = f"A copy of {target['keeper_rel']}"
|
||||
self._record_target(job, "removed", target, text, "-")
|
||||
elif outcome == "kept":
|
||||
self._record_target(job, "kept", target, ROW_DETAILS[code])
|
||||
self._record_target(job, "kept", target, ROW_DETAILS[detail])
|
||||
else:
|
||||
failure = code
|
||||
self._record_target(job, "errors", target, code, "!")
|
||||
failure = detail
|
||||
self._record_target(job, "errors", target, detail, "!")
|
||||
job.update(processed=done, progress=int(done / len(targets) * 100))
|
||||
links_ok = await self._move_links(job, db, removed)
|
||||
if removed:
|
||||
self.invalidate_gallery_cache()
|
||||
line = f"Removed {_plural(len(removed), 'duplicate file')}"
|
||||
self._log_moved_records(job, totals)
|
||||
line = f"Removed {_plural(removed, 'duplicate file')}"
|
||||
if removed < len(targets):
|
||||
line += f", {len(targets) - removed:,} not removed"
|
||||
job.log_line(line)
|
||||
if failure is not None or not links_ok:
|
||||
line = "Stopped after an error · " + line if failure else line
|
||||
job.update(progress=100, status_line=f"{line} · {backup_note(backup)}")
|
||||
if failure is not None:
|
||||
line = f"Stopped after an error · {line}"
|
||||
job.update(progress=100, status_line=f"{line} · {backup_note(backup)}")
|
||||
if failure is not None:
|
||||
job.publish({"type": "error", "code": "delete-failed", "message": line})
|
||||
job.finish("failed")
|
||||
return
|
||||
job.update(progress=100, status_line=f"{line} · {backup_note(backup)}")
|
||||
|
||||
@staticmethod
|
||||
def _log_moved_records(job, totals):
|
||||
links = totals["moved"] + totals["merged"]
|
||||
if links:
|
||||
job.log_line(f"Moved {_plural(links, 'database link')} to the kept copies")
|
||||
if totals["favorites"]:
|
||||
job.log_line(
|
||||
f"Moved {_plural(totals['favorites'], 'favorite')} to the kept copies"
|
||||
)
|
||||
if totals["tags"]:
|
||||
job.log_line(
|
||||
f"Added {_plural(totals['tags'], 'image tag')} to the kept copies"
|
||||
)
|
||||
if totals["review"]:
|
||||
job.log_line(
|
||||
f"Settled {_plural(totals['review'], 'Needs review item')} "
|
||||
"of removed copies"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _record_target(job, group, target, detail, mark="="):
|
||||
@@ -592,73 +650,126 @@ class DuplicateFilesMixin:
|
||||
group, row, f"{mark} {target['root_label']}/{target['rel']} · {detail}"
|
||||
)
|
||||
|
||||
def _remove_one_duplicate(self, target, keepers):
|
||||
"""``(outcome, detail or code)`` for one reviewed removal (blocking).
|
||||
def _remove_one_duplicate(self, db, target, keepers, spellings):
|
||||
"""``(outcome, detail, counts)`` for one reviewed removal (blocking).
|
||||
|
||||
``keepers`` caches each group's kept-copy check for this job.
|
||||
The kept copy (once per group; cached in ``keepers``) and the file
|
||||
are checked again, hashes included, and the file must not be its kept
|
||||
copy. Then the file's records move to the kept copy in one short
|
||||
transaction, and only then is the file unlinked: a crash or a failed
|
||||
unlink leaves its records on the identical kept copy, never on a
|
||||
deleted file. A failed move leaves the file and its records alone.
|
||||
"""
|
||||
hasher = self._calculate_file_hash
|
||||
group_id = target["group_id"]
|
||||
if group_id not in keepers:
|
||||
keepers[group_id] = (
|
||||
file_problem(
|
||||
target["keeper_abs"],
|
||||
target["keeper_root"],
|
||||
target["hash"],
|
||||
target["keeper_size"],
|
||||
hasher,
|
||||
)
|
||||
is None
|
||||
)
|
||||
if not keepers[group_id]:
|
||||
return "kept", KEEPER
|
||||
keepers[group_id] = _kept_copy_state(target, hasher)
|
||||
if keepers[group_id] is None:
|
||||
return "kept", KEEPER, None
|
||||
problem = file_problem(
|
||||
target["abs"], target["root"], target["hash"], target["size"], hasher
|
||||
target["abs"],
|
||||
target["root"],
|
||||
target["hash"],
|
||||
target["size"],
|
||||
hasher,
|
||||
keeper=target["keeper_abs"],
|
||||
)
|
||||
if problem:
|
||||
return "kept", problem
|
||||
return "kept", problem, None
|
||||
try:
|
||||
counts = db.move_copy_records(
|
||||
self._copy_move(target, keepers[group_id], spellings)
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception(f"Could not move the records of {target['rel']}")
|
||||
return "error", RECORDS_FAILED, None
|
||||
try:
|
||||
_unlink(target["abs"])
|
||||
except FileNotFoundError:
|
||||
return "kept", GONE
|
||||
return "removed", None, counts
|
||||
except PermissionError:
|
||||
self.logger.warning(f"Duplicate file is locked: {target['rel']}")
|
||||
return "error", LOCKED_FILE
|
||||
self.logger.warning(
|
||||
f"Duplicate file is locked: {target['rel']}; "
|
||||
"its records now name the kept copy"
|
||||
)
|
||||
return "error", LOCKED_FILE, counts
|
||||
except OSError:
|
||||
self.logger.warning(
|
||||
f"Could not delete duplicate file {target['rel']}", exc_info=True
|
||||
f"Could not delete duplicate file {target['rel']}; "
|
||||
"its records now name the kept copy",
|
||||
exc_info=True,
|
||||
)
|
||||
return "error", DELETE_FAILED
|
||||
return "error", DELETE_FAILED, counts
|
||||
self.logger.info(f"Deleted duplicate file: {target['rel']}")
|
||||
_remove_thumbnail(Path(target["root"]), Path(target["rel"]), self.logger)
|
||||
return "removed", None
|
||||
return "removed", None, counts
|
||||
|
||||
async def _move_links(self, job, db, removed):
|
||||
"""Move the removed files' links to their kept copy; False on failure."""
|
||||
if not removed:
|
||||
return True
|
||||
moves = [(t["abs"], t["keeper_abs"]) for t in removed]
|
||||
favorites = [
|
||||
(
|
||||
_favorite_root_key(t["root"]),
|
||||
_favorite_relative_key(t["rel"]),
|
||||
_favorite_root_key(t["keeper_root"]),
|
||||
_favorite_relative_key(t["keeper_rel"]),
|
||||
@staticmethod
|
||||
def _copy_move(target, kept_stat, spellings):
|
||||
"""What ``move_copy_records`` moves for one removal."""
|
||||
removed = _file_spellings(spellings, target["root"], target["rel"])
|
||||
kept = _file_spellings(spellings, target["keeper_root"], target["keeper_rel"])
|
||||
same_source = _identity(target["root"]) == _identity(target["keeper_root"])
|
||||
native_rel = target["rel"].replace("/", os.sep)
|
||||
native_kept = target["keeper_rel"].replace("/", os.sep)
|
||||
targets = {}
|
||||
for spelled in removed:
|
||||
prefix = spelled[: len(spelled) - len(native_rel)]
|
||||
targets[spelled] = (
|
||||
prefix + native_kept if same_source else target["keeper_abs"]
|
||||
)
|
||||
for t in removed
|
||||
]
|
||||
try:
|
||||
counts = await self._run_in_executor(db.move_file_links, moves, favorites)
|
||||
except Exception:
|
||||
self.logger.exception("Moving duplicate file links failed")
|
||||
job.record(
|
||||
"errors", {"path": "", "detail": LINKS_FAILED}, f"! {LINKS_FAILED}"
|
||||
)
|
||||
return False
|
||||
links = counts["moved"] + counts["merged"]
|
||||
job.log_line(f"Moved {_plural(links, 'database link')} to the kept copies")
|
||||
if counts["favorites"]:
|
||||
job.log_line(
|
||||
f"Moved {_plural(counts['favorites'], 'favorite')} to the kept copies"
|
||||
)
|
||||
return True
|
||||
return {
|
||||
"removed": removed,
|
||||
"kept": kept,
|
||||
"targets": targets,
|
||||
"kept_path": target["keeper_abs"],
|
||||
"favorite": (
|
||||
_favorite_root_key(target["root"]),
|
||||
_favorite_relative_key(target["rel"]),
|
||||
_favorite_root_key(target["keeper_root"]),
|
||||
_favorite_relative_key(target["keeper_rel"]),
|
||||
),
|
||||
"removed_locator": target["locator"],
|
||||
"kept_locator": target["keeper_locator"],
|
||||
"sha256": target["hash"],
|
||||
"kept_size": kept_stat.st_size,
|
||||
"kept_mtime_ns": kept_stat.st_mtime_ns,
|
||||
}
|
||||
|
||||
|
||||
def _bound_problem(record, group, keeper=None):
|
||||
"""``file_problem`` without reading the file: size, mtime and inode."""
|
||||
return file_problem(
|
||||
record["abs"],
|
||||
record["root"],
|
||||
group["hash"],
|
||||
record["size"],
|
||||
hash_check=False,
|
||||
mtime_ns=record.get("mtime_ns"),
|
||||
inode=record.get("inode"),
|
||||
keeper=keeper,
|
||||
)
|
||||
|
||||
|
||||
def _kept_copy_state(target, hasher):
|
||||
"""The kept copy's ``os.stat`` when it is still the reviewed bytes, else None."""
|
||||
problem = file_problem(
|
||||
target["keeper_abs"],
|
||||
target["keeper_root"],
|
||||
target["hash"],
|
||||
target["keeper_size"],
|
||||
hasher,
|
||||
)
|
||||
if problem:
|
||||
return None
|
||||
try:
|
||||
return os.stat(target["keeper_abs"])
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _file_spellings(spellings, root, rel):
|
||||
"""Every path one file is linked by: its real path, then each spelling."""
|
||||
native = rel.replace("/", os.sep)
|
||||
roots = [root] + spellings.get(_identity(root), [])
|
||||
return list(dict.fromkeys(os.path.join(spelled, native) for spelled in roots))
|
||||
|
||||
Reference in New Issue
Block a user