diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 5dd3676..238999c 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -34,6 +34,7 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue. - Issue: `get_db` opens a fresh `aiosqlite` connection per request without explicit database pragmas such as `busy_timeout` or WAL mode. - Propose: Configure SQLite connections in `open_db` with safe defaults (e.g. WAL, busy timeout, journal mode) and consider a centralized request-scoped access wrapper to keep connection setup consistent. - Test: Confirm `open_db` applies the expected pragmas and add simulated concurrency tests that surface lock / timeout failures. + - Status: completed 5. Separate startup settings from runtime configuration mutation - Goal: Keep startup configuration immutable after bootstrap and handle runtime overrides through a dedicated manager. diff --git a/backend/app/db.py b/backend/app/db.py index f2df526..1549c38 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -106,6 +106,13 @@ _SCHEMA_STATEMENTS: list[str] = [ # --------------------------------------------------------------------------- +async def _configure_connection(db: aiosqlite.Connection) -> None: + """Apply hardening pragmas to a newly-opened SQLite connection.""" + await db.execute("PRAGMA journal_mode=WAL;") + await db.execute("PRAGMA foreign_keys=ON;") + await db.execute("PRAGMA busy_timeout=5000;") + + async def init_db(db: aiosqlite.Connection) -> None: """Create all BanGUI application tables if they do not already exist. @@ -117,10 +124,7 @@ async def init_db(db: aiosqlite.Connection) -> None: db: An open :class:`aiosqlite.Connection` to the application database. """ log.info("initialising_database_schema") - async with db.execute("PRAGMA journal_mode=WAL;"): - pass - async with db.execute("PRAGMA foreign_keys=ON;"): - pass + await _configure_connection(db) for statement in _SCHEMA_STATEMENTS: await db.executescript(statement) await db.commit() @@ -138,5 +142,5 @@ async def open_db(database_path: str) -> aiosqlite.Connection: """ db = await aiosqlite.connect(database_path) db.row_factory = aiosqlite.Row - await db.execute("PRAGMA foreign_keys=ON;") + await _configure_connection(db) return db diff --git a/backend/tests/test_db.py b/backend/tests/test_db.py new file mode 100644 index 0000000..b125cb7 --- /dev/null +++ b/backend/tests/test_db.py @@ -0,0 +1,58 @@ +import asyncio +from pathlib import Path + +import aiosqlite + +from app.db import open_db + + +async def test_open_db_applies_hardening_pragmas(tmp_path: Path) -> None: + database_path = str(tmp_path / "bangui_test.db") + db = await open_db(database_path) + try: + async with db.execute("PRAGMA journal_mode;") as cursor: + row = await cursor.fetchone() + assert row is not None and row[0].lower() == "wal" + + async with db.execute("PRAGMA foreign_keys;") as cursor: + row = await cursor.fetchone() + assert row is not None and row[0] == 1 + + async with db.execute("PRAGMA busy_timeout;") as cursor: + row = await cursor.fetchone() + assert row is not None and row[0] == 5000 + finally: + await db.close() + + +async def test_open_db_respects_busy_timeout_for_concurrent_writes(tmp_path: Path) -> None: + database_path = str(tmp_path / "bangui_lock.db") + connection_a = await open_db(database_path) + try: + await connection_a.execute( + "CREATE TABLE IF NOT EXISTS test_lock (id INTEGER PRIMARY KEY, value TEXT);" + ) + await connection_a.commit() + + await connection_a.execute("BEGIN EXCLUSIVE;") + + async def write_after_lock() -> None: + connection_b = await open_db(database_path) + try: + await connection_b.execute( + "INSERT INTO test_lock (value) VALUES ('locked');" + ) + await connection_b.commit() + finally: + await connection_b.close() + + task = asyncio.create_task(write_after_lock()) + await asyncio.sleep(0.1) + await connection_a.commit() + await task + + async with connection_a.execute("SELECT value FROM test_lock;") as cursor: + row = await cursor.fetchone() + assert row is not None and row[0] == "locked" + finally: + await connection_a.close()