Files
Paul Hernandez 816ee85fb9 fix(core): prevent asyncpg engine-dispose crash on Postgres backend (#902)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:37:23 -05:00

70 lines
2.8 KiB
Python

"""Integration test for the asyncpg engine-dispose race (issue #831 / #877).
On the Postgres backend (``postgresql+asyncpg``), the "open async engine -> work
-> await engine.dispose()" cycle could crash with
``IndexError: pop from an empty deque`` raised from SQLAlchemy's async dispose
machinery as it races event-loop teardown. This made Postgres + file watcher
unusable (container restart loop).
These tests exercise the real create-engine -> query -> dispose cycle against a
live Postgres instance (testcontainers) for many iterations. The race is
timing-dependent, so we loop enough to be meaningful and assert clean
completion. They are skipped on SQLite, which is unaffected.
Run with:
BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest \
test-int/test_postgres_dispose_cycle.py -q
"""
import pytest
from sqlalchemy import text
from basic_memory import db
from basic_memory.config import DatabaseBackend
from basic_memory.db import DatabaseType, _create_postgres_engine
@pytest.mark.asyncio
async def test_create_query_dispose_cycle_is_clean(app_config, db_backend):
"""Repeatedly create -> query -> dispose an asyncpg engine without crashing."""
if db_backend != "postgres":
pytest.skip("Postgres-specific test - asyncpg dispose race does not affect SQLite")
db_url = DatabaseType.get_db_url(app_config.database_path, DatabaseType.POSTGRES, app_config)
# Loop enough iterations to make the timing-dependent dispose race observable.
for _ in range(50):
engine = _create_postgres_engine(db_url, app_config)
async with engine.connect() as conn:
result = await conn.execute(text("SELECT 1"))
assert result.scalar() == 1
# This dispose is the call site that raced loop teardown in #831/#877.
await engine.dispose()
@pytest.mark.asyncio
async def test_shutdown_db_dispose_is_clean(app_config, config_manager, db_backend):
"""Repeated get_or_create_db -> query -> shutdown_db cycles complete cleanly.
Exercises the shielded ``shutdown_db`` teardown path against asyncpg. The
autouse ``cleanup_global_db_after_test`` fixture in conftest restores module
state afterwards.
"""
if db_backend != "postgres":
pytest.skip("Postgres-specific test - asyncpg dispose race does not affect SQLite")
assert app_config.database_backend == DatabaseBackend.POSTGRES
for _ in range(25):
engine, session_maker = await db.get_or_create_db(
app_config.database_path,
db_type=DatabaseType.POSTGRES,
ensure_migrations=False,
config=app_config,
)
async with db.scoped_session(session_maker) as session:
result = await session.execute(text("SELECT 1"))
assert result.scalar() == 1
# Shielded dispose - must not surface the asyncpg deque race.
await db.shutdown_db()