test(e2e): vault V2 — private engine, no shared _DbHolder (kill cross-loop flake)

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.
This commit is contained in:
Renn F
2026-07-14 06:14:27 +02:00
committed by Renzo F
parent 1c63c88cbf
commit 62e19ea729
+23 -15
View File
@@ -47,7 +47,7 @@ from roboco.services.vault_kb_engine import VaultKBEngine
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path 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 from tests.e2e_smoke.harness import E2EStack
_PROJECT_SLUG = "vault-e2e-proj" _PROJECT_SLUG = "vault-e2e-proj"
@@ -116,13 +116,25 @@ def _arm_vault(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
return vault return vault
def _fresh_factory() -> async_sessionmaker[AsyncSession]: def _fresh_factory(
"""The app's lazy factory, rebound to THIS test's loop (M1 posture).""" db_url: str,
from roboco.db import base as db_base ) -> 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 The create/janitor seams use only the passed session (``assemble_task_note_data``
db_base._DbHolder.session_factory = None / ``get_project_service`` / ``VaultJanitor`` never call ``get_session_factory``),
return db_base.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 """Real ``TaskService.create`` against the e2e DB: with the vault armed
the note appears (status frontmatter + placeholder narrative); with the the note appears (status frontmatter + placeholder narrative); with the
flag off a second create writes nothing new.""" flag off a second create writes nothing new."""
from roboco.db import base as db_base
vault = _arm_vault(monkeypatch, tmp_path) vault = _arm_vault(monkeypatch, tmp_path)
factory = _fresh_factory() factory, engine = _fresh_factory(e2e_stack.db_url)
try: try:
async with factory() as session: async with factory() as session:
created_by = await _seed_system_agent(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() await session.commit()
assert len(list(vault.rglob("*.md"))) == 1 # nothing new assert len(list(vault.rglob("*.md"))) == 1 # nothing new
finally: 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/<year>/, re-projects into Tasks/, the old terminal one lands in Archive/<year>/,
the state file carries last_sweep + archive_watermark, and the returned the state file carries last_sweep + archive_watermark, and the returned
counts match what was actually done. No vault code is mocked.""" 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) vault = _arm_vault(monkeypatch, tmp_path)
monkeypatch.setattr(settings, "vault_archive_days", 30) monkeypatch.setattr(settings, "vault_archive_days", 30)
monkeypatch.setattr(settings, "vault_report_enabled", False) monkeypatch.setattr(settings, "vault_report_enabled", False)
factory = _fresh_factory() factory, engine = _fresh_factory(e2e_stack.db_url)
try: try:
async with factory() as session: async with factory() as session:
created_by = await _seed_system_agent(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["last_sweep"]).tzinfo is not None
assert datetime.fromisoformat(state["archive_watermark"]).tzinfo is not None assert datetime.fromisoformat(state["archive_watermark"]).tzinfo is not None
finally: finally:
await db_base.close_db() await engine.dispose()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------