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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user