refactor(services): shared race-safe get_or_create helper (DRY backend sweep)
CI / lint (push) Failing after 2s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m19s

The find-or-create dance — SELECT, then a SAVEPOINT INSERT that recovers (not a
full rollback) on IntegrityError when a concurrent worker inserted first — was
hand-rolled identically in 4 async sites: ArtistService.find_or_create,
TagService.find_or_create, ExtensionService._find_or_create_artist and
._find_or_create_source. Divergent copies of exactly this pattern are how the
duplicate-row/race bugs in reference_scalar_one_or_none_duplicates crept in, so
it now lives once in services/db_helpers.get_or_create (returns (row, created);
factory adds+flushes+returns the row; caller owns the outer commit).

Over-DRY guard: SourceService's IntegrityError sites RAISE DuplicateSourceError
(reject-on-conflict, a different concept) — left alone. Importer._get_or_create
is the lone SYNC consumer (already shared by 2 callers) — stays separate, can't
cross the sync/async boundary. §8b: no hand-rolled async find-or-create remains.
Test: get_or_create creates then returns existing without re-invoking the factory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 23:46:01 -04:00
parent 9deebfa133
commit 7b2a2051e9
5 changed files with 131 additions and 70 deletions
+14 -19
View File
@@ -10,7 +10,6 @@ from dataclasses import dataclass
from sqlalchemy import and_, case, func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import (
@@ -24,6 +23,7 @@ from ..models import (
)
from ..models.tag import image_tag
from ..utils.slug import slugify
from .db_helpers import get_or_create
from .gallery_service import decode_cursor, encode_cursor, thumbnail_url
@@ -250,12 +250,10 @@ class ArtistService:
)
async def find_or_create(self, name: str) -> tuple[Artist, bool]:
"""Return (artist, created). Slug-keyed; idempotent under races.
Audit 2026-06-02: switched from session.rollback() to a
begin_nested savepoint + IntegrityError recovery so a lost
race doesn't unwind the calling request's surrounding work.
Mirrors importer._get_or_create.
"""Return (artist, created). Slug-keyed; idempotent under races via the
shared race-safe db_helpers.get_or_create (savepoint + IntegrityError
recovery). A new artist also seeds an ArtistVisit so the directory's
`+N new` badge starts at 0.
"""
cleaned = (name or "").strip()
if not cleaned:
@@ -263,12 +261,8 @@ class ArtistService:
slug = slugify(cleaned)
select_existing = select(Artist).where(Artist.slug == slug)
existing = (await self.session.execute(select_existing)).scalar_one_or_none()
if existing is not None:
return existing, False
sp = await self.session.begin_nested()
try:
async def _create() -> Artist:
artist = Artist(name=cleaned, slug=slug)
self.session.add(artist)
await self.session.flush()
@@ -279,13 +273,14 @@ class ArtistService:
# count every image imported in the same session.
self.session.add(ArtistVisit(artist_id=artist.id))
await self.session.flush()
await sp.commit()
except IntegrityError:
await sp.rollback()
existing = (await self.session.execute(select_existing)).scalar_one()
return existing, False
await self.session.commit()
return artist, True
return artist
artist, created = await get_or_create(
self.session, select_existing, _create
)
if created:
await self.session.commit()
return artist, created
async def autocomplete(self, prefix: str, limit: int = 20) -> list[Artist]:
cleaned = (prefix or "").strip()