From 62e19ea729e3a4722f3afd6f5f7a1aeac154704c Mon Sep 17 00:00:00 2001 From: Renn F Date: Tue, 14 Jul 2026 05:52:20 +0200 Subject: [PATCH] =?UTF-8?q?test(e2e):=20vault=20V2=20=E2=80=94=20private?= =?UTF-8?q?=20engine,=20no=20shared=20=5FDbHolder=20(kill=20cross-loop=20f?= =?UTF-8?q?lake)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The push-event e2e smoke flaked ~1/50 with ``RuntimeError: Future ... attached to a different loop`` in test_create_seam_materializes_note_flag_on_and_off (and the janitor test shares the same helper). Root cause: _fresh_factory returned the app's SHARED get_session_factory() (_DbHolder engine), so the test's session shared a connection pool with the uvicorn server thread (loop B). A lingering app handler from a prior test could check out a connection on loop B; asyncpg's pool is not loop-affinity-aware, so it then handed the vault test a connection created on loop B, awaited on the test's function-scoped loop A → cross-loop. _reset_lazy_db_holder only resets at teardown, so it can't stop a lingering handler contaminating the fresh pool mid-test. Fix: _fresh_factory builds a PRIVATE engine from e2e_stack.db_url and returns (factory, engine); the caller disposes it in finally. The create/janitor seams use only the passed session (assemble_task_note_data, get_project_service, VaultJanitor never call get_session_factory), so a private engine against the same e2e DB exercises the real wiring while keeping its pool loop-pure — the app can't reach it. This is the e2e-suite cross-loop flake that was blocking PR #516's push-event e2e check (the pull_request run passed, the push run hit this unrelated vault test). Pre-existing; not introduced by the auditor fix. --- tests/e2e_smoke/test_vault_v2.py | 38 +++++++++++++++++++------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/tests/e2e_smoke/test_vault_v2.py b/tests/e2e_smoke/test_vault_v2.py index 620677f3..907ca3e6 100644 --- a/tests/e2e_smoke/test_vault_v2.py +++ b/tests/e2e_smoke/test_vault_v2.py @@ -47,7 +47,7 @@ from roboco.services.vault_kb_engine import VaultKBEngine if TYPE_CHECKING: from pathlib import Path - from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from tests.e2e_smoke.harness import E2EStack _PROJECT_SLUG = "vault-e2e-proj" @@ -116,13 +116,25 @@ def _arm_vault(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: return vault -def _fresh_factory() -> async_sessionmaker[AsyncSession]: - """The app's lazy factory, rebound to THIS test's loop (M1 posture).""" - from roboco.db import base as db_base +def _fresh_factory( + db_url: str, +) -> tuple[async_sessionmaker[AsyncSession], AsyncEngine]: + """A PRIVATE engine bound to THIS test's loop — not the app's shared _DbHolder. - db_base._DbHolder.engine = None - db_base._DbHolder.session_factory = None - return db_base.get_session_factory() + The create/janitor seams use only the passed session (``assemble_task_note_data`` + / ``get_project_service`` / ``VaultJanitor`` never call ``get_session_factory``), + so a private engine against the same e2e DB exercises the real wiring without + sharing a connection pool with the uvicorn server thread. A lingering app + handler on the server's loop checking out a connection from a shared engine + is what flaked this suite ~1/50 in CI: asyncpg's pool then handed a following + test a connection created on the server's loop, awaited on the test's loop → + ``RuntimeError: Future ... attached to a different loop``. A private engine + the app can't reach keeps its pool loop-pure. Caller disposes the engine. + """ + from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + + engine = create_async_engine(db_url) + return async_sessionmaker(engine, expire_on_commit=False), engine # --------------------------------------------------------------------------- @@ -139,10 +151,8 @@ async def test_create_seam_materializes_note_flag_on_and_off( """Real ``TaskService.create`` against the e2e DB: with the vault armed the note appears (status frontmatter + placeholder narrative); with the flag off a second create writes nothing new.""" - from roboco.db import base as db_base - vault = _arm_vault(monkeypatch, tmp_path) - factory = _fresh_factory() + factory, engine = _fresh_factory(e2e_stack.db_url) try: async with factory() as session: created_by = await _seed_system_agent(session) @@ -168,7 +178,7 @@ async def test_create_seam_materializes_note_flag_on_and_off( await session.commit() assert len(list(vault.rglob("*.md"))) == 1 # nothing new finally: - await db_base.close_db() + await engine.dispose() # --------------------------------------------------------------------------- @@ -226,12 +236,10 @@ async def test_janitor_cycle_reprojects_archives_and_persists_state( re-projects into Tasks/, the old terminal one lands in Archive//, the state file carries last_sweep + archive_watermark, and the returned counts match what was actually done. No vault code is mocked.""" - from roboco.db import base as db_base - vault = _arm_vault(monkeypatch, tmp_path) monkeypatch.setattr(settings, "vault_archive_days", 30) monkeypatch.setattr(settings, "vault_report_enabled", False) - factory = _fresh_factory() + factory, engine = _fresh_factory(e2e_stack.db_url) try: async with factory() as session: created_by = await _seed_system_agent(session) @@ -266,7 +274,7 @@ async def test_janitor_cycle_reprojects_archives_and_persists_state( assert datetime.fromisoformat(state["last_sweep"]).tzinfo is not None assert datetime.fromisoformat(state["archive_watermark"]).tzinfo is not None finally: - await db_base.close_db() + await engine.dispose() # ---------------------------------------------------------------------------