fix: atomic upsert for import runs (Issue #12)

Replace check-then-insert race condition with INSERT ON CONFLICT.
- upsert_pending uses RETURNING id for atomic upsert
- UNIQUE(source_id, content_hash) constraint from migration 6
- blocklist_import_workflow updated to use upsert_pending
- test_import_source_success fixed for async mock patterns

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-02 23:39:43 +02:00
parent 1285bc8571
commit e436727942
11 changed files with 144 additions and 164 deletions

View File

@@ -190,41 +190,19 @@ class BlocklistImportWorkflow:
# --- Create or update pending import run entry ---
if existing_run is None:
try:
run_id = await import_run_repo.create_pending(
db,
source.id,
content_hash,
)
log.info(
"blocklist_import_tracking_created",
source_id=source.id,
run_id=run_id,
content_hash=content_hash[:8],
)
except aiosqlite.IntegrityError as e:
# Race condition: another request created the same import between
# our check and this insert. Fetch the existing run and use its ID.
existing_run = await import_run_repo.get_by_source_and_hash(
db,
source.id,
content_hash,
)
if existing_run is None:
# Unexpected: the constraint error indicates a row exists, but
# we can't find it. This should not happen in normal operation.
raise RuntimeError(
f"Integrity error indicates import exists, "
f"but lookup failed for source_id={source.id}, "
f"content_hash={content_hash[:8]}"
) from e
run_id = existing_run.id
log.info(
"blocklist_import_lost_race",
source_id=source.id,
run_id=run_id,
content_hash=content_hash[:8],
)
run_id = await import_run_repo.upsert_pending(
db,
source.id,
content_hash,
)
# Commit the implicit transaction opened by RETURNING.
await db.commit()
log.info(
"blocklist_import_tracking_created",
source_id=source.id,
run_id=run_id,
content_hash=content_hash[:8],
)
else:
# Retry case: existing run is pending or failed, try again
run_id = existing_run.id

View File

@@ -18,8 +18,10 @@ import json
from typing import TYPE_CHECKING
import aiohttp
import aiosqlite
import structlog
from app.exceptions import BlocklistSourceHasLogsError
from app.models.blocklist import (
BlocklistSource,
ImportLogEntry,
@@ -40,7 +42,6 @@ from app.utils.pagination import create_pagination_metadata
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
import aiosqlite
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.config import Settings
@@ -196,8 +197,17 @@ async def delete_source(db: aiosqlite.Connection, source_id: int) -> bool:
Returns:
``True`` if the source was found and deleted, ``False`` otherwise.
Raises:
BlocklistSourceHasLogsError: If the source has associated import logs
and cannot be deleted due to RESTRICT foreign key constraint.
"""
deleted = await blocklist_repo.delete_source(db, source_id)
try:
deleted = await blocklist_repo.delete_source(db, source_id)
except aiosqlite.IntegrityError as e:
if "FOREIGN KEY constraint failed" in str(e):
raise BlocklistSourceHasLogsError(source_id) from e
raise
if deleted:
log.info("blocklist_source_deleted", id=source_id)
return deleted