fix(bib): host-side Zotero sync skips copies it cannot read
Some checks failed
CI / lint (push) Successful in 1m27s
CI / notebooks-smoke (push) Successful in 2m20s
Deploy / changes (push) Successful in 39s
CI / test (push) Has been cancelled
Deploy / report (push) Has been cancelled
Deploy / notebooks (push) Has been cancelled
Deploy / zotero (push) Has been cancelled
Deploy / docs (push) Has been cancelled
Deploy / api (push) Has been cancelled
Deploy / bcda (push) Has been cancelled
Deploy / llm (push) Has been cancelled
Deploy / mc (push) Has been cancelled
Infra CI / mc (push) Has been cancelled
Infra CI / zotero (push) Has been cancelled
Infra CI / docs (push) Has been cancelled
Infra CI / api (push) Has been cancelled
Infra CI / bcda (push) Has been cancelled
Infra CI / llm (push) Has been cancelled
Infra CI / notebooks (push) Has been cancelled

Zotero's storage files belong to the container user; on the host they
stat but do not open, and comparing a same-name file crashed the
nightly bib-refresh-iom sync. An unreadable copy now counts as
unchanged (nothing is written); the in-container nightly sync compares
and hoards.
This commit is contained in:
kert
2026-11-11 14:27:47 -05:00
parent 2f889ff9fc
commit bf8fd3b1f5
2 changed files with 53 additions and 2 deletions

View File

@@ -19,6 +19,7 @@ Usage::
from __future__ import annotations
import json
import logging
import shutil
from datetime import date
from pathlib import Path
@@ -28,6 +29,8 @@ from bib.item import Item
from zot.db import TYPE_MAP as ZOT_TYPE_MAP
from zot.db import Db, generate_key, is_valid_key, now_iso
log = logging.getLogger(__name__)
if TYPE_CHECKING:
from bib.store import Store
@@ -596,13 +599,23 @@ def _sync_attachments(
def _same_file(a: Path, b: Path) -> bool:
"""Byte-identical? Size + mtime short-circuit (copy2 keeps mtime)."""
"""Byte-identical? Size + mtime short-circuit (copy2 keeps mtime).
On the host, Zotero's storage files belong to the container user:
they stat but do not open. An unreadable copy counts as the same —
nothing is written; the nightly in-container sync can compare it."""
import filecmp
sa, sb = a.stat(), b.stat()
if sa.st_size != sb.st_size:
return False
return sa.st_mtime_ns == sb.st_mtime_ns or filecmp.cmp(a, b, shallow=False)
if sa.st_mtime_ns == sb.st_mtime_ns:
return True
try:
return filecmp.cmp(a, b, shallow=False)
except PermissionError:
log.debug("cannot read %s to compare; leaving it", b)
return True
def _sha8(p: Path) -> str:

View File

@@ -128,6 +128,44 @@ class TestSyncAttachments:
blobs = sorted(p.read_bytes() for p in storage.rglob("*.pdf"))
assert blobs == [b"%PDF new chapter, revised", b"%PDF old chapter"]
def test_unreadable_zotero_copy_is_left_alone(self, tmp_path, monkeypatch):
# On the host, Zotero's storage files belong to the container user:
# stat works, open does not. Sync must skip, not crash or write.
import filecmp
path = _setup(tmp_path)
storage = tmp_path / "storage"
storage.mkdir()
src_file = tmp_path / "source" / "chapter.pdf"
src_file.parent.mkdir()
src_file.write_bytes(b"%PDF a")
row = {
"filename": "chapter.pdf",
"content_type": "application/pdf",
"storage_path": str(src_file),
"created_at": "2026-04-15T00:00:00Z",
}
store = MagicMock()
store._con.return_value.execute.return_value.fetchall.return_value = [row]
with Db(path) as db:
parent_id = db.create_item(TYPE_MAP["document"])
db.commit()
assert (
_sync_attachments(db, store, MagicMock(key="K1"), parent_id, storage)
== 1
)
db.commit()
src_file.write_bytes(b"%PDF b") # same size, new mtime
def denied(*a, **k):
raise PermissionError(13, "Permission denied")
monkeypatch.setattr(filecmp, "cmp", denied)
assert (
_sync_attachments(db, store, MagicMock(key="K1"), parent_id, storage)
== 0
)
def test_missing_file(self, tmp_path):
path = _setup(tmp_path)
store = MagicMock()