Fix race condition in get_or_create_* under concurrent ingestion
CI / lint (push) Canceled after 0s
CI / typecheck (push) Canceled after 0s
CI / unit-tests (push) Canceled after 0s
CI / integration-tests (push) Canceled after 0s
CI / container-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s

Every get_or_create_* helper (genre, keyword, language, country,
company, collection, person) followed check-then-act: look up by
natural key, insert if missing. That was safe as long as nothing
ingested concurrently for overlapping data -- which stopped being
true the moment ingest_series_task could cascade season ingestion
(include_seasons), since arq runs multiple queued jobs concurrently.
Two seasons of the same series sharing a guest star, crew member, or
company can now race to create it: both see "not found", both try to
INSERT, and the loser hits a unique-constraint violation instead of
finding the winner's row.

Reproduced live: cascading Better Call Saul (60059) with
include_seasons crashed a season job with
"duplicate key value violates unique constraint uq_external_id_source"
on a recurring guest actor shared across two seasons ingesting at the
same time.

_create_or_recover wraps each insert in a SAVEPOINT and, on that
specific failure, rolls back just the savepoint and re-queries for
what the concurrent job committed -- recovering instead of aborting
the whole ingestion job. Added unit tests for the retry mechanism
itself; genuinely reproducing the underlying race in an automated test
would need real Postgres MVCC timing, which isn't something a
deterministic test can force reliably.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
NixReaper
2026-09-05 02:27:07 -05:00
co-authored by Claude Sonnet 5
parent 64169c4aee
commit dd3ff9764b
2 changed files with 261 additions and 100 deletions
+72
View File
@@ -0,0 +1,72 @@
"""Unit tests for the get-or-create race-recovery mechanism
(app/repositories/reference_repository.py:_create_or_recover).
Genuinely reproducing the underlying race (two concurrent transactions
both missing each other's uncommitted insert) needs real Postgres MVCC
timing, which isn't something a deterministic test can force reliably.
What *can* be tested deterministically, and is the actual fix, is that
_create_or_recover falls back to `recover()` on a unique-constraint
violation instead of propagating it — see the integration-level
reproduction in the commit that introduced this file for how the bug
actually manifested (concurrent season-ingestion jobs sharing a guest
star both trying to create the same Person)."""
from __future__ import annotations
import pytest
from sqlalchemy.exc import IntegrityError
from app.repositories.reference_repository import _create_or_recover
class _FakeNestedTransaction:
async def __aenter__(self) -> _FakeNestedTransaction:
return self
async def __aexit__(self, exc_type, exc, tb) -> bool:
return False # never suppress — let the real exception propagate
class _FakeSession:
def begin_nested(self) -> _FakeNestedTransaction:
return _FakeNestedTransaction()
def _fake_integrity_error() -> IntegrityError:
return IntegrityError("INSERT ...", {}, Exception("duplicate key value"))
@pytest.mark.asyncio
async def test_create_or_recover_returns_created_value_on_success():
async def create() -> str:
return "created"
async def recover() -> str | None:
raise AssertionError("recover() must not run when create() succeeds")
result = await _create_or_recover(_FakeSession(), create, recover)
assert result == "created"
@pytest.mark.asyncio
async def test_create_or_recover_falls_back_to_recover_on_integrity_error():
async def create() -> str:
raise _fake_integrity_error()
async def recover() -> str | None:
return "recovered"
result = await _create_or_recover(_FakeSession(), create, recover)
assert result == "recovered"
@pytest.mark.asyncio
async def test_create_or_recover_reraises_when_recover_finds_nothing():
async def create() -> str:
raise _fake_integrity_error()
async def recover() -> str | None:
return None # the failure wasn't actually a race we can recover from
with pytest.raises(IntegrityError):
await _create_or_recover(_FakeSession(), create, recover)