| """ |
| Database Lock and Deadlock Tests |
| |
| Tests for database transaction behavior under concurrent access. |
| These tests document SQLite limitations and PostgreSQL behavior. |
| |
| Key Bugs Tested: |
| - Transaction rollback on deadlock |
| - SELECT FOR UPDATE locking behavior |
| - Isolation level differences |
| - Connection pool exhaustion |
| |
| SQLite Limitations Documented: |
| - Only one writer at a time (serialized access) |
| - No true parallel write concurrency |
| - Limited deadlock detection |
| - For production, PostgreSQL provides: |
| - SERIALIZABLE isolation for phantom read prevention |
| - Real deadlock detection |
| - Row-level locking with SELECT FOR UPDATE |
| - Connection pooling with proper isolation |
| """ |
|
|
| import threading |
| import time |
| import pytest |
| import uuid |
| from datetime import datetime |
| from typing import List |
|
|
| from sqlalchemy import text |
| from sqlalchemy.orm import Session |
|
|
| from core.models import AgentRegistry, AgentStatus |
| from core.database import SessionLocal |
|
|
|
|
| class TestTransactionDeadlockHandling: |
| """Test transaction behavior under concurrent access.""" |
|
|
| def test_sqlite_concurrent_write_serialization(self, db_session: Session): |
| """ |
| CONCURRENT: SQLite serializes concurrent writes. |
| |
| Documents that SQLite allows only one writer at a time. |
| Multiple threads writing will be serialized (not truly concurrent). |
| |
| BUG_PATTERN: Assuming parallel writes on SQLite. |
| EXPECTED: Writes are serialized, no corruption. |
| """ |
| |
| agents = [] |
| for i in range(5): |
| agent = AgentRegistry( |
| id=str(uuid.uuid4()), |
| name=f"Agent{i}", |
| category="test", |
| module_path="test.module", |
| class_name="TestAgent", |
| status=AgentStatus.INTERN.value, |
| confidence_score=0.6, |
| ) |
| db_session.add(agent) |
| agents.append(agent) |
| db_session.commit() |
|
|
| errors = [] |
| update_order = [] |
|
|
| def update_agent(agent_id: int, new_confidence: float): |
| """Update agent confidence score.""" |
| local_db = SessionLocal() |
| try: |
| agent = local_db.query(AgentRegistry).filter( |
| AgentRegistry.id == agents[agent_id].id |
| ).first() |
| if agent: |
| agent.confidence_score = new_confidence |
| local_db.commit() |
| update_order.append(agent_id) |
| except Exception as e: |
| errors.append(e) |
| local_db.rollback() |
| finally: |
| local_db.close() |
|
|
| |
| threads = [] |
| for i in range(5): |
| thread = threading.Thread(target=update_agent, args=(i, 0.7 + i * 0.05)) |
| threads.append(thread) |
| thread.start() |
|
|
| for thread in threads: |
| thread.join() |
|
|
| |
| assert len(errors) == 0, f"Errors during concurrent writes: {errors}" |
|
|
| |
| for agent in agents: |
| db_session.refresh(agent) |
| |
| assert agent.confidence_score >= 0.6, f"Agent {agent.name} has unexpected value: {agent.confidence_score}" |
|
|
| def test_sqlite_read_write_concurrency(self, db_session: Session): |
| """ |
| CONCURRENT: SQLite allows one writer OR multiple readers. |
| |
| Documents SQLite concurrency model: one writer blocks all other |
| operations (writes and reads). Multiple readers allowed only |
| when no writer is active. |
| |
| BUG_PATTERN: Assuming reads can proceed during writes. |
| EXPECTED: Reads blocked during writes (serialized). |
| """ |
| |
| for i in range(10): |
| agent = AgentRegistry( |
| id=str(uuid.uuid4()), |
| name=f"Agent{i}", |
| category="test", |
| module_path="test.module", |
| class_name="TestAgent", |
| status=AgentStatus.INTERN.value, |
| confidence_score=0.6, |
| ) |
| db_session.add(agent) |
| db_session.commit() |
|
|
| errors = [] |
| read_count = [0] |
| write_count = [0] |
|
|
| def read_agents(): |
| """Read agents.""" |
| local_db = SessionLocal() |
| try: |
| agents = local_db.query(AgentRegistry).all() |
| read_count[0] = len(agents) |
| except Exception as e: |
| errors.append(e) |
| finally: |
| local_db.close() |
|
|
| def write_agent(): |
| """Write agent.""" |
| local_db = SessionLocal() |
| try: |
| agent = local_db.query(AgentRegistry).first() |
| if agent: |
| agent.confidence_score = 0.8 |
| local_db.commit() |
| write_count[0] += 1 |
| except Exception as e: |
| errors.append(e) |
| local_db.rollback() |
| finally: |
| local_db.close() |
|
|
| |
| threads = [] |
| for i in range(5): |
| threads.append(threading.Thread(target=read_agents)) |
| threads.append(threading.Thread(target=write_agent)) |
|
|
| for thread in threads: |
| thread.start() |
| for thread in threads: |
| thread.join() |
|
|
| |
| assert len(errors) == 0, f"Errors: {errors}" |
| assert read_count[0] > 0, "Should have read agents" |
| assert write_count[0] > 0, "Should have written agents" |
|
|
|
|
| class TestDatabaseIsolationLevels: |
| """Test transaction isolation levels.""" |
|
|
| def test_read_committed_isolation(self, db_session: Session): |
| """ |
| CONCURRENT: Read committed isolation behavior. |
| |
| Documents that SQLite defaults to read committed (or higher). |
| Uncommitted changes from other transactions are not visible. |
| |
| BUG_PATTERN: Assuming uncommitted reads are visible. |
| EXPECTED: Reads see only committed data. |
| """ |
| |
| agent = AgentRegistry( |
| id=str(uuid.uuid4()), |
| name="TestAgent", |
| category="test", |
| module_path="test.module", |
| class_name="TestAgent", |
| status=AgentStatus.INTERN.value, |
| confidence_score=0.6, |
| ) |
| db_session.add(agent) |
| db_session.commit() |
|
|
| uncommitted_value = [None] |
| committed_value = [None] |
| errors = [] |
|
|
| def write_uncommitted(): |
| """Write but don't commit.""" |
| local_db = SessionLocal() |
| try: |
| agent = local_db.query(AgentRegistry).first() |
| agent.confidence_score = 0.9 |
| |
| uncommitted_value[0] = agent.confidence_score |
| time.sleep(0.1) |
| local_db.commit() |
| except Exception as e: |
| errors.append(e) |
| finally: |
| local_db.close() |
|
|
| def read_committed(): |
| """Read committed data.""" |
| time.sleep(0.05) |
| local_db = SessionLocal() |
| try: |
| agent = local_db.query(AgentRegistry).first() |
| committed_value[0] = agent.confidence_score |
| except Exception as e: |
| errors.append(e) |
| finally: |
| local_db.close() |
|
|
| |
| thread1 = threading.Thread(target=write_uncommitted) |
| thread2 = threading.Thread(target=read_committed) |
|
|
| thread1.start() |
| thread2.start() |
|
|
| thread1.join() |
| thread2.join() |
|
|
| |
| assert len(errors) == 0, f"Errors: {errors}" |
|
|
| |
| |
| assert 0.6 <= committed_value[0] <= 0.9, f"Unexpected value: {committed_value[0]}" |
|
|
| def test_serializable_isolation_note(self, db_session: Session): |
| """ |
| DOCUMENTATION: PostgreSQL SERIALIZABLE isolation. |
| |
| Notes that PostgreSQL supports SERIALIZABLE isolation level |
| which prevents phantom reads. SQLite has limited isolation |
| level support. |
| |
| For production with PostgreSQL: |
| engine = create_engine( |
| "postgresql://...", |
| isolation_level="SERIALIZABLE" |
| ) |
| |
| This ensures: |
| - No phantom reads |
| - No non-repeatable reads |
| - Full serializable execution |
| """ |
| |
| |
| |
|
|
| |
| result = db_session.execute(text("PRAGMA read_uncommitted")).scalar() |
| |
| assert result == 0, "SQLite should default to read committed" |
|
|
|
|
| class TestConnectionPoolBehavior: |
| """Test connection pool under concurrent load.""" |
|
|
| def test_connection_pool_exhaustion_handling(self, db_session: Session): |
| """ |
| CONCURRENT: Connection pool doesn't exhaust under load. |
| |
| Tests that connection pool handles many concurrent connections |
| without exhaustion. SQLite uses StaticPool (single connection) |
| but tests should verify pool behavior. |
| |
| BUG_PATTERN: Connection leak causing pool exhaustion. |
| EXPECTED: All connections released properly. |
| """ |
| |
| for i in range(20): |
| agent = AgentRegistry( |
| id=str(uuid.uuid4()), |
| name=f"Agent{i}", |
| category="test", |
| module_path="test.module", |
| class_name="TestAgent", |
| status=AgentStatus.INTERN.value, |
| confidence_score=0.6, |
| ) |
| db_session.add(agent) |
| db_session.commit() |
|
|
| errors = [] |
| successful_reads = [0] |
|
|
| def read_and_close(): |
| """Read from database and close connection.""" |
| local_db = SessionLocal() |
| try: |
| |
| for _ in range(5): |
| agents = local_db.query(AgentRegistry).limit(5).all() |
| successful_reads[0] += len(agents) |
| local_db.commit() |
| except Exception as e: |
| errors.append(e) |
| finally: |
| local_db.close() |
|
|
| |
| threads = [] |
| for _ in range(20): |
| thread = threading.Thread(target=read_and_close) |
| threads.append(thread) |
| thread.start() |
|
|
| for thread in threads: |
| thread.join() |
|
|
| |
| assert len(errors) == 0, f"Connection pool errors: {errors}" |
|
|
| |
| assert successful_reads[0] > 0, "Should have completed reads" |
|
|
|
|
| class TestPostgreSQLBehaviorDocumentation: |
| """Document expected PostgreSQL behavior for production.""" |
|
|
| def test_select_for_update_pattern(self, db_session: Session): |
| """ |
| DOCUMENTATION: SELECT FOR UPDATE for pessimistic locking. |
| |
| Documents the pattern for row-level locking in PostgreSQL. |
| SQLite doesn't support SELECT FOR UPDATE, but PostgreSQL does. |
| |
| PostgreSQL Pattern: |
| with get_db_session() as db: |
| # Lock the row for update |
| agent = db.query(AgentRegistry).filter( |
| AgentRegistry.id == agent_id |
| ).with_for_update().first() |
| |
| # Update while holding lock |
| agent.confidence_score = 0.8 |
| db.commit() |
| |
| This prevents race conditions when updating the same row |
| from multiple transactions. |
| |
| SQLite Behavior: |
| - Entire database locked during write |
| - SELECT FOR UPDATE not supported (ignored) |
| - Serialization happens at DB level, not row level |
| """ |
| |
| |
| agent = AgentRegistry( |
| id=str(uuid.uuid4()), |
| name="TestAgent", |
| category="test", |
| module_path="test.module", |
| class_name="TestAgent", |
| status=AgentStatus.INTERN.value, |
| confidence_score=0.6, |
| ) |
| db_session.add(agent) |
| db_session.commit() |
|
|
| |
| result = db_session.query(AgentRegistry).filter( |
| AgentRegistry.id == agent.id |
| ).with_for_update().first() |
|
|
| assert result is not None, "Should find agent" |
| |
|
|
| def test_deadlock_detection_note(self, db_session: Session): |
| """ |
| DOCUMENTATION: PostgreSQL deadlock detection. |
| |
| Notes that PostgreSQL automatically detects deadlocks and |
| rolls back one of the transactions. SQLite has limited |
| deadlock detection due to serialized writes. |
| |
| PostgreSQL Deadlock Behavior: |
| - Automatically detects circular wait conditions |
| - Rolls back one transaction (returns error) |
| - Application should retry the transaction |
| |
| Retry Pattern: |
| max_retries = 3 |
| for attempt in range(max_retries): |
| try: |
| # Database operation |
| db.commit() |
| break |
| except OperationalError as e: |
| if "deadlock" in str(e).lower(): |
| db.rollback() |
| if attempt < max_retries - 1: |
| time.sleep(0.1 * (attempt + 1)) |
| continue |
| raise |
| |
| SQLite Behavior: |
| - Rare true deadlocks (due to serialization) |
| - Lock timeouts more common than deadlocks |
| - Error: "database is locked" (not "deadlock") |
| """ |
| |
| |
|
|
| retry_count = [0] |
| max_retries = 3 |
|
|
| def operation_with_retry(): |
| """Example retry pattern.""" |
| for attempt in range(max_retries): |
| try: |
| |
| retry_count[0] = attempt + 1 |
| return True |
| except Exception as e: |
| if "deadlock" in str(e).lower() and attempt < max_retries - 1: |
| time.sleep(0.1 * (attempt + 1)) |
| continue |
| raise |
|
|
| result = operation_with_retry() |
| assert result is True |
| assert retry_count[0] == 1 |
|
|