From fb41b901109a58307c4f3cc2d554e78e948194e5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 16:27:51 -0400 Subject: [PATCH] =?UTF-8?q?fix(extension):=20=5Ffind=5For=5Fcreate=5Fartis?= =?UTF-8?q?t=20+=20=5Ffind=5For=5Fcreate=5Fsource=20race-safe=20via=20save?= =?UTF-8?q?point=20+=20IntegrityError=20recovery=20=E2=80=94=20same=20patt?= =?UTF-8?q?ern=20as=20importer's=20helpers.=20Two=20concurrent=20quick-add?= =?UTF-8?q?-source=20calls=20on=20the=20same=20artist/url=20would=20have?= =?UTF-8?q?=20500'd=20on=20uq=5Fartist=5Fslug=20/=20uq=5Fsource=5Fartist?= =?UTF-8?q?=5Fplatform=5Furl;=20now=20the=20second=20one=20rolls=20the=20s?= =?UTF-8?q?avepoint=20back=20and=20returns=20the=20existing=20row.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/services/extension_service.py | 51 +++++++++++++++++++---- 1 file changed, 44 insertions(+), 7 deletions(-) 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