diff --git a/features/steps/repositories_coverage_r2_steps.py b/features/steps/repositories_coverage_r2_steps.py index 149f8f014..296bd933f 100644 --- a/features/steps/repositories_coverage_r2_steps.py +++ b/features/steps/repositories_coverage_r2_steps.py @@ -18,7 +18,7 @@ from behave import given, then, when from behave.runner import Context from sqlalchemy import create_engine from sqlalchemy.exc import OperationalError -from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.orm import Session, scoped_session, sessionmaker from cleveragents.core.exceptions import DatabaseError from cleveragents.domain.models.core.checkpoint import Checkpoint, CheckpointMetadata @@ -234,7 +234,20 @@ def step_fresh_db(context: Context) -> None: engine = create_engine("sqlite:///:memory:", echo=False) Base.metadata.create_all(engine) context.r2_engine = engine - context.r2_session_factory = sessionmaker(bind=engine) + # Use scoped_session so that every ``factory()`` call within the + # same thread returns the *same* Session instance. With plain + # ``sessionmaker``, each ``factory()`` call creates a new Session. + # SQLite in-memory uses ``SingletonThreadPool`` (one connection per + # thread), so all sessions share the same connection. When a + # session created inside a repository method goes out of scope, + # Python's garbage collector may close it, issuing an implicit + # ROLLBACK on the shared connection — wiping flushed-but-uncommitted + # rows written by *other* sessions. Under high memory pressure + # (e.g. 32 parallel worker processes) GC fires often enough to + # cause intermittent data loss between ``flush()`` and ``commit()``. + # ``scoped_session`` avoids the problem entirely: one Session lives + # for the whole scenario, so there is no premature close/rollback. + context.r2_session_factory = scoped_session(sessionmaker(bind=engine)) # Pre-create repos used by multiple scenarios context.r2_skill_repo = SkillRepository(