From 8fab40eb42ec08d2832286bc5943ca680eb8a728 Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira Date: Thu, 29 Oct 2026 17:12:14 +0000 Subject: [PATCH] test: keep worker collection databases consistent across threads --- tests/TESTING_STANDARD.md | 6 ++++-- tests/helpers/worker_runtime.py | 5 +++++ tests/test_worker_runtime.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/tests/TESTING_STANDARD.md b/tests/TESTING_STANDARD.md index fd8c944e4..cb0eb71de 100644 --- a/tests/TESTING_STANDARD.md +++ b/tests/TESTING_STANDARD.md @@ -99,8 +99,10 @@ cleanup. Specifically: `os.environ[...] = ...` that outlives the test. - **Current working directory** - never `chdir` without restoring; never assert against cwd-relative paths like `./data`. Use a temp workspace helper instead. -- **Database** - the root `conftest.py` defaults `DATABASE_URL` to an in-memory - SQLite for collection safety. A test that needs a real file-backed DB must opt +- **Database** - the root `conftest.py` defaults `DATABASE_URL` to a private, + 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 `SessionLocal` onto the module under test. Do not rely on a persistent on-disk DB existing. diff --git a/tests/helpers/worker_runtime.py b/tests/helpers/worker_runtime.py index 0e8b36138..fd912180c 100644 --- a/tests/helpers/worker_runtime.py +++ b/tests/helpers/worker_runtime.py @@ -60,6 +60,11 @@ def isolated_runtime(worker="main"): path.mkdir(mode=0o700) patcher.setenv(name, str(path)) (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. patcher.delenv("AGENT_BROWSER_SOCKET_DIR", raising=False) tmp = root / "tmp" diff --git a/tests/test_worker_runtime.py b/tests/test_worker_runtime.py index a91157902..99c88cfdf 100644 --- a/tests/test_worker_runtime.py +++ b/tests/test_worker_runtime.py @@ -34,6 +34,7 @@ def test_runtime_restores_environment_and_removes_files(monkeypatch, tmp_path, f path = Path(os.environ[name]) assert path.is_dir() and path.is_relative_to(root) (path / "owned").write_text("test") + assert os.environ["DATABASE_URL"] == f"sqlite:///{root / 'collection.db'}" assert Path(tempfile.gettempdir()).is_relative_to(root) if fail: 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- basetemp made it 110 bytes under xdist. with socket.socket(socket.AF_UNIX) as 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)