fix(mcp): scope db.session per asyncio task to prevent cross-call teardown - #13
Conversation
…rdown Co-Authored-By: bot_apk <apk@cognition.ai>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
| try: | ||
| task = asyncio.current_task() | ||
| except RuntimeError: | ||
| task = None | ||
| if task is not None: | ||
| return task | ||
| return _ident_func() | ||
|
|
||
|
|
||
| db = get_sqla_class()(session_options={"scopefunc": _session_scopefunc}) |
There was a problem hiding this comment.
🟡 Database sessions opened during an async call can be held forever, slowly using up database connections
Database work started inside an async call is now filed under that call's own identity (asyncio.current_task() at superset/extensions/__init__.py:189) but is only ever released when the same call cleans up, so any async path that borrows an outer cleanup never releases its database connection.
Impact: Long-running MCP servers can slowly accumulate unreleased database sessions and connections, eventually exhausting the connection pool.
Why task-keyed scoping can orphan registry entries
scoped_session stores one Session per value returned by scopefunc in a plain dict, and entries are removed only by a remove() call made while the same scope key is current. Keying on the asyncio.Task means the key disappears when the task finishes, so any Session created inside a task that does not itself run db.session.remove() (the app-context teardown) stays in the registry forever, together with a strong reference to the completed Task and a checked-out pooled connection.
Concrete paths that touch db.session inside a task without pushing/popping their own app context:
superset/mcp_service/server.py:510-511: when an app context is already active (Flask app contexts live in aContextVar, which child tasks inherit from the task that pushed it),_check()runs DB lookups directly with no context push, so no teardown runs in that task.superset/mcp_service/auth.py:1044-1045: with an external request context present,nullcontext()is used, so the tool call's DB work is cleaned up by whichever scope pops the outer context — a different task under ASGI.
Before this change these sessions resolved to the thread/greenlet-wide session and were reclaimed by any teardown running on that thread.
Prompt for agents
Keying the SQLAlchemy scoped-session registry on the asyncio Task object (superset/extensions/__init__.py:_session_scopefunc) means registry entries are only ever evicted by a remove() executed while that same task is current. Any code that creates a session inside an asyncio task but relies on an app-context teardown owned by a different task/thread (e.g. superset/mcp_service/server.py:510-511 taking the has_app_context() shortcut, or the nullcontext() branch in superset/mcp_service/auth.py:1044-1045) will leave a Session, a strong reference to the finished Task, and a checked-out pooled connection in the registry forever. Consider either (a) ensuring every asyncio entry point that touches db.session pushes and pops its own app context so teardown runs in the same task, or (b) attaching an eviction hook to the task (e.g. task.add_done_callback that calls db.session.remove() under that scope) so orphaned entries are cleaned up when the task completes.
Was this helpful? React with 👍 or 👎 to provide feedback.
SUMMARY
Fixes #7.
Concurrent async MCP tool calls shared a single SQLAlchemy
Session, and each call's per-call Flask app-context teardown removed that shared session out from under the still-running calls, detaching their ORM instances (DetachedInstanceError).Root cause, unchanged from the issue write-up:
mcp_auth_hookpushes a new app context per call (correct — isolatesg).flask-sqlalchemy==2.5.1scopesdb.sessionwithscopefunc=_ident_func(=greenlet.getcurrent). Async tool calls are asyncio tasks that all run in the same greenlet on the event-loop thread, so they all resolve to oneSession.@app.teardown_appcontext shutdown_sessioncallsdb.session.remove()unconditionally, so the first call to finish removes the session shared by every other in-flight call.Fix (issue's first suggested direction — scope the session per asyncio task): install a loop-aware
scopefuncon the SQLAlchemy scoped session:asyncio.Task, so each gets its ownSession, andscoped_session.remove()at teardown only clears the finishing task's registry entry — never another call's.asyncio.current_task()raisesRuntimeError, so the identity falls back to the exact previous default (_ident_func= greenlet/thread). No behavior change off the event loop.This targets the structural cause behind apache#42567 (not just the
generate_chartsymptom removed by apache#42621), so any tool re-lazy-loading ORM attributes (e.g.User.rolesduring the dataset access check) is covered, not only the post-commit chart path.BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF
Not applicable.
TESTING INSTRUCTIONS
New regression test
tests/unit_tests/mcp_service/test_session_scope_isolation.py:_session_scopefuncreturns a distinct scope key per concurrent asyncio task, and falls back toflask_sqlalchemy._ident_funcoutside a running loop.DetachedInstanceError._session_scopefunc: the other task keeps its own session and reads its instance cleanly.Run:
pytest tests/unit_tests/mcp_service/test_session_scope_isolation.py pytest tests/unit_tests/mcp_service/ # full MCP suite (3155 passed)ADDITIONAL INFORMATION
Link to Devin session: https://app.devin.ai/sessions/1202e31f5a654a939d90bb6aecbb2fcf