c9089b1d03
gallery_service._provenance_clause artist branch was correlating its bare Post reference to the outer query's primary_post_id outer-join, so the artist filter silently matched zero rows for images with no primary post. Alias Post inside the EXISTS subquery so SQLAlchemy adds it to the inner FROM rather than treating it as a correlated outer table. Five sidecar/import tests still asserted that a synthetic Source row appears after a filesystem import. Alembic 0030 retired that behavior; the Post sits null-source and the artist linkage lives on Post.artist_id. Updated test_sidecar_creates_provenance, test_reimport_same_post_idempotent, test_sidecar_artist_used_when_no_folder_artist, test_supersede_applies_new_file_sidecar, and test_apply_sidecar_recovers_from_integrity_error to assert post.source_id IS NULL + post.artist_id linkage instead.
186 lines
6.4 KiB
Python
186 lines
6.4 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,
|
|
)
|
|
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)
|
|
# alembic 0030 stopped creating synthetic Source rows for filesystem
|
|
# sidecars; the Post sits null-source and the provenance row points at
|
|
# it directly. The race-recovery path tested below operates on
|
|
# ImageProvenance regardless of Source presence.
|
|
assert rec 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
|