diff --git a/backend/app/services/extension_service.py b/backend/app/services/extension_service.py index ff88c9c..ce34ed9 100644 --- a/backend/app/services/extension_service.py +++ b/backend/app/services/extension_service.py @@ -11,6 +11,7 @@ from __future__ import annotations import re from sqlalchemy import select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from ..models import Artist, Source @@ -97,20 +98,40 @@ class ExtensionService: raise UnknownPlatformError(f"no platform pattern matched {url!r}") async def _find_or_create_artist(self, raw_name: str) -> tuple[Artist, bool]: + """Race-safe find-or-create on Artist by slug. Mirrors the + savepoint + IntegrityError recovery pattern used in + Importer._find_or_create_source/post (see + reference_scalar_one_or_none_duplicates memory). Without this, + two concurrent quick-add-source calls hitting the same artist + would both miss the existence check and the second INSERT would + 500 against uq_artist_slug. + """ slug = slugify(raw_name) existing = (await self.session.execute( select(Artist).where(Artist.slug == slug) )).scalar_one_or_none() if existing is not None: return existing, False - artist = Artist(name=raw_name, slug=slug, is_subscription=True) - self.session.add(artist) - await self.session.flush() - return artist, True + sp = await self.session.begin_nested() + try: + artist = Artist(name=raw_name, slug=slug, is_subscription=True) + self.session.add(artist) + await self.session.flush() + await sp.commit() + return artist, True + except IntegrityError: + await sp.rollback() + recovered = (await self.session.execute( + select(Artist).where(Artist.slug == slug) + )).scalar_one() + return recovered, False async def _find_or_create_source( self, *, artist_id: int, platform: str, url: str, ) -> tuple[Source, bool]: + """Race-safe — same pattern as _find_or_create_artist above. The + uq_source_artist_platform_url constraint catches the duplicate + insert; we roll the savepoint back and re-select.""" existing = (await self.session.execute( select(Source).where( Source.artist_id == artist_id, @@ -120,8 +141,24 @@ class ExtensionService: )).scalar_one_or_none() if existing is not None: return existing, False - src = Source(artist_id=artist_id, platform=platform, url=url, enabled=True) - self.session.add(src) - await self.session.flush() + sp = await self.session.begin_nested() + try: + src = Source( + artist_id=artist_id, platform=platform, + url=url, enabled=True, + ) + self.session.add(src) + await self.session.flush() + await sp.commit() + except IntegrityError: + await sp.rollback() + recovered = (await self.session.execute( + select(Source).where( + Source.artist_id == artist_id, + Source.platform == platform, + Source.url == url, + ) + )).scalar_one() + return recovered, False await self.session.commit() return src, True