fix(extension): _find_or_create_artist + _find_or_create_source race-safe via savepoint + IntegrityError recovery — same pattern as importer's helpers. Two concurrent quick-add-source calls on the same artist/url would have 500'd on uq_artist_slug / uq_source_artist_platform_url; now the second one rolls the savepoint back and returns the existing row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 16:27:51 -04:00
parent 7d84990f6d
commit fb41b90110
+44 -7
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import re import re
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Artist, Source from ..models import Artist, Source
@@ -97,20 +98,40 @@ class ExtensionService:
raise UnknownPlatformError(f"no platform pattern matched {url!r}") raise UnknownPlatformError(f"no platform pattern matched {url!r}")
async def _find_or_create_artist(self, raw_name: str) -> tuple[Artist, bool]: 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) slug = slugify(raw_name)
existing = (await self.session.execute( existing = (await self.session.execute(
select(Artist).where(Artist.slug == slug) select(Artist).where(Artist.slug == slug)
)).scalar_one_or_none() )).scalar_one_or_none()
if existing is not None: if existing is not None:
return existing, False return existing, False
artist = Artist(name=raw_name, slug=slug, is_subscription=True) sp = await self.session.begin_nested()
self.session.add(artist) try:
await self.session.flush() artist = Artist(name=raw_name, slug=slug, is_subscription=True)
return artist, 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( async def _find_or_create_source(
self, *, artist_id: int, platform: str, url: str, self, *, artist_id: int, platform: str, url: str,
) -> tuple[Source, bool]: ) -> 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( existing = (await self.session.execute(
select(Source).where( select(Source).where(
Source.artist_id == artist_id, Source.artist_id == artist_id,
@@ -120,8 +141,24 @@ class ExtensionService:
)).scalar_one_or_none() )).scalar_one_or_none()
if existing is not None: if existing is not None:
return existing, False return existing, False
src = Source(artist_id=artist_id, platform=platform, url=url, enabled=True) sp = await self.session.begin_nested()
self.session.add(src) try:
await self.session.flush() 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() await self.session.commit()
return src, True return src, True