397021dcbd
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
187 lines
6.3 KiB
Python
187 lines
6.3 KiB
Python
"""Race-safe ImageProvenance insert in Importer._apply_sidecar.
|
|
|
|
Operator-flagged 2026-05-26: the prior SELECT-then-INSERT pattern lost a
|
|
race when two workers ran _apply_sidecar on the same (image, post) pair
|
|
(plausibly seeded when the 5-min recovery sweep re-enqueued a still-running
|
|
long import). Duplicates then broke .scalar_one_or_none() on every later
|
|
deep-scan rederive (MultipleResultsFound). Alembic 0021 added
|
|
uq_image_provenance_image_post; the importer's new savepoint+IntegrityError
|
|
recovery path now trips on collision and gracefully recovers.
|
|
|
|
Tests cover:
|
|
- idempotent: re-running _apply_sidecar via _deep_rederive produces
|
|
exactly one ImageProvenance row.
|
|
- IntegrityError recovery: pre-seed a provenance row, force the first
|
|
SELECT to return None (simulating the race window where two workers
|
|
both observed no row), call _apply_sidecar — the savepoint INSERT
|
|
trips uq_image_provenance_image_post, gets rolled back, no exception
|
|
escapes, still exactly one row.
|
|
"""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
from sqlalchemy import func, select
|
|
|
|
from backend.app.models import (
|
|
ImageProvenance,
|
|
ImageRecord,
|
|
ImportSettings,
|
|
Post,
|
|
Source,
|
|
)
|
|
from backend.app.services.importer import Importer
|
|
from backend.app.services.thumbnailer import Thumbnailer
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
@pytest.fixture
|
|
def import_layout(tmp_path):
|
|
import_root = tmp_path / "import"
|
|
images_root = tmp_path / "images"
|
|
import_root.mkdir()
|
|
images_root.mkdir()
|
|
return import_root, images_root
|
|
|
|
|
|
@pytest.fixture
|
|
def importer(db_sync, import_layout):
|
|
import_root, images_root = import_layout
|
|
settings = db_sync.execute(
|
|
select(ImportSettings).where(ImportSettings.id == 1)
|
|
).scalar_one()
|
|
return Importer(
|
|
session=db_sync,
|
|
images_root=images_root,
|
|
import_root=import_root,
|
|
thumbnailer=Thumbnailer(images_root=images_root),
|
|
settings=settings,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def deep_importer(db_sync, import_layout):
|
|
import_root, images_root = import_layout
|
|
settings = db_sync.execute(
|
|
select(ImportSettings).where(ImportSettings.id == 1)
|
|
).scalar_one()
|
|
return Importer(
|
|
session=db_sync,
|
|
images_root=images_root,
|
|
import_root=import_root,
|
|
thumbnailer=Thumbnailer(images_root=images_root),
|
|
settings=settings,
|
|
deep=True,
|
|
)
|
|
|
|
|
|
def _split(path: Path, orient, size=(256, 256)):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
w, h = size
|
|
im = Image.new("L", size, 0)
|
|
px = im.load()
|
|
for y in range(h):
|
|
for x in range(w):
|
|
if (x / w if orient == "v" else y / h) >= 0.5:
|
|
px[x, y] = 255
|
|
im.convert("RGB").save(path, "JPEG")
|
|
|
|
|
|
def _sidecar(media: Path, payload: dict):
|
|
media.with_suffix(".json").write_text(json.dumps(payload))
|
|
|
|
|
|
def test_apply_sidecar_idempotent_on_deep_rederive(
|
|
importer, deep_importer, import_layout,
|
|
):
|
|
"""Normal-flow path: deep rederive on an already-imported image finds
|
|
the existing provenance row via .scalar_one_or_none() and skips the
|
|
insert. Exactly one ImageProvenance row after both runs."""
|
|
import_root, _ = import_layout
|
|
m = import_root / "Alice" / "a.jpg"
|
|
_split(m, "v")
|
|
_sidecar(m, {
|
|
"category": "patreon", "id": 555,
|
|
"url": "https://patreon.com/posts/555", "title": "Set 1",
|
|
})
|
|
r = importer.import_one(m)
|
|
assert r.status == "imported"
|
|
|
|
# Re-import via deep mode — sha matches → _deep_rederive → _apply_sidecar.
|
|
r2 = deep_importer.import_one(m)
|
|
assert r2.status == "refreshed"
|
|
|
|
count = importer.session.execute(
|
|
select(func.count()).select_from(ImageProvenance)
|
|
).scalar_one()
|
|
assert count == 1
|
|
|
|
|
|
def test_apply_sidecar_recovers_from_integrity_error(
|
|
importer, deep_importer, import_layout, db_sync, monkeypatch,
|
|
):
|
|
"""Race recovery: a row already exists for (image, post). We force the
|
|
importer's existence-check SELECT to return None for one call, mimicking
|
|
the race window where two workers both saw no row. The savepoint INSERT
|
|
then trips uq_image_provenance_image_post; the helper rolls the
|
|
savepoint back, no exception escapes, and the row count stays at 1.
|
|
"""
|
|
import_root, _ = import_layout
|
|
m = import_root / "Bob" / "b.jpg"
|
|
_split(m, "v")
|
|
_sidecar(m, {
|
|
"category": "patreon", "id": 777,
|
|
"url": "https://patreon.com/posts/777", "title": "Set 2",
|
|
})
|
|
# First import lays the canonical provenance row.
|
|
r = importer.import_one(m)
|
|
assert r.status == "imported"
|
|
rec = importer.session.get(ImageRecord, r.image_id)
|
|
post = importer.session.execute(select(Post)).scalar_one()
|
|
src = importer.session.execute(select(Source)).scalar_one()
|
|
assert rec is not None
|
|
assert src is not None
|
|
|
|
# Monkeypatch session.execute so the FIRST select inside _apply_sidecar's
|
|
# existence-check returns a "no row" wrapper. Subsequent selects (e.g.
|
|
# the find_or_create_source / find_or_create_post existence checks
|
|
# earlier in _apply_sidecar) all run normally; we intercept only the
|
|
# ImageProvenance lookup, identified by the SELECT's target columns
|
|
# mentioning image_provenance.
|
|
real_execute = db_sync.execute
|
|
intercepted = [False]
|
|
|
|
def _intercepting_execute(stmt, *args, **kwargs):
|
|
text = str(stmt)
|
|
if (
|
|
not intercepted[0]
|
|
and "image_provenance" in text
|
|
and "image_record_id" in text
|
|
and "post_id" in text
|
|
):
|
|
intercepted[0] = True
|
|
|
|
class _ForcedMiss:
|
|
def scalar_one_or_none(self):
|
|
return None
|
|
return _ForcedMiss()
|
|
return real_execute(stmt, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(db_sync, "execute", _intercepting_execute)
|
|
|
|
# Re-import via deep mode → _deep_rederive → _apply_sidecar. With the
|
|
# provenance-SELECT forced to miss, the helper will attempt the INSERT,
|
|
# trip uq_image_provenance_image_post, catch IntegrityError, and recover.
|
|
r2 = deep_importer.import_one(m)
|
|
assert r2.status == "refreshed"
|
|
|
|
# Lift the intercept, then verify the row count.
|
|
monkeypatch.undo()
|
|
count = db_sync.execute(
|
|
select(func.count()).select_from(ImageProvenance)
|
|
).scalar_one()
|
|
assert count == 1
|