mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
fix(sync): serialize in-memory SQLite sessions so concurrent rollbacks cannot destroy writes
Root cause of the test_sync_entity_circular_relations CI failure (#940, len(entity_b.outgoing_relations) == 0): the in-memory SQLite URL (sqlite+aiosqlite://) falls back to SQLAlchemy's StaticPool, which hands the same DBAPI connection to every concurrently checked-out session. Concurrent asyncio tasks therefore share one SQLite transaction scope. A rollback issued through one session — scoped_session's exception handler, or the pool's reset-on-return at connection checkin (~40 real ROLLBACKs per sync, measured) — also rolls back any other task's executed-but-uncommitted statements. During batch indexing, per-file tasks run concurrently (asyncio.gather bounded by index_entity_max_concurrent). If a sibling session's checkin ROLLBACK lands between one task's relation INSERT and its COMMIT, the relation row is silently erased: no error is raised, the sync reports success. The final sync-level resolve_relations() pass cannot heal this because it only re-queries rows with to_id IS NULL — the destroyed INSERT leaves no row at all. Fix: give MEMORY-type engines a single-connection AsyncAdaptedQueuePool (pool_size=1, max_overflow=0). The lone connection keeps the in-memory database alive for the engine's lifetime (the reason StaticPool was used), while the blocking checkout serializes sessions at transaction granularity, restoring the isolation the repositories assume. File-based SQLite and Postgres engines are unchanged; production never uses MEMORY engines. The regression test pins the invariant directly: a session that rolls back in one task must never destroy another task's uncommitted writes. It fails deterministically against StaticPool and passes with the serialized pool. tests/repository/test_entity_repository.py held a scoped session open while calling a repository method that opens its own session — tolerated on a shared connection, a deadlock under a serialized pool — so the nested call moved out of the session block. Refs #940 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
committed by
Paul Hernandez
parent
1ad3a350ad
commit
85c701b8c2
+25
-13
@@ -19,7 +19,7 @@ from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
async_scoped_session,
|
||||
)
|
||||
from sqlalchemy.pool import NullPool
|
||||
from sqlalchemy.pool import AsyncAdaptedQueuePool, NullPool
|
||||
|
||||
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
|
||||
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
|
||||
@@ -216,19 +216,31 @@ def _create_sqlite_engine(db_url: str, db_type: DatabaseType) -> AsyncEngine:
|
||||
"isolation_level": None, # Use autocommit mode
|
||||
}
|
||||
)
|
||||
|
||||
if db_type == DatabaseType.MEMORY:
|
||||
# Trigger: an in-memory SQLite URL would default to StaticPool, which hands the
|
||||
# same DBAPI connection to every concurrently checked-out session.
|
||||
# Why: concurrent asyncio tasks then share one transaction scope — a rollback
|
||||
# issued by one session (scoped_session exception handling or the pool's
|
||||
# reset-on-return) silently destroys another session's uncommitted writes (#940).
|
||||
# Outcome: a single-connection blocking queue pool keeps the in-memory database
|
||||
# alive for the engine's lifetime while serializing sessions at transaction
|
||||
# granularity, restoring the isolation the repositories assume.
|
||||
engine = create_async_engine(
|
||||
db_url,
|
||||
connect_args=connect_args,
|
||||
poolclass=AsyncAdaptedQueuePool,
|
||||
pool_size=1,
|
||||
max_overflow=0,
|
||||
)
|
||||
elif os.name == "nt":
|
||||
# Use NullPool for Windows filesystem databases to avoid connection pooling issues
|
||||
# Important: Do NOT use NullPool for in-memory databases as it will destroy the database
|
||||
# between connections
|
||||
if db_type == DatabaseType.FILESYSTEM:
|
||||
engine = create_async_engine(
|
||||
db_url,
|
||||
connect_args=connect_args,
|
||||
poolclass=NullPool, # Disable connection pooling on Windows
|
||||
echo=False,
|
||||
)
|
||||
else:
|
||||
# In-memory databases need connection pooling to maintain state
|
||||
engine = create_async_engine(db_url, connect_args=connect_args)
|
||||
engine = create_async_engine(
|
||||
db_url,
|
||||
connect_args=connect_args,
|
||||
poolclass=NullPool, # Disable connection pooling on Windows
|
||||
echo=False,
|
||||
)
|
||||
else:
|
||||
engine = create_async_engine(db_url, connect_args=connect_args)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user