Files
local-deep-research/tests/database/test_sqlcipher_connection_factory.py
LearningCircuit 8e11dcf729 refactor(db): remove per-thread NullPool engines to fix FD leak (#3441)
Previously DatabaseManager kept a dedicated per-(username, thread_id)
NullPool engine in `_thread_engines` for background-thread metric
writes, alongside the per-user QueuePool engine in `connections`.
Orphaned entries leaked SQLCipher+WAL file handles (3 FDs per active
connection) when @thread_cleanup did not fire, eventually exhausting
the 1024 FD soft limit and causing werkzeug's per-request selector to
fail on every request.

Route metric writes through the shared per-user QueuePool engine, which
is already created with check_same_thread=False and is safe to use
from background threads. FD usage is now bounded by
pool_size + max_overflow per user instead of scaling with background
thread count.

Also:
- Bump pool_size=20, max_overflow=40, add pool_timeout=10 to absorb
  concurrent research + HTTP + metric writers against the shared pool.
- Add pool_checked_out observability to the periodic Resource monitor.
- Delete ~200 lines of thread-engine bookkeeping:
  cleanup_thread_engines, cleanup_dead_thread_engines,
  maybe_sweep_dead_engines, cleanup_all_thread_engines, _sweep_lock,
  _last_sweep_time, _thread_engine_lock, _thread_engines.
- Force QueuePool on the SQLCipher integration-test fixture so
  concurrent-write tests exercise real pooling (not StaticPool).
- Update docs/architecture.md and web/database/README.md.

Known follow-up: parallel_constrained_strategy.py uses max_workers=100
which could spike pool pressure under worst-case load; sessions are
short-lived so sustained contention is unlikely, and pool_timeout=10
will surface it as errors rather than deadlock.

1996 passed, 8 skipped across tests/database and tests/web/auth.
2026-04-12 20:02:22 +02:00

344 lines
14 KiB
Python

"""Tests for _make_sqlcipher_connection() factory method.
Verifies:
- Factory method creates connections with correct parameters
- Correct call order: key → pragmas → verify → performance
- Parameters are passed through correctly
- Failed verification raises ValueError
- Metrics path preserves error logging
"""
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from local_deep_research.database.encrypted_db import DatabaseManager
# Module path prefix for patching
_EDB = "local_deep_research.database.encrypted_db"
@pytest.fixture
def manager():
"""Create a DatabaseManager with encryption disabled for testing."""
with patch(f"{_EDB}.get_data_directory") as mock_dir:
mock_dir.return_value = Path("/tmp/test")
with patch.object(
DatabaseManager, "_check_encryption_available", return_value=False
):
with patch.dict("os.environ", {"LDR_ALLOW_UNENCRYPTED": "true"}):
return DatabaseManager()
@pytest.fixture
def mock_sqlcipher():
"""Mock all SQLCipher dependencies for _make_sqlcipher_connection."""
mock_conn = MagicMock()
mock_cursor = MagicMock()
mock_conn.cursor.return_value = mock_cursor
mock_module = MagicMock()
mock_module.connect.return_value = mock_conn
with (
patch(
f"{_EDB}.get_sqlcipher_module", return_value=mock_module
) as mock_get,
patch(f"{_EDB}.set_sqlcipher_key") as mock_set_key,
patch(
f"{_EDB}.verify_sqlcipher_connection", return_value=True
) as mock_verify,
patch(f"{_EDB}.apply_sqlcipher_pragmas") as mock_pragmas,
patch(f"{_EDB}.apply_performance_pragmas") as mock_perf,
):
yield {
"module": mock_module,
"conn": mock_conn,
"cursor": mock_cursor,
"get_module": mock_get,
"set_key": mock_set_key,
"verify": mock_verify,
"pragmas": mock_pragmas,
"performance": mock_perf,
}
class TestMakeSqlcipherConnection:
"""Verify _make_sqlcipher_connection creates connections correctly."""
def test_returns_connection(self, manager, mock_sqlcipher):
"""Factory should return the connection object from sqlcipher3.connect."""
result = manager._make_sqlcipher_connection(
Path("/tmp/test.db"), "secret"
)
assert result is mock_sqlcipher["conn"]
def test_connect_with_default_params(self, manager, mock_sqlcipher):
"""Default params: isolation_level=IMMEDIATE, check_same_thread=False."""
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["module"].connect.assert_called_once_with(
"/tmp/test.db",
isolation_level="IMMEDIATE",
check_same_thread=False,
)
def test_connect_with_custom_isolation_level(self, manager, mock_sqlcipher):
"""Metrics path passes isolation_level='' for deferred transactions."""
manager._make_sqlcipher_connection(
Path("/tmp/test.db"), "secret", isolation_level=""
)
mock_sqlcipher["module"].connect.assert_called_once_with(
"/tmp/test.db",
isolation_level="",
check_same_thread=False,
)
def test_connect_with_check_same_thread(self, manager, mock_sqlcipher):
"""check_same_thread should be forwarded to sqlcipher3.connect."""
manager._make_sqlcipher_connection(
Path("/tmp/test.db"), "secret", check_same_thread=True
)
mock_sqlcipher["module"].connect.assert_called_once_with(
"/tmp/test.db",
isolation_level="IMMEDIATE",
check_same_thread=True,
)
def test_sets_sqlcipher_key(self, manager, mock_sqlcipher):
"""Must call set_sqlcipher_key with cursor, password, and db_path."""
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "my_pass")
mock_sqlcipher["set_key"].assert_called_once_with(
mock_sqlcipher["cursor"], "my_pass", db_path=Path("/tmp/test.db")
)
def test_verifies_connection(self, manager, mock_sqlcipher):
"""Must verify connection works after setting key."""
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["verify"].assert_called_once_with(
mock_sqlcipher["cursor"]
)
def test_applies_sqlcipher_pragmas(self, manager, mock_sqlcipher):
"""Must apply SQLCipher pragmas with creation_mode=False."""
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["pragmas"].assert_called_once_with(
mock_sqlcipher["cursor"], creation_mode=False
)
def test_applies_performance_pragmas(self, manager, mock_sqlcipher):
"""Must call apply_performance_pragmas with cursor."""
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["performance"].assert_called_once_with(
mock_sqlcipher["cursor"]
)
def test_closes_cursor(self, manager, mock_sqlcipher):
"""Cursor must be closed before returning."""
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["cursor"].close.assert_called_once()
def test_call_order(self, manager, mock_sqlcipher):
"""Operations must happen in order: key → pragmas → verify → performance."""
call_order = []
mock_sqlcipher["set_key"].side_effect = lambda *a, **kw: (
call_order.append("key")
)
mock_sqlcipher["verify"].side_effect = lambda *a: (
call_order.append("verify") or True
)
mock_sqlcipher["pragmas"].side_effect = lambda *a, **kw: (
call_order.append("pragmas")
)
mock_sqlcipher["performance"].side_effect = lambda *a: (
call_order.append("performance")
)
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
assert call_order == ["key", "pragmas", "verify", "performance"]
def test_raises_on_failed_verification(self, manager, mock_sqlcipher):
"""Must raise ValueError when verification fails."""
mock_sqlcipher["verify"].return_value = False
with pytest.raises(ValueError, match="Failed to verify database key"):
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
def test_no_performance_pragmas_after_failed_verification(
self, manager, mock_sqlcipher
):
"""Cipher pragmas run before verify, but performance pragmas must not."""
mock_sqlcipher["verify"].return_value = False
with pytest.raises(ValueError):
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["pragmas"].assert_called_once_with(
mock_sqlcipher["cursor"], creation_mode=False
)
mock_sqlcipher["performance"].assert_not_called()
def test_closes_cursor_and_conn_on_verification_failure(
self, manager, mock_sqlcipher
):
"""Cursor and connection must be closed when verification fails."""
mock_sqlcipher["verify"].return_value = False
with pytest.raises(ValueError):
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["cursor"].close.assert_called_once()
mock_sqlcipher["conn"].close.assert_called_once()
def test_closes_cursor_and_conn_on_key_error(self, manager, mock_sqlcipher):
"""Cursor and connection must be closed when set_sqlcipher_key raises."""
mock_sqlcipher["set_key"].side_effect = RuntimeError("key failed")
with pytest.raises(RuntimeError):
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["cursor"].close.assert_called_once()
mock_sqlcipher["conn"].close.assert_called_once()
def test_closes_cursor_and_conn_on_pragma_error(
self, manager, mock_sqlcipher
):
"""Cursor and connection must be closed when pragma application raises."""
mock_sqlcipher["pragmas"].side_effect = RuntimeError("pragma failed")
with pytest.raises(RuntimeError):
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["cursor"].close.assert_called_once()
mock_sqlcipher["conn"].close.assert_called_once()
mock_sqlcipher["verify"].assert_not_called()
mock_sqlcipher["performance"].assert_not_called()
def test_connect_converts_path_to_string(self, manager, mock_sqlcipher):
"""Path objects must be converted to str for sqlcipher3.connect."""
manager._make_sqlcipher_connection(Path("/some/dir/test.db"), "secret")
call_args = mock_sqlcipher["module"].connect.call_args
assert call_args[0][0] == "/some/dir/test.db"
assert isinstance(call_args[0][0], str)
def test_connect_with_none_isolation_level(self, manager, mock_sqlcipher):
"""isolation_level=None (autocommit) must be forwarded to connect."""
manager._make_sqlcipher_connection(
Path("/tmp/test.db"), "secret", isolation_level=None
)
mock_sqlcipher["module"].connect.assert_called_once_with(
"/tmp/test.db",
isolation_level=None,
check_same_thread=False,
)
def test_conn_not_closed_on_success(self, manager, mock_sqlcipher):
"""On success, cursor is closed but connection must remain open."""
result = manager._make_sqlcipher_connection(
Path("/tmp/test.db"), "secret"
)
mock_sqlcipher["cursor"].close.assert_called_once()
mock_sqlcipher["conn"].close.assert_not_called()
assert result is mock_sqlcipher["conn"]
def test_key_error_prevents_further_calls(self, manager, mock_sqlcipher):
"""If set_sqlcipher_key fails, no pragmas or verify should run."""
mock_sqlcipher["set_key"].side_effect = RuntimeError("bad key")
with pytest.raises(RuntimeError):
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["pragmas"].assert_not_called()
mock_sqlcipher["verify"].assert_not_called()
mock_sqlcipher["performance"].assert_not_called()
def test_original_exception_propagated(self, manager, mock_sqlcipher):
"""The original exception must propagate without wrapping."""
original = RuntimeError("specific error")
mock_sqlcipher["pragmas"].side_effect = original
with pytest.raises(RuntimeError) as exc_info:
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
assert exc_info.value is original
def test_conn_closed_even_if_cursor_close_raises(
self, manager, mock_sqlcipher
):
"""Connection must close even when cursor.close() raises during cleanup."""
mock_sqlcipher["verify"].return_value = False
mock_sqlcipher["cursor"].close.side_effect = RuntimeError(
"cursor close failed"
)
with pytest.raises(ValueError, match="Failed to verify database key"):
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["conn"].close.assert_called_once()
def test_import_error_propagates(self, manager):
"""ImportError from get_sqlcipher_module() must propagate cleanly."""
with patch(
f"{_EDB}.get_sqlcipher_module",
side_effect=ImportError("no sqlcipher3"),
):
with pytest.raises(ImportError, match="no sqlcipher3"):
manager._make_sqlcipher_connection(
Path("/tmp/test.db"), "secret"
)
def test_connect_failure_propagates(self, manager, mock_sqlcipher):
"""Exceptions from sqlcipher3.connect() must propagate without cleanup attempts."""
mock_sqlcipher["module"].connect.side_effect = OSError("disk full")
with pytest.raises(OSError, match="disk full"):
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["set_key"].assert_not_called()
def test_closes_cursor_and_conn_on_performance_pragma_error(
self, manager, mock_sqlcipher
):
"""Cursor and connection must be closed when apply_performance_pragmas raises."""
mock_sqlcipher["performance"].side_effect = RuntimeError(
"perf pragma failed"
)
with pytest.raises(RuntimeError, match="perf pragma failed"):
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["cursor"].close.assert_called_once()
mock_sqlcipher["conn"].close.assert_called_once()
mock_sqlcipher["verify"].assert_called_once()
def test_closes_cursor_and_conn_on_verify_exception(
self, manager, mock_sqlcipher
):
"""Cleanup must happen when verify_sqlcipher_connection raises (not just returns False)."""
mock_sqlcipher["verify"].side_effect = RuntimeError("verify crashed")
with pytest.raises(RuntimeError, match="verify crashed"):
manager._make_sqlcipher_connection(Path("/tmp/test.db"), "secret")
mock_sqlcipher["cursor"].close.assert_called_once()
mock_sqlcipher["conn"].close.assert_called_once()
mock_sqlcipher["performance"].assert_not_called()
# NOTE: The former TestMetricsThreadConnectionLogging tests exercised
# the inline SQLCipher creator inside create_thread_safe_session_for_metrics.
# That method now delegates to open_user_database(), so connection-failure
# logging is covered by the open_user_database tests instead.