From a574f57cd8455a0a02e97b2f8ae39949e377b33b Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Mon, 19 Oct 2026 10:21:18 -0700 Subject: [PATCH] test(database): require identical WAL/NORMAL settings on managed connections (#242) Concurrent writers failed with 'database is locked' on Windows runners despite the 30 s busy timeout: every WAL commit flushed to disk while it held the write lock, so polling waiters starved. These tests pin the shared per-connection settings, including synchronous=NORMAL in WAL mode. --- tests/test_database_concurrency.py | 73 ++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/test_database_concurrency.py b/tests/test_database_concurrency.py index 4b5cedb..ecba97e 100644 --- a/tests/test_database_concurrency.py +++ b/tests/test_database_concurrency.py @@ -7,13 +7,16 @@ another thread's half-done write. """ import os +import sqlite3 import sys import tempfile import threading import unittest +from unittest.mock import patch sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from database import models from database.operations import PromptDatabase from utils.hashing import generate_prompt_hash @@ -126,5 +129,75 @@ class TestConcurrentWrites(ConcurrencyTestCase): self.assertEqual(prompt["run_count"], THREADS * CALLS_PER_THREAD) +def connection_settings(conn): + return tuple( + conn.execute(f"PRAGMA {name}").fetchone()[0] + for name in ("journal_mode", "synchronous", "busy_timeout", "foreign_keys") + ) + + +# WAL journal, synchronous=NORMAL (1), the shared busy timeout, foreign keys on. +MANAGED_SETTINGS = ("wal", 1, models.BUSY_TIMEOUT_SECONDS * 1000, 1) + + +class TestConnectionConfiguration(ConcurrencyTestCase): + """Every managed connection shares one locking and durability setup. + + With synchronous=FULL each WAL commit waits for a disk flush while it holds + SQLite's single write lock. On slow disks (Windows runners) the other + writers' polling busy handlers then starve past the busy timeout and fail + with "database is locked" (#242). In WAL mode synchronous=NORMAL keeps the + database consistent after an application crash and only flushes at + checkpoints, so commits stop holding the lock across a flush. + """ + + def test_every_thread_connection_uses_the_managed_settings(self): + seen = [] + + def grab(_index): + seen.append(connection_settings(self.db.model.get_connection())) + + self.assertEqual(self._run_threads(grab), []) + seen.append(connection_settings(self.db.model.get_connection())) + self.db.close_all() + seen.append(connection_settings(self.db.model.get_connection())) + self.assertEqual(seen, [MANAGED_SETTINGS] * (THREADS + 2)) + + def test_schema_connection_uses_the_managed_settings(self): + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + seen = [] + create_tables = models.PromptModel._create_tables + + def record(model, conn): + seen.append(connection_settings(conn)) + return create_tables(model, conn) + + fresh = None + try: + with patch.object( + models.PromptModel, "_create_tables", autospec=True, side_effect=record + ): + fresh = PromptDatabase(path) + finally: + if fresh is not None: + fresh.close_all() + for suffix in ("", "-wal", "-shm"): + if os.path.exists(path + suffix): + os.unlink(path + suffix) + self.assertEqual(seen, [MANAGED_SETTINGS]) + + def test_normal_sync_is_only_used_with_a_wal_journal(self): + # An in-memory database cannot use WAL; keep SQLite's FULL default there. + conn = sqlite3.connect(":memory:") + try: + models.configure_connection(conn) + self.assertEqual( + connection_settings(conn), ("memory", 2, *MANAGED_SETTINGS[2:]) + ) + finally: + conn.close() + + if __name__ == "__main__": unittest.main()