fix(dev): dedupe unlinks after commit; report removed keys (refs #615)

apply() deleted storage copies inside the BEGIN/COMMIT, so a failure
partway through rolled the rows back and left the surviving rows
pointing at files that were already gone. Collect the doomed paths
during the transaction, COMMIT, then unlink — rows and files can now
only be lost together.

--apply also prints how many keys it removed (first 20), and main()
closes the connection it opens.
This commit is contained in:
kert
2026-09-08 15:42:25 -04:00
parent 32c06ad204
commit 4e9dc3d3d8
2 changed files with 78 additions and 19 deletions

View File

@@ -92,7 +92,16 @@ def _size(path: str) -> int:
def apply(con: sqlite3.Connection, groups: list[Group]) -> Report: def apply(con: sqlite3.Connection, groups: list[Group]) -> Report:
"""Delete the duplicate rows, then their storage copies.
Rows first, files second, with the COMMIT in between: a failure
mid-transaction rolls the rows back, and rows that still exist must
still have their files. Unlinking inside the transaction would leave
surviving rows pointing at nothing — the one outcome this cleanup
must never produce.
"""
rep = Report(groups=len(groups)) rep = Report(groups=len(groups))
doomed: list[Path] = []
con.execute("BEGIN") con.execute("BEGIN")
try: try:
for g in groups: for g in groups:
@@ -108,19 +117,20 @@ def apply(con: sqlite3.Connection, groups: list[Group]) -> Report:
rep.rows_removed += 1 rep.rows_removed += 1
rep.removed_keys.append(key) rep.removed_keys.append(key)
if not still_referenced: if not still_referenced:
p = Path(path) doomed.append(Path(path))
if p.is_file():
rep.bytes_freed += p.stat().st_size
p.unlink()
rep.files_removed += 1
try:
p.parent.rmdir() # the per-key dir, if now empty
except OSError:
pass
con.execute("COMMIT") con.execute("COMMIT")
except Exception: except Exception:
con.execute("ROLLBACK") con.execute("ROLLBACK")
raise raise
for p in doomed:
if p.is_file():
rep.bytes_freed += p.stat().st_size
p.unlink()
rep.files_removed += 1
try:
p.parent.rmdir() # the per-key dir, if now empty
except OSError:
pass
return rep return rep
@@ -143,20 +153,28 @@ def main(argv: list[str] | None = None) -> int:
db = str(path("db.bib")) db = str(path("db.bib"))
con = sqlite3.connect(db, isolation_level=None) con = sqlite3.connect(db, isolation_level=None)
groups = plan(con) try:
conflicts = sum(1 for g in groups if g.conflict) groups = plan(con)
rows = sum(len(g.remove) for g in groups) conflicts = sum(1 for g in groups if g.conflict)
print( rows = sum(len(g.remove) for g in groups)
f"{db}: {len(groups)} duplicate groups, {rows} rows to remove, {conflicts} conflicts (size mismatch, skipped)" print(
) f"{db}: {len(groups)} duplicate groups, {rows} rows to remove, "
if not args.apply: f"{conflicts} conflicts (size mismatch, skipped)"
print("dry run — pass --apply to delete") )
return 0 if not args.apply:
rep = apply(con, groups) print("dry run — pass --apply to delete")
return 0
rep = apply(con, groups)
finally:
con.close()
print( print(
f"removed rows={rep.rows_removed} files={rep.files_removed} " f"removed rows={rep.rows_removed} files={rep.files_removed} "
f"freed={rep.bytes_freed / 1e6:.1f} MB skipped_conflicts={rep.skipped_conflicts}" f"freed={rep.bytes_freed / 1e6:.1f} MB skipped_conflicts={rep.skipped_conflicts}"
) )
if rep.removed_keys:
head = ", ".join(rep.removed_keys[:20])
more = " …" if len(rep.removed_keys) > 20 else ""
print(f"removed keys ({len(rep.removed_keys)}): {head}{more}")
print("reopen the store once (any `stack bib` command) to create the unique index") print("reopen the store once (any `stack bib` command) to create the unique index")
return 0 return 0

View File

@@ -115,3 +115,44 @@ def test_unique_index_created_only_when_clean(tmp_path: Path):
for r in s3._con().execute("SELECT name FROM sqlite_master WHERE type='index'") for r in s3._con().execute("SELECT name FROM sqlite_master WHERE type='index'")
} }
assert "idx_attachments_item_filename" in idx assert "idx_attachments_item_filename" in idx
class _FailAfterFirstDelete:
"""Connection proxy that dies partway through the transaction."""
def __init__(self, con: sqlite3.Connection) -> None:
self._con = con
self.deletes = 0
def execute(self, sql: str, *args):
if sql.lstrip().upper().startswith("DELETE"):
self.deletes += 1
if self.deletes == 2:
raise sqlite3.OperationalError("disk I/O error")
return self._con.execute(sql, *args)
def test_apply_removes_no_file_when_the_transaction_fails(tmp_path: Path):
"""A rollback restores the rows — so the files they point at must
still be there. Unlinking inside the transaction loses data."""
import pytest
s, _ = _seed(tmp_path)
groups = mod.plan(s._con())
flaky = _FailAfterFirstDelete(s._con())
with pytest.raises(sqlite3.OperationalError):
mod.apply(flaky, groups)
assert s._con().execute("SELECT count(*) FROM attachments").fetchone()[0] == 3
for k in ("AAAAAAAA", "BBBBBBBB", "CCCCCCCC"):
assert (tmp_path / "storage" / k / "attachment_1.pdf").is_file()
def test_main_apply_reports_removed_keys(tmp_path: Path, capsys):
s, _ = _seed(tmp_path)
s.close()
rc = mod.main(["--db", str(tmp_path / "bib.sqlite"), "--apply"])
out = capsys.readouterr().out
assert rc == 0
assert "removed rows=2" in out
assert "removed keys (2)" in out
assert "BBBBBBBB" in out and "CCCCCCCC" in out