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.
159 lines
5.6 KiB
Python
159 lines
5.6 KiB
Python
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
|
|
|
|
|
|
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
|