test: keep worker collection databases consistent across threads

This commit is contained in:
Alexandre Teixeira
2026-10-29 17:12:14 +00:00
parent 8b8fc62449
commit aa2db8d87c
3 changed files with 39 additions and 2 deletions
+4 -2
View File
@@ -99,8 +99,10 @@ cleanup. Specifically:
`os.environ[...] = ...` that outlives the test. `os.environ[...] = ...` that outlives the test.
- **Current working directory** - never `chdir` without restoring; never assert - **Current working directory** - never `chdir` without restoring; never assert
against cwd-relative paths like `./data`. Use a temp workspace helper instead. against cwd-relative paths like `./data`. Use a temp workspace helper instead.
- **Database** - the root `conftest.py` defaults `DATABASE_URL` to an in-memory - **Database** - the root `conftest.py` defaults `DATABASE_URL` to a private,
SQLite for collection safety. A test that needs a real file-backed DB must opt worker-owned SQLite file for collection safety and consistent schema across
TestClient threads. This fallback is not a per-test data fixture. A test that
writes or depends on particular rows must opt
in explicitly via `tests.helpers.sqlite_db.make_temp_sqlite` and bind its in explicitly via `tests.helpers.sqlite_db.make_temp_sqlite` and bind its
`SessionLocal` onto the module under test. Do not rely on a persistent `SessionLocal` onto the module under test. Do not rely on a persistent
on-disk DB existing. on-disk DB existing.
+5
View File
@@ -60,6 +60,11 @@ def isolated_runtime(worker="main"):
path.mkdir(mode=0o700) path.mkdir(mode=0o700)
patcher.setenv(name, str(path)) patcher.setenv(name, str(path))
(root / "data" / "agent_workspace").mkdir(mode=0o700, exist_ok=True) (root / "data" / "agent_workspace").mkdir(mode=0o700, exist_ok=True)
# SQLite's default in-memory pool gives each thread an empty DB
# and can evict the main connection as TestClient threads churn.
# A worker-owned file preserves the collection schema across real
# connections. Tests that write rows still own explicit fixtures.
patcher.setenv("DATABASE_URL", f"sqlite:///{root / 'collection.db'}")
# Browser resolution falls back to our XDG runtime directory. # Browser resolution falls back to our XDG runtime directory.
patcher.delenv("AGENT_BROWSER_SOCKET_DIR", raising=False) patcher.delenv("AGENT_BROWSER_SOCKET_DIR", raising=False)
tmp = root / "tmp" tmp = root / "tmp"
+30
View File
@@ -34,6 +34,7 @@ def test_runtime_restores_environment_and_removes_files(monkeypatch, tmp_path, f
path = Path(os.environ[name]) path = Path(os.environ[name])
assert path.is_dir() and path.is_relative_to(root) assert path.is_dir() and path.is_relative_to(root)
(path / "owned").write_text("test") (path / "owned").write_text("test")
assert os.environ["DATABASE_URL"] == f"sqlite:///{root / 'collection.db'}"
assert Path(tempfile.gettempdir()).is_relative_to(root) assert Path(tempfile.gettempdir()).is_relative_to(root)
if fail: if fail:
raise RuntimeError("test failure") raise RuntimeError("test failure")
@@ -79,3 +80,32 @@ def test_tmp_path_under_private_runtime_fits_unix_socket(tmp_path):
# witness. A nested pytest-of-<user> basetemp made it 110 bytes under xdist. # witness. A nested pytest-of-<user> basetemp made it 110 bytes under xdist.
with socket.socket(socket.AF_UNIX) as sock: with socket.socket(socket.AF_UNIX) as sock:
sock.bind(str(tmp_path / "tmux.sock")) sock.bind(str(tmp_path / "tmux.sock"))
def test_collection_database_survives_thread_connection_churn():
"""TestClient threads must not lose the collection database or its schema.
SingletonThreadPool's default five-connection limit used to close the main
thread's in-memory database after repeated route clients. Each new thread
also saw an empty database. Exercise more threads than that limit and then
verify both compatibility factories still see all registered tables.
"""
from concurrent.futures import ThreadPoolExecutor
from sqlalchemy import inspect
import core.database as database
import src.database as compatibility
expected = set(database.Base.metadata.tables)
def table_names(factory):
with factory() as session:
return set(inspect(session.connection()).get_table_names())
assert {"documents", "model_endpoints", "mcp_servers", "calendars"} <= expected
assert expected <= table_names(database.SessionLocal)
for _ in range(8):
# A fresh executor creates a fresh thread rather than reusing one.
with ThreadPoolExecutor(max_workers=1) as executor:
assert expected <= executor.submit(table_names, database.SessionLocal).result()
assert expected <= table_names(database.SessionLocal)
assert expected <= table_names(compatibility.SessionLocal)