-
Notifications
You must be signed in to change notification settings - Fork 145
fix(db): enable SQLite WAL mode and busy timeout to resolve database lock contention #69
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
773e44a
36638df
93bae45
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,8 @@ __pycache__/ | |
| htmlcov/ | ||
| *.db | ||
| *.db-journal | ||
| *.db-wal | ||
| *.db-shm | ||
| .env | ||
| .venv/ | ||
| venv/ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| """Failing test demonstrating Issue #3: SQLite Database Lock Contention under concurrent write traffic. | ||
|
|
||
| When running SQLite with default engine settings (without WAL journal_mode and without connection busy timeouts), | ||
| concurrent write transactions (such as AuthMiddleware key updates and RequestLog writes) contend for the | ||
| exclusive SQLite database lock, causing `sqlite3.OperationalError: database is locked`. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import os | ||
| import tempfile | ||
| from datetime import datetime, timezone | ||
|
|
||
| import pytest | ||
| from sqlalchemy import select | ||
| from sqlalchemy.ext.asyncio import async_sessionmaker | ||
|
|
||
| from packages.auth.hashing import hash_api_key | ||
| from packages.db.engine import build_engine | ||
| from packages.db.models.api_key import ApiKey | ||
| from packages.db.models.base import Base | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_sqlite_lock_contention_under_concurrent_writes(): | ||
| """Failing test demonstrating Issue #3: | ||
| Under concurrent write transactions (e.g. auth key last_used updates and request logging), | ||
| the default SQLite engine configuration (DELETE journal mode, no WAL mode or timeout tuning) | ||
| results in database lock errors. | ||
| """ | ||
| fd, db_path = tempfile.mkstemp(suffix=".db") | ||
| os.close(fd) | ||
| db_url = f"sqlite+aiosqlite:///{db_path}" | ||
|
|
||
| try: | ||
| # Build engine using packages.db.engine.build_engine (with WAL mode and busy_timeout=30000) | ||
| engine = build_engine(db_url) | ||
|
|
||
| async with engine.begin() as conn: | ||
| await conn.run_sync(Base.metadata.create_all) | ||
|
|
||
| factory = async_sessionmaker(engine, expire_on_commit=False) | ||
|
|
||
| raw_key = "sk-orca-test123456789012345678901234" | ||
| key_hash = hash_api_key(raw_key) | ||
|
|
||
| async with factory() as s: | ||
| s.add( | ||
| ApiKey( | ||
| id="key_concurrency_test", | ||
| workspace_id="default", | ||
| name="test-key", | ||
| key_hash=key_hash, | ||
| key_prefix="sk-orca-....1234", | ||
| is_active=True, | ||
| ) | ||
| ) | ||
| await s.commit() | ||
|
|
||
| # Verify PRAGMA journal_mode is WAL | ||
| async with engine.connect() as conn: | ||
| from sqlalchemy import text | ||
| mode = (await conn.execute(text("PRAGMA journal_mode;"))).scalar() | ||
| assert str(mode).lower() == "wal", f"Expected WAL journal mode, got {mode}" | ||
|
|
||
| # Simulate 50 concurrent write operations running simultaneously | ||
| async def _concurrent_write_operation(op_id: int): | ||
| for attempt in range(5): | ||
| try: | ||
| async with factory() as session: | ||
| stmt = select(ApiKey).where(ApiKey.id == "key_concurrency_test") | ||
| res = await session.execute(stmt) | ||
| row = res.scalar_one_or_none() | ||
| assert row is not None | ||
| row.last_used_at = datetime.now(timezone.utc) | ||
| await session.commit() | ||
| return | ||
| except Exception as e: | ||
| if attempt == 4: | ||
| raise e | ||
| await asyncio.sleep(0.01 * (attempt + 1)) | ||
|
|
||
| results = await asyncio.gather( | ||
| *[_concurrent_write_operation(i) for i in range(50)], | ||
| return_exceptions=True, | ||
| ) | ||
|
|
||
| errors = [r for r in results if isinstance(r, Exception)] | ||
|
|
||
| assert len(errors) == 0, ( | ||
| f"Expected 0 database lock errors under concurrent load, but encountered {len(errors)} failures! " | ||
| f"First error: {errors[0] if errors else 'None'}" | ||
|
JhaSourav07 marked this conversation as resolved.
|
||
| ) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ⚪ P3 Retry-all loop lets the regression test pass even when WAL/busy_timeout are broken
|
||
|
|
||
| await engine.dispose() | ||
| finally: | ||
| try: | ||
| os.unlink(db_path) | ||
| except OSError: | ||
| pass | ||
| wal_path = f"{db_path}-wal" | ||
| shm_path = f"{db_path}-shm" | ||
| for extra in (wal_path, shm_path): | ||
| if os.path.exists(extra): | ||
| try: | ||
| os.unlink(extra) | ||
| except OSError: | ||
| pass | ||
Uh oh!
There was an error while loading. Please reload this page.