7b2a2051e9
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>
56 lines
2.2 KiB
Python
56 lines
2.2 KiB
Python
"""Shared DB-access helpers for the async services.
|
|
|
|
`get_or_create` centralizes the race-safe find-or-create dance — SELECT, then on
|
|
a miss a savepoint INSERT that recovers (NOT a full rollback) when a concurrent
|
|
worker inserted the same row first. It was hand-rolled identically in
|
|
ArtistService, TagService and ExtensionService; divergent copies are exactly how
|
|
the duplicate-row / race bugs in [[reference_scalar_one_or_none_duplicates]] crept
|
|
in, so it lives in one place now (DRY pattern sweep 2026-06-09).
|
|
|
|
Note: this is the ASYNC sibling of `Importer._get_or_create` (sync, used by the
|
|
filesystem-import path). The two can't share an implementation across the
|
|
sync/async boundary; the importer one stays as the lone sync consumer.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Awaitable, Callable
|
|
from typing import TypeVar
|
|
|
|
from sqlalchemy import Select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
T = TypeVar("T")
|
|
|
|
|
|
async def get_or_create(
|
|
session: AsyncSession,
|
|
select_stmt: Select,
|
|
factory: Callable[[], Awaitable[T]],
|
|
) -> tuple[T, bool]:
|
|
"""Race-safe find-or-create. Returns ``(row, created)``.
|
|
|
|
Run ``select_stmt`` (scalar_one_or_none); if a row exists, return it with
|
|
``created=False``. Otherwise open a SAVEPOINT and ``await factory()`` — which
|
|
must add its row(s), flush, and return the primary row. On ``IntegrityError``
|
|
(a concurrent worker inserted the same row first) roll back the SAVEPOINT —
|
|
NOT the outer transaction, which would lose the caller's surrounding work —
|
|
and re-run ``select_stmt`` (scalar_one) to return the row the other worker
|
|
created. The caller owns the outer commit.
|
|
|
|
A UNIQUE/partial-unique constraint matching ``select_stmt``'s predicate is
|
|
required for the recovery to trip; without it a duplicate slips through.
|
|
"""
|
|
existing = (await session.execute(select_stmt)).scalar_one_or_none()
|
|
if existing is not None:
|
|
return existing, False
|
|
sp = await session.begin_nested()
|
|
try:
|
|
row = await factory()
|
|
await sp.commit()
|
|
return row, True
|
|
except IntegrityError:
|
|
await sp.rollback()
|
|
return (await session.execute(select_stmt)).scalar_one(), False
|