Date: December 3, 2025 Status: Draft (Pending Chief Architect Review) Deciders: Chief Architect, Development Team Related: ADR-006 (Standardize Async Session Management), Issue #442, Issue #453
During alpha testing of the setup wizard (Issue #442), users encountered InterfaceError: cannot perform operation: another operation is in progress and Future attached to a different loop errors. These errors only manifested in specific runtime conditions:
python main.py setup)Python’s asyncio.run() creates a new event loop each time it’s called. When our application:
db singleton during startup (Event Loop A)asyncio.run(run_setup_wizard()) (creates Event Loop B)db.engine remains bound to Event Loop AThis is particularly insidious because:
session_scope() properlyADR-006 established AsyncSessionFactory.session_scope() as the standard pattern, but assumed all database operations would occur within a single event loop context. This assumption breaks in:
Add session_scope_fresh() to AsyncSessionFactory as the safe pattern for code that may run in a different event loop than application startup.
class AsyncSessionFactory:
@staticmethod
@asynccontextmanager
async def session_scope():
"""Standard pattern - uses global db singleton.
Use when: Code runs in the same event loop as app startup.
Examples: HTTP request handlers, background tasks started during startup.
"""
session = await db.get_session()
try:
yield session
finally:
await session.close()
@staticmethod
@asynccontextmanager
async def session_scope_fresh():
"""Fresh engine pattern - creates new engine per request.
Use when: Code may run in a different event loop than app startup.
Examples: CLI wizards, setup endpoints, test fixtures.
Trade-off: Slightly higher overhead (new engine creation) for
guaranteed event loop safety.
"""
engine = create_async_engine(
_get_database_url(),
pool_size=1,
max_overflow=0,
)
session_factory = async_sessionmaker(engine)
session = session_factory()
try:
yield session
finally:
await session.close()
await engine.dispose()
| Scenario | Pattern | Rationale |
|---|---|---|
| HTTP request handlers | session_scope() |
Same event loop as uvicorn startup |
| Background tasks via FastAPI | session_scope() |
Same event loop as application |
CLI commands with asyncio.run() |
session_scope_fresh() |
New event loop context |
| Setup wizard endpoints | session_scope_fresh() |
May be called before/after CLI |
| Test fixtures | session_scope_fresh() |
pytest-asyncio creates isolated loops |
| Worker processes | session_scope_fresh() |
Separate process = separate loop |
When reviewing code, look for these patterns that suggest session_scope_fresh() is needed:
asyncio.run() anywhere in the call chain - Creates new event loopcli/ directory - Often invoked separately from main appsession_scope() still uses efficient poolingsession_scope_fresh() creates/disposes engines per requestsession_scope_fresh() to AsyncSessionFactorysetup_wizard.py to use session_scope_fresh() (16 call sites)session_scope() usage for event loop safetysession_scope_fresh()Mitigation:
session_scope() - it’s correct for 90% of casesMitigation:
session_scope_fresh() only used in setup/initialization pathsStandard Pattern - session_scope():
asyncio.run() in call chainFresh Engine Pattern - session_scope_fresh():
InterfaceError: another operation is in progress doesn’t suggest event loop issuesThe async ecosystem’s “works in isolation, fails in integration” pattern is a fundamental challenge. Global singletons that bind to event loops at initialization time are a hidden coupling that breaks when runtime context changes.
Draft Date: December 3, 2025 Submitted For Review: Chief Architect Risk Level: Low (extends existing pattern, doesn’t replace it) Business Impact: None (transparent to users, improves reliability)