feat(bib): dedupe_attachments migration + guarded unique index on (item, filename) (refs #615)
This commit is contained in:
165
dev/scripts/dedupe_attachments.py
Executable file
165
dev/scripts/dedupe_attachments.py
Executable file
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""One-time cleanup of duplicate bib attachments (refs #615).
|
||||||
|
|
||||||
|
``Store.attach_file`` used to mint a new row + storage copy on every
|
||||||
|
call, so re-farms produced thousands of ``(item_id, filename)``
|
||||||
|
duplicates (33,379 groups / 66,917 of 78,017 rows on 2026-09-08). This
|
||||||
|
keeps the oldest row of each group, deletes the others' rows and their
|
||||||
|
storage copies, and prints a report. Dry-run by default.
|
||||||
|
|
||||||
|
Zotero is not touched: ``bib.sync`` already dedupes child attachments by
|
||||||
|
filename, so a duplicate that was synced once is a single Zotero child
|
||||||
|
attachment and stays valid.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
uv run python dev/scripts/dedupe_attachments.py # report only
|
||||||
|
uv run python dev/scripts/dedupe_attachments.py --apply # delete
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Group:
|
||||||
|
item_id: int
|
||||||
|
filename: str
|
||||||
|
keep: str
|
||||||
|
remove: list[str] = field(default_factory=list)
|
||||||
|
remove_paths: list[str] = field(default_factory=list)
|
||||||
|
conflict: bool = False # sizes differ → do not touch
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Report:
|
||||||
|
groups: int = 0
|
||||||
|
rows_removed: int = 0
|
||||||
|
files_removed: int = 0
|
||||||
|
bytes_freed: int = 0
|
||||||
|
skipped_conflicts: int = 0
|
||||||
|
removed_keys: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def plan(con: sqlite3.Connection) -> list[Group]:
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
rows = con.execute(
|
||||||
|
"""
|
||||||
|
SELECT a.item_id, a.filename, a.key, a.storage_path, a.rowid AS rid
|
||||||
|
FROM attachments a
|
||||||
|
WHERE (a.item_id, a.filename) IN (
|
||||||
|
SELECT item_id, filename FROM attachments
|
||||||
|
GROUP BY item_id, filename HAVING count(*) > 1
|
||||||
|
)
|
||||||
|
ORDER BY a.item_id, a.filename, a.rowid
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
groups: dict[tuple[int, str], Group] = {}
|
||||||
|
for r in rows:
|
||||||
|
gkey = (r["item_id"], r["filename"])
|
||||||
|
g = groups.get(gkey)
|
||||||
|
if g is None:
|
||||||
|
groups[gkey] = Group(
|
||||||
|
item_id=r["item_id"], filename=r["filename"], keep=r["key"]
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
g.remove.append(r["key"])
|
||||||
|
g.remove_paths.append(r["storage_path"])
|
||||||
|
# conflict check: every copy must have the same size as the kept one
|
||||||
|
for g in groups.values():
|
||||||
|
keep_path = con.execute(
|
||||||
|
"SELECT storage_path FROM attachments WHERE key = ?", (g.keep,)
|
||||||
|
).fetchone()["storage_path"]
|
||||||
|
keep_size = _size(keep_path)
|
||||||
|
for p in g.remove_paths:
|
||||||
|
if _size(p) not in (keep_size, -1):
|
||||||
|
g.conflict = True
|
||||||
|
break
|
||||||
|
return list(groups.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _size(path: str) -> int:
|
||||||
|
try:
|
||||||
|
return os.stat(path).st_size
|
||||||
|
except OSError:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def apply(con: sqlite3.Connection, groups: list[Group]) -> Report:
|
||||||
|
rep = Report(groups=len(groups))
|
||||||
|
con.execute("BEGIN")
|
||||||
|
try:
|
||||||
|
for g in groups:
|
||||||
|
if g.conflict:
|
||||||
|
rep.skipped_conflicts += 1
|
||||||
|
continue
|
||||||
|
for key, path in zip(g.remove, g.remove_paths):
|
||||||
|
still_referenced = con.execute(
|
||||||
|
"SELECT count(*) FROM attachments WHERE storage_path = ? AND key <> ?",
|
||||||
|
(path, key),
|
||||||
|
).fetchone()[0]
|
||||||
|
con.execute("DELETE FROM attachments WHERE key = ?", (key,))
|
||||||
|
rep.rows_removed += 1
|
||||||
|
rep.removed_keys.append(key)
|
||||||
|
if not still_referenced:
|
||||||
|
p = 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")
|
||||||
|
except Exception:
|
||||||
|
con.execute("ROLLBACK")
|
||||||
|
raise
|
||||||
|
return rep
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--db", default="", help="bib.sqlite path (default: stack.toml db.bib)"
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--apply", action="store_true", help="delete duplicates (default: report only)"
|
||||||
|
)
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
if args.db:
|
||||||
|
db = args.db
|
||||||
|
else:
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src"))
|
||||||
|
from conf import path
|
||||||
|
|
||||||
|
db = str(path("db.bib"))
|
||||||
|
con = sqlite3.connect(db, isolation_level=None)
|
||||||
|
groups = plan(con)
|
||||||
|
conflicts = sum(1 for g in groups if g.conflict)
|
||||||
|
rows = sum(len(g.remove) for g in groups)
|
||||||
|
print(
|
||||||
|
f"{db}: {len(groups)} duplicate groups, {rows} rows to remove, {conflicts} conflicts (size mismatch, skipped)"
|
||||||
|
)
|
||||||
|
if not args.apply:
|
||||||
|
print("dry run — pass --apply to delete")
|
||||||
|
return 0
|
||||||
|
rep = apply(con, groups)
|
||||||
|
print(
|
||||||
|
f"removed rows={rep.rows_removed} files={rep.files_removed} "
|
||||||
|
f"freed={rep.bytes_freed / 1e6:.1f} MB skipped_conflicts={rep.skipped_conflicts}"
|
||||||
|
)
|
||||||
|
print("reopen the store once (any `stack bib` command) to create the unique index")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -83,6 +83,19 @@ class Store:
|
|||||||
def _init_schema(self) -> None:
|
def _init_schema(self) -> None:
|
||||||
ddl = resources.files("bib").joinpath("schema.sql").read_text()
|
ddl = resources.files("bib").joinpath("schema.sql").read_text()
|
||||||
self._con().executescript(ddl)
|
self._con().executescript(ddl)
|
||||||
|
# The (item_id, filename) unique index can only exist once the
|
||||||
|
# dedupe migration has run (dev/scripts/dedupe_attachments.py);
|
||||||
|
# until then we leave it off rather than fail to open the store.
|
||||||
|
con = self._con()
|
||||||
|
dup = con.execute(
|
||||||
|
"SELECT 1 FROM attachments GROUP BY item_id, filename HAVING count(*) > 1 LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if dup is None:
|
||||||
|
con.execute(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_attachments_item_filename "
|
||||||
|
"ON attachments(item_id, filename)"
|
||||||
|
)
|
||||||
|
con.commit()
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
if self._connection is not None:
|
if self._connection is not None:
|
||||||
|
|||||||
117
tests/dev/test_dedupe_attachments.py
Normal file
117
tests/dev/test_dedupe_attachments.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from importlib import resources
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from bib.item import Source
|
||||||
|
from bib.store import Store
|
||||||
|
|
||||||
|
_SCRIPT = (
|
||||||
|
Path(__file__).resolve().parents[2] / "dev" / "scripts" / "dedupe_attachments.py"
|
||||||
|
)
|
||||||
|
spec = importlib.util.spec_from_file_location("dedupe_attachments", _SCRIPT)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules["dedupe_attachments"] = mod
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(tmp_path: Path) -> tuple[Store, str]:
|
||||||
|
# Seed the item + duplicate attachment rows via a raw connection,
|
||||||
|
# *before* any Store ever opens this file. Store's schema guard adds
|
||||||
|
# the (item_id, filename) unique index the moment it sees zero
|
||||||
|
# duplicate groups — true the instant the attachments table exists —
|
||||||
|
# so seeding duplicates through a Store-opened connection can never
|
||||||
|
# succeed. This mirrors the real migration scenario: the duplicates
|
||||||
|
# were written by pre-Task-4 code that predates this guard.
|
||||||
|
db_path = tmp_path / "bib.sqlite"
|
||||||
|
storage = tmp_path / "storage"
|
||||||
|
ddl = resources.files("bib").joinpath("schema.sql").read_text()
|
||||||
|
raw = sqlite3.connect(str(db_path))
|
||||||
|
raw.executescript(ddl)
|
||||||
|
item = Source(title="T", url="https://x/1")
|
||||||
|
item.stamp_access()
|
||||||
|
row = item.to_row()
|
||||||
|
key = row["key"] or "SEEDKEY1"
|
||||||
|
row["key"] = key
|
||||||
|
raw.execute(
|
||||||
|
"""INSERT INTO items
|
||||||
|
(key, item_type, title, url, date_published,
|
||||||
|
access_date, abstract, institution, extra, extra_json)
|
||||||
|
VALUES (:key, :item_type, :title, :url,
|
||||||
|
:date_published, :access_date, :abstract,
|
||||||
|
:institution, :extra, :extra_json)""",
|
||||||
|
row,
|
||||||
|
)
|
||||||
|
item_id = raw.execute("SELECT id FROM items WHERE key=?", (key,)).fetchone()[0]
|
||||||
|
# Simulate the old non-idempotent attach: three rows, three copies.
|
||||||
|
for k in ("AAAAAAAA", "BBBBBBBB", "CCCCCCCC"):
|
||||||
|
d = storage / k
|
||||||
|
d.mkdir(parents=True)
|
||||||
|
(d / "attachment_1.pdf").write_bytes(b"%PDF-dup")
|
||||||
|
raw.execute(
|
||||||
|
"INSERT INTO attachments (item_id, key, filename, content_type, storage_path) VALUES (?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
item_id,
|
||||||
|
k,
|
||||||
|
"attachment_1.pdf",
|
||||||
|
"application/pdf",
|
||||||
|
str(d / "attachment_1.pdf"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
raw.commit()
|
||||||
|
raw.close()
|
||||||
|
s = Store(str(db_path), storage_dir=storage)
|
||||||
|
return s, key
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_finds_group_and_keeps_oldest(tmp_path: Path):
|
||||||
|
s, _ = _seed(tmp_path)
|
||||||
|
groups = mod.plan(s._con())
|
||||||
|
assert len(groups) == 1
|
||||||
|
g = groups[0]
|
||||||
|
assert g.keep == "AAAAAAAA"
|
||||||
|
assert sorted(g.remove) == ["BBBBBBBB", "CCCCCCCC"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_removes_rows_and_files(tmp_path: Path):
|
||||||
|
s, _ = _seed(tmp_path)
|
||||||
|
rep = mod.apply(s._con(), mod.plan(s._con()))
|
||||||
|
assert rep.rows_removed == 2
|
||||||
|
assert rep.files_removed == 2
|
||||||
|
assert s._con().execute("SELECT count(*) FROM attachments").fetchone()[0] == 1
|
||||||
|
assert (tmp_path / "storage" / "AAAAAAAA" / "attachment_1.pdf").is_file()
|
||||||
|
assert not (tmp_path / "storage" / "BBBBBBBB").exists()
|
||||||
|
assert mod.plan(s._con()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_refuses_group_with_differing_sizes(tmp_path: Path):
|
||||||
|
s, _ = _seed(tmp_path)
|
||||||
|
(tmp_path / "storage" / "CCCCCCCC" / "attachment_1.pdf").write_bytes(
|
||||||
|
b"different-longer"
|
||||||
|
)
|
||||||
|
groups = mod.plan(s._con())
|
||||||
|
assert groups[0].conflict is True
|
||||||
|
rep = mod.apply(s._con(), groups)
|
||||||
|
assert rep.rows_removed == 0 and rep.skipped_conflicts == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_unique_index_created_only_when_clean(tmp_path: Path):
|
||||||
|
s, _ = _seed(tmp_path)
|
||||||
|
s.close()
|
||||||
|
s2 = Store(str(tmp_path / "bib.sqlite"), storage_dir=tmp_path / "storage")
|
||||||
|
idx = {
|
||||||
|
r[0]
|
||||||
|
for r in s2._con().execute("SELECT name FROM sqlite_master WHERE type='index'")
|
||||||
|
}
|
||||||
|
assert "idx_attachments_item_filename" not in idx
|
||||||
|
mod.apply(s2._con(), mod.plan(s2._con()))
|
||||||
|
s2.close()
|
||||||
|
s3 = Store(str(tmp_path / "bib.sqlite"), storage_dir=tmp_path / "storage")
|
||||||
|
idx = {
|
||||||
|
r[0]
|
||||||
|
for r in s3._con().execute("SELECT name FROM sqlite_master WHERE type='index'")
|
||||||
|
}
|
||||||
|
assert "idx_attachments_item_filename" in idx
|
||||||
Reference in New Issue
Block a user