@@ -57,7 +57,12 @@ async def test_my_mcp_tool(mcp_server, app):
5757import pytest_asyncio
5858from pathlib import Path
5959from sqlalchemy import text
60- from sqlalchemy .ext .asyncio import AsyncSession , async_sessionmaker , create_async_engine
60+ from sqlalchemy .ext .asyncio import (
61+ AsyncEngine ,
62+ AsyncSession ,
63+ async_sessionmaker ,
64+ create_async_engine ,
65+ )
6166from sqlalchemy .pool import NullPool
6267from testcontainers .postgres import PostgresContainer
6368
@@ -118,6 +123,21 @@ def postgres_container(db_backend):
118123 yield postgres
119124
120125
126+ @pytest_asyncio .fixture (autouse = True )
127+ async def cleanup_global_db_after_test () -> AsyncGenerator [None , None ]:
128+ """Close any module-level DB engine created outside fixture ownership."""
129+ yield
130+
131+ # Trigger: integration tests invoke CLI/MCP routes through the production
132+ # client fallback, bypassing this file's engine_factory fixture.
133+ # Why: those fallback engines live in basic_memory.db module state and can
134+ # otherwise leave a non-daemon aiosqlite worker alive after pytest finishes.
135+ # Outcome: every test boundary becomes a cleanup point for fallback engines.
136+ from basic_memory import db
137+
138+ await db .shutdown_db ()
139+
140+
121141POSTGRES_EPHEMERAL_TABLES = [
122142 "search_vector_embeddings" ,
123143 "search_vector_chunks" ,
@@ -182,49 +202,72 @@ async def _reset_postgres_integration_schema(engine) -> None:
182202 )
183203
184204
205+ @pytest_asyncio .fixture (scope = "session" , loop_scope = "session" )
206+ async def postgres_engine (
207+ db_backend : Literal ["sqlite" , "postgres" ], postgres_container
208+ ) -> AsyncGenerator [AsyncEngine | None , None ]:
209+ """Create the shared Postgres engine once per integration test session."""
210+ if db_backend != "postgres" :
211+ yield None
212+ return
213+
214+ sync_url = _resolve_postgres_sync_url (postgres_container )
215+ async_url = sync_url .replace ("postgresql+psycopg2" , "postgresql+asyncpg" )
216+ engine = create_async_engine (
217+ async_url ,
218+ echo = False ,
219+ poolclass = NullPool ,
220+ )
221+
222+ try :
223+ yield engine
224+ finally :
225+ await engine .dispose ()
226+
227+
185228@pytest_asyncio .fixture
186229async def engine_factory (
187230 app_config ,
188231 config_manager ,
189232 db_backend : Literal ["sqlite" , "postgres" ],
190233 postgres_container ,
234+ postgres_engine ,
191235 tmp_path ,
192236) -> AsyncGenerator [tuple , None ]:
193237 """Create engine and session factory for the configured database backend."""
194238 from basic_memory .models .search import CREATE_SEARCH_INDEX
195239 from basic_memory import db
196240
197241 if db_backend == "postgres" :
198- # Postgres mode using testcontainers
199- sync_url = _resolve_postgres_sync_url (postgres_container )
200- async_url = sync_url .replace ("postgresql+psycopg2" , "postgresql+asyncpg" )
242+ assert postgres_engine is not None
201243
202- engine = create_async_engine (
203- async_url ,
204- echo = False ,
205- poolclass = NullPool ,
206- )
244+ # Trigger: full-stack MCP/CLI tests exercise sync/indexing code that can
245+ # recover from DB errors by rolling back and opening later scoped sessions.
246+ # Why: one savepoint-backed connection is too brittle for that flow.
247+ # Outcome: reuse the engine, but reset rows/schema before each test and
248+ # let app code use normal transaction boundaries.
249+ await _reset_postgres_integration_schema (postgres_engine )
207250
208251 session_maker = async_sessionmaker (
209- bind = engine ,
252+ bind = postgres_engine ,
210253 class_ = AsyncSession ,
211254 expire_on_commit = False ,
212255 autoflush = False ,
213256 )
214257
215258 # Set module-level state to prevent MCP lifespan from re-initializing
216259 # This ensures get_or_create_db() sees an existing engine and skips initialization
217- db ._engine = engine
260+ db ._engine = postgres_engine
218261 db ._session_maker = session_maker
219262
220- await _reset_postgres_integration_schema ( engine )
221-
222- yield engine , session_maker
223-
224- # Clean up module-level state
225- await engine . dispose ()
226- db ._engine = None
227- db ._session_maker = None
263+ try :
264+ yield postgres_engine , session_maker
265+ finally :
266+ # Clean up module-level state
267+ if db . _engine is postgres_engine :
268+ db . _engine = None
269+ if db ._session_maker is session_maker :
270+ db ._session_maker = None
228271
229272 else :
230273 # SQLite: Create fresh database (fast with tmp files)
0 commit comments