mirror of
https://github.com/apache/superset.git
synced 2026-09-01 21:11:28 +00:00
Co-authored-by: goingforstudying-ctrl <goingforstudying-ctrl@users.noreply.github.com> Co-authored-by: Evan Rusackas <evan@preset.io> Co-authored-by: Joe Li <joe@preset.io>
234 lines
8.5 KiB
Python
234 lines
8.5 KiB
Python
# Licensed to the Apache Software Foundation (ASF) under one
|
|
# or more contributor license agreements. See the NOTICE file
|
|
# distributed with this work for additional information
|
|
# regarding copyright ownership. The ASF licenses this file
|
|
# to you under the Apache License, Version 2.0 (the
|
|
# "License"); you may not use this file except in compliance
|
|
# with the License. You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing,
|
|
# software distributed under the License is distributed on an
|
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
# KIND, either express or implied. See the License for the
|
|
# specific language governing permissions and limitations
|
|
# under the License.
|
|
"""Tests for per-tool-call SQLAlchemy session scoping in the MCP service.
|
|
|
|
db.session is keyed by greenlet ident, so concurrent async tool calls on
|
|
one event-loop greenlet share a single Session; the first call's app-context
|
|
teardown removes it and detaches the other calls' instances (issue #42622).
|
|
The MCP scopefunc keys the registry on a per-call ContextVar token instead.
|
|
"""
|
|
|
|
import asyncio
|
|
from collections.abc import Iterator
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from sqlalchemy import text
|
|
|
|
from superset.extensions import db
|
|
from superset.mcp_service.auth import _mcp_tool_call_context
|
|
from superset.mcp_service.session_scope import (
|
|
install_mcp_session_scoping,
|
|
mcp_session_scopefunc,
|
|
)
|
|
|
|
# NOTE: most tests enter tool-call contexts via _mcp_tool_call_context()
|
|
# directly rather than _get_app_context_manager(). The latter consults
|
|
# Flask state (request/app context) to pick a context manager, while these
|
|
# tests exercise the token mechanics in isolation. Request-backed tool
|
|
# calls go through _request_tool_call_context(), covered by
|
|
# test_request_context_tool_calls_get_isolated_sessions below.
|
|
|
|
|
|
@pytest.fixture
|
|
def mcp_scoping() -> Iterator[Any]:
|
|
"""Install the MCP scopefunc and restore the original one afterwards."""
|
|
registry = db.session.registry
|
|
original_scopefunc = registry.scopefunc
|
|
install_mcp_session_scoping()
|
|
yield registry
|
|
registry.scopefunc = original_scopefunc
|
|
|
|
|
|
def test_install_swaps_scopefunc_and_is_idempotent(mcp_scoping: Any) -> None:
|
|
assert mcp_scoping.scopefunc is mcp_session_scopefunc
|
|
install_mcp_session_scoping()
|
|
assert mcp_scoping.scopefunc is mcp_session_scopefunc
|
|
|
|
|
|
def test_scopefunc_falls_back_to_greenlet_outside_tool_calls(
|
|
mcp_scoping: Any,
|
|
) -> None:
|
|
# No per-call token set: the key must be the greenlet ident, exactly
|
|
# what flask-sqlalchemy uses by default, so web/CLI/Celery paths are
|
|
# unaffected.
|
|
import greenlet
|
|
|
|
assert mcp_session_scopefunc() is greenlet.getcurrent()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_tool_calls_get_isolated_sessions(
|
|
mcp_scoping: Any,
|
|
) -> None:
|
|
"""Each async tool call gets its own Session; one call's app-context
|
|
teardown removes only that call's session, leaving the other usable."""
|
|
a_ready = asyncio.Event()
|
|
b_done = asyncio.Event()
|
|
outcome: dict[str, Any] = {}
|
|
|
|
async def call_a() -> None:
|
|
with _mcp_tool_call_context():
|
|
session_a = db.session()
|
|
outcome["a"] = session_a
|
|
a_ready.set()
|
|
await b_done.wait()
|
|
# B has fully torn down; A's session must still resolve and work.
|
|
assert db.session() is session_a
|
|
session_a.execute(text("SELECT 1"))
|
|
outcome["a_alive"] = True
|
|
|
|
async def call_b() -> None:
|
|
await a_ready.wait()
|
|
with _mcp_tool_call_context():
|
|
outcome["b"] = db.session()
|
|
# Leaving the context pops it, so teardown removes B's session here.
|
|
b_done.set()
|
|
|
|
await asyncio.gather(call_a(), call_b())
|
|
|
|
assert outcome["a"] is not outcome["b"]
|
|
assert outcome.get("a_alive") is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shared_session_teardown_breaks_other_call_without_fix(
|
|
mcp_scoping: Any,
|
|
) -> None:
|
|
"""Counterfactual: with the default greenlet scope restored, concurrent
|
|
calls resolve to the SAME Session and the first teardown breaks the
|
|
survivor — the exact failure this change prevents."""
|
|
registry = db.session.registry
|
|
original = registry.scopefunc
|
|
try:
|
|
import greenlet
|
|
|
|
registry.scopefunc = greenlet.getcurrent
|
|
|
|
a_ready = asyncio.Event()
|
|
b_done = asyncio.Event()
|
|
outcome: dict[str, Any] = {}
|
|
|
|
async def call_a() -> None:
|
|
with _mcp_tool_call_context():
|
|
outcome["a"] = db.session()
|
|
a_ready.set()
|
|
await b_done.wait()
|
|
outcome["a_has_session"] = db.session.registry.has()
|
|
|
|
async def call_b() -> None:
|
|
await a_ready.wait()
|
|
with _mcp_tool_call_context():
|
|
outcome["b"] = db.session()
|
|
b_done.set()
|
|
|
|
await asyncio.gather(call_a(), call_b())
|
|
|
|
assert outcome["a"] is outcome["b"]
|
|
# B's teardown removed the shared session out from under A.
|
|
assert outcome["a_has_session"] is False
|
|
finally:
|
|
registry.scopefunc = original
|
|
|
|
|
|
def test_create_mcp_app_installs_session_scoping() -> None:
|
|
"""Direct factory callers get per-call scoping without a server entry point.
|
|
|
|
Deployments that serve the app straight from ``create_mcp_app()`` never
|
|
pass through ``init_fastmcp_server()`` or ``run_server()``; the factory
|
|
itself must swap the scopefunc or concurrent tool calls share one
|
|
greenlet-scoped session again.
|
|
"""
|
|
import greenlet
|
|
|
|
from superset.mcp_service.app import create_mcp_app
|
|
|
|
registry = db.session.registry
|
|
original_scopefunc = registry.scopefunc
|
|
try:
|
|
registry.scopefunc = greenlet.getcurrent
|
|
create_mcp_app(name="scoping-test")
|
|
assert registry.scopefunc is mcp_session_scopefunc
|
|
finally:
|
|
registry.scopefunc = original_scopefunc
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nested_tool_calls_keep_separate_sessions(mcp_scoping: Any) -> None:
|
|
"""A nested tool-call context gets its own session and its teardown does
|
|
not disturb the enclosing call's session."""
|
|
with _mcp_tool_call_context():
|
|
outer = db.session()
|
|
with _mcp_tool_call_context():
|
|
inner = db.session()
|
|
assert inner is not outer
|
|
# inner context popped: outer session untouched
|
|
assert db.session() is outer
|
|
outer.execute(text("SELECT 1"))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_request_context_tool_calls_get_isolated_sessions(
|
|
mcp_scoping: Any,
|
|
) -> None:
|
|
"""WorkspaceContextMiddleware path: with a request context active,
|
|
_get_app_context_manager() reuses it but must still give each tool
|
|
call its own session token — request-backed calls would otherwise
|
|
share the greenlet-scoped session, the same race fixed elsewhere.
|
|
Exiting a call removes only that call's session; the request's own
|
|
session belongs to the request lifecycle.
|
|
"""
|
|
from superset.mcp_service.auth import _get_app_context_manager
|
|
from superset.mcp_service.flask_singleton import get_flask_app
|
|
|
|
app = get_flask_app()
|
|
with app.test_request_context(path="/mcp"):
|
|
request_session = db.session()
|
|
a_ready = asyncio.Event()
|
|
b_done = asyncio.Event()
|
|
outcome: dict[str, Any] = {}
|
|
|
|
async def call_a() -> None:
|
|
with _get_app_context_manager():
|
|
session_a = db.session()
|
|
outcome["a"] = session_a
|
|
a_ready.set()
|
|
await b_done.wait()
|
|
# B has fully torn down; A's session must still resolve.
|
|
assert db.session() is session_a
|
|
session_a.execute(text("SELECT 1"))
|
|
outcome["a_alive"] = True
|
|
|
|
async def call_b() -> None:
|
|
await a_ready.wait()
|
|
with _get_app_context_manager():
|
|
outcome["b"] = db.session()
|
|
outcome["b_key"] = mcp_session_scopefunc()
|
|
# Exiting deregistered B's per-call session from the registry.
|
|
outcome["b_removed"] = outcome["b_key"] not in db.session.registry.registry
|
|
b_done.set()
|
|
|
|
await asyncio.gather(call_a(), call_b())
|
|
|
|
assert outcome["a"] is not outcome["b"]
|
|
assert outcome["a"] is not request_session
|
|
assert outcome["b"] is not request_session
|
|
assert outcome.get("a_alive") is True
|
|
assert outcome["b_removed"] is True
|
|
# The request's own greenlet-scoped session is untouched.
|
|
assert db.session() is request_session
|