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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user