mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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/<year>/,
|
||||
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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user