Add explicit database transaction isolation to multi-step operations
This commit addresses race conditions in multi-step database operations by: 1. Wrap write operations in BEGIN IMMEDIATE ... COMMIT transactions: - import_run_repo: create_pending, mark_completed, mark_failed - geo_cache_repo: all upsert_*_and_commit functions - geo_cache_repo: bulk_upsert_entries_and_neg_entries_and_commit 2. Handle concurrent write collisions gracefully: - import_run_repo.create_pending can now raise IntegrityError - blocklist_import_workflow catches IntegrityError and retries lookup - Logs 'blocklist_import_lost_race' event when another request wins the race 3. Add comprehensive documentation: - Backend-Development.md § 6.3 Database Transactions - Explains when to use BEGIN IMMEDIATE - Shows transaction pattern with try-except-rollback - Documents race condition error handling pattern The solution leverages SQLite's UNIQUE constraint for data integrity while handling the concurrent case gracefully in application logic. This is more efficient than using BEGIN EXCLUSIVE which would serialize all writers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -18,7 +18,6 @@ if TYPE_CHECKING:
|
||||
|
||||
from app.models.geo import GeoCacheEntry
|
||||
|
||||
|
||||
# Alias for backward compatibility with protocols
|
||||
GeoCacheRow = GeoCacheEntry
|
||||
|
||||
@@ -109,9 +108,17 @@ async def upsert_entry_and_commit(
|
||||
asn: str | None,
|
||||
org: str | None,
|
||||
) -> None:
|
||||
"""Insert or update a resolved geo cache entry and commit."""
|
||||
await upsert_entry(db, ip, country_code, country_name, asn, org)
|
||||
await db.commit()
|
||||
"""Insert or update a resolved geo cache entry and commit.
|
||||
|
||||
Wraps the upsert in an explicit transaction to ensure atomicity.
|
||||
"""
|
||||
try:
|
||||
await db.execute("BEGIN IMMEDIATE")
|
||||
await upsert_entry(db, ip, country_code, country_name, asn, org)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def upsert_neg_entry(db: aiosqlite.Connection, ip: str) -> None:
|
||||
@@ -127,9 +134,17 @@ async def upsert_neg_entry(db: aiosqlite.Connection, ip: str) -> None:
|
||||
|
||||
|
||||
async def upsert_neg_entry_and_commit(db: aiosqlite.Connection, ip: str) -> None:
|
||||
"""Record a failed lookup attempt and commit the transaction."""
|
||||
await upsert_neg_entry(db, ip)
|
||||
await db.commit()
|
||||
"""Record a failed lookup attempt and commit the transaction.
|
||||
|
||||
Wraps the upsert in an explicit transaction to ensure atomicity.
|
||||
"""
|
||||
try:
|
||||
await db.execute("BEGIN IMMEDIATE")
|
||||
await upsert_neg_entry(db, ip)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def bulk_upsert_entries(
|
||||
@@ -173,17 +188,33 @@ async def bulk_upsert_entries_and_commit(
|
||||
db: aiosqlite.Connection,
|
||||
rows: Sequence[tuple[str, str | None, str | None, str | None, str | None]],
|
||||
) -> int:
|
||||
"""Bulk insert or update multiple geo cache entries and commit."""
|
||||
count = await bulk_upsert_entries(db, rows)
|
||||
await db.commit()
|
||||
return count
|
||||
"""Bulk insert or update multiple geo cache entries and commit.
|
||||
|
||||
Wraps the bulk upsert in an explicit transaction to ensure atomicity.
|
||||
"""
|
||||
try:
|
||||
await db.execute("BEGIN IMMEDIATE")
|
||||
count = await bulk_upsert_entries(db, rows)
|
||||
await db.commit()
|
||||
return count
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def bulk_upsert_neg_entries_and_commit(db: aiosqlite.Connection, ips: list[str]) -> int:
|
||||
"""Bulk insert negative lookup entries and commit."""
|
||||
count = await bulk_upsert_neg_entries(db, ips)
|
||||
await db.commit()
|
||||
return count
|
||||
"""Bulk insert negative lookup entries and commit.
|
||||
|
||||
Wraps the bulk upsert in an explicit transaction to ensure atomicity.
|
||||
"""
|
||||
try:
|
||||
await db.execute("BEGIN IMMEDIATE")
|
||||
count = await bulk_upsert_neg_entries(db, ips)
|
||||
await db.commit()
|
||||
return count
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def bulk_upsert_entries_and_neg_entries_and_commit(
|
||||
@@ -191,17 +222,34 @@ async def bulk_upsert_entries_and_neg_entries_and_commit(
|
||||
rows: Sequence[tuple[str, str | None, str | None, str | None, str | None]],
|
||||
ips: list[str],
|
||||
) -> tuple[int, int]:
|
||||
"""Persist positive and negative geo cache rows together, then commit."""
|
||||
"""Persist positive and negative geo cache rows together, then commit.
|
||||
|
||||
Wraps both upserts in a single transaction to ensure atomicity.
|
||||
Either all rows are persisted or none are.
|
||||
|
||||
Args:
|
||||
db: Active aiosqlite connection.
|
||||
rows: Sequence of (ip, country_code, country_name, asn, org) tuples.
|
||||
ips: List of IP strings for negative entries (failed lookups).
|
||||
|
||||
Returns:
|
||||
A tuple (positive_count, negative_count) of rows persisted.
|
||||
"""
|
||||
positive_count = 0
|
||||
negative_count = 0
|
||||
|
||||
if rows:
|
||||
positive_count = await bulk_upsert_entries(db, rows)
|
||||
if ips:
|
||||
negative_count = await bulk_upsert_neg_entries(db, ips)
|
||||
try:
|
||||
await db.execute("BEGIN IMMEDIATE")
|
||||
if rows:
|
||||
positive_count = await bulk_upsert_entries(db, rows)
|
||||
if ips:
|
||||
negative_count = await bulk_upsert_neg_entries(db, ips)
|
||||
|
||||
if rows or ips:
|
||||
await db.commit()
|
||||
if rows or ips:
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
return positive_count, negative_count
|
||||
|
||||
|
||||
Reference in New Issue
Block a user