audit-g5: architectural debt — 4 bundles (A/B/C/D) #53

Merged
bvandeusen merged 6 commits from dev into main 2026-06-02 18:07:25 -04:00
3 changed files with 50 additions and 21 deletions
Showing only changes of commit 8d75ade1d5 - Show all commits
+15 -10
View File
@@ -208,27 +208,32 @@ class ArtistService:
) )
async def find_or_create(self, name: str) -> tuple[Artist, bool]: async def find_or_create(self, name: str) -> tuple[Artist, bool]:
"""Return (artist, created). Slug-keyed; idempotent under races.""" """Return (artist, created). Slug-keyed; idempotent under races.
Audit 2026-06-02: switched from session.rollback() to a
begin_nested savepoint + IntegrityError recovery so a lost
race doesn't unwind the calling request's surrounding work.
Mirrors importer._get_or_create.
"""
cleaned = (name or "").strip() cleaned = (name or "").strip()
if not cleaned: if not cleaned:
raise ValueError("artist name must not be empty") raise ValueError("artist name must not be empty")
slug = slugify(cleaned) slug = slugify(cleaned)
existing = (await self.session.execute( select_existing = select(Artist).where(Artist.slug == slug)
select(Artist).where(Artist.slug == slug) existing = (await self.session.execute(select_existing)).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=cleaned, slug=slug) sp = await self.session.begin_nested()
self.session.add(artist)
try: try:
artist = Artist(name=cleaned, slug=slug)
self.session.add(artist)
await self.session.flush() await self.session.flush()
await sp.commit()
except IntegrityError: except IntegrityError:
await self.session.rollback() await sp.rollback()
existing = (await self.session.execute( existing = (await self.session.execute(select_existing)).scalar_one()
select(Artist).where(Artist.slug == slug)
)).scalar_one()
return existing, False return existing, False
await self.session.commit() await self.session.commit()
return artist, True return artist, True
+18 -4
View File
@@ -374,10 +374,19 @@ class Importer:
artist = self._resolve_artist(source) artist = self._resolve_artist(source)
post = self._post_for_sidecar(source, artist) post = self._post_for_sidecar(source, artist)
sha = _sha256_of(source) sha = _sha256_of(source)
existing = self.session.execute( select_existing = select(PostAttachment).where(PostAttachment.sha256 == sha)
select(PostAttachment).where(PostAttachment.sha256 == sha) existing = self.session.execute(select_existing).scalar_one_or_none()
).scalar_one_or_none() if existing is not None:
if existing is None: self.session.commit()
return ImportResult(status="attached")
# Savepoint + IntegrityError recovery — PostAttachment.sha256 is
# UNIQUE, so two workers can both pass the SELECT and only the
# second INSERT fails. Without savepoint, the outer transaction
# poisons and the calling task crashes. attachments.store is
# sha-addressed so both workers race to write the same target
# path; shutil.copy2 + rename is idempotent. Audit 2026-06-02.
sp = self.session.begin_nested()
try:
stored = self.attachments.store(source, sha) stored = self.attachments.store(source, sha)
self.session.add(PostAttachment( self.session.add(PostAttachment(
post_id=post.id if post else None, post_id=post.id if post else None,
@@ -390,6 +399,11 @@ class Importer:
size_bytes=source.stat().st_size, size_bytes=source.stat().st_size,
)) ))
self.session.flush() self.session.flush()
sp.commit()
except IntegrityError:
sp.rollback()
# Lost the race — the other worker's row is canonical.
self.session.execute(select_existing).scalar_one()
self.session.commit() self.session.commit()
return ImportResult(status="attached") return ImportResult(status="attached")
+17 -7
View File
@@ -5,6 +5,7 @@ from dataclasses import dataclass
from sqlalchemy import and_, case, exists, func, select, text, update from sqlalchemy import and_, case, exists, func, select, text, update
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Tag, TagKind, image_tag from ..models import Tag, TagKind, image_tag
@@ -86,9 +87,12 @@ class TagService:
f"fandom_id {fandom_id} does not reference a fandom tag" f"fandom_id {fandom_id} does not reference a fandom tag"
) )
# Upsert via INSERT ... ON CONFLICT DO NOTHING. We can't use the # Audit 2026-06-02: race-safe upsert via savepoint +
# uniqueness index name directly (it's a partial coalesce-based # IntegrityError recovery. The partial uniqueness index on
# expression), so we re-select after insert. # (name, kind, COALESCE(fandom_id, -1)) catches concurrent
# inserts; without the savepoint the outer transaction would
# poison and the calling request crashes. Mirrors
# importer._get_or_create.
stmt = ( stmt = (
select(Tag) select(Tag)
.where(Tag.name == name) .where(Tag.name == name)
@@ -101,10 +105,16 @@ class TagService:
if existing: if existing:
return existing return existing
new_tag = Tag(name=name, kind=kind, fandom_id=fandom_id) sp = await self.session.begin_nested()
self.session.add(new_tag) try:
await self.session.flush() new_tag = Tag(name=name, kind=kind, fandom_id=fandom_id)
return new_tag self.session.add(new_tag)
await self.session.flush()
await sp.commit()
return new_tag
except IntegrityError:
await sp.rollback()
return (await self.session.execute(stmt)).scalar_one()
async def autocomplete( async def autocomplete(
self, self,