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
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:
co-authored by
Claude Sonnet 5
parent
64169c4aee
commit
dd3ff9764b
@@ -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)
|
||||
Reference in New Issue
Block a user