Skip to content

Commit d3fa283

Browse files
committed
Harden SQLite against background sync locks
1 parent 4e79d46 commit d3fa283

2 files changed

Lines changed: 42 additions & 6 deletions

File tree

inboxanchor/infra/database.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import os
4+
import sqlite3
45
import tempfile
56
from contextlib import contextmanager
67
from datetime import datetime, timezone
@@ -18,6 +19,7 @@
1819
Text,
1920
UniqueConstraint,
2021
create_engine,
22+
event,
2123
)
2224
from sqlalchemy.orm import (
2325
DeclarativeBase,
@@ -390,19 +392,46 @@ def _resolve_database_url(raw_url: str) -> str:
390392
return f"sqlite:///{candidate}"
391393

392394

395+
def _is_sqlite_url(url: str) -> bool:
396+
return url.startswith("sqlite:///")
397+
398+
399+
def _sqlite_connect_args() -> dict:
400+
return {
401+
"check_same_thread": False,
402+
"timeout": 30,
403+
}
404+
405+
393406
RESOLVED_DATABASE_URL = _resolve_database_url(SETTINGS.database_url)
394407
engine = create_engine(
395408
RESOLVED_DATABASE_URL,
396409
future=True,
397-
connect_args=(
398-
{"check_same_thread": False}
399-
if RESOLVED_DATABASE_URL.startswith("sqlite:///")
400-
else {}
401-
),
410+
connect_args=_sqlite_connect_args() if _is_sqlite_url(RESOLVED_DATABASE_URL) else {},
402411
)
403412
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False, class_=Session)
404413

405414

415+
if _is_sqlite_url(RESOLVED_DATABASE_URL):
416+
417+
@event.listens_for(engine, "connect")
418+
def _configure_sqlite_connection(
419+
dbapi_connection,
420+
connection_record,
421+
): # pragma: no cover - event hook
422+
del connection_record
423+
if not isinstance(dbapi_connection, sqlite3.Connection):
424+
return
425+
cursor = dbapi_connection.cursor()
426+
try:
427+
cursor.execute("PRAGMA journal_mode=WAL;")
428+
cursor.execute("PRAGMA synchronous=NORMAL;")
429+
cursor.execute("PRAGMA busy_timeout=30000;")
430+
cursor.execute("PRAGMA foreign_keys=ON;")
431+
finally:
432+
cursor.close()
433+
434+
406435
def init_db() -> None:
407436
Base.metadata.create_all(bind=engine)
408437

tests/test_database.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from pathlib import Path
33

44
from inboxanchor.bootstrap import build_demo_emails
5-
from inboxanchor.infra.database import _resolve_database_url, session_scope
5+
from inboxanchor.infra.database import _resolve_database_url, _sqlite_connect_args, session_scope
66
from inboxanchor.infra.repository import InboxRepository
77

88

@@ -24,6 +24,13 @@ def test_absolute_sqlite_url_is_preserved_when_parent_is_writable(tmp_path):
2424
assert Path(candidate.parent).exists()
2525

2626

27+
def test_sqlite_connect_args_enable_busy_timeout_and_cross_thread_access():
28+
connect_args = _sqlite_connect_args()
29+
30+
assert connect_args["check_same_thread"] is False
31+
assert connect_args["timeout"] == 30
32+
33+
2734
def test_mailbox_cache_preserves_existing_full_body_on_metadata_refresh():
2835
seed = build_demo_emails()[0].model_copy(
2936
update={

0 commit comments

Comments
 (0)