blob: 7c0a7b1400c65a8a8f5af432c2efeeb3ffa60a9a [file]
# 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.
"""Regression tests for multi-instance routing and multi-FE failover."""
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from doris_mcp_server.auth.token_manager import DatabaseConfig as TokenDatabaseConfig
from doris_mcp_server.utils import db as db_module
from doris_mcp_server.utils.adbc_query_tools import DorisADBCQueryTools
from doris_mcp_server.utils.config import DorisConfig
from doris_mcp_server.utils.db import DorisConnectionManager
from doris_mcp_server.utils.doris_http_client import (
DorisHTTPClient,
DorisHTTPRequestError,
DorisHTTPResponse,
configured_fe_http_hosts,
)
from doris_mcp_server.utils.security import (
AuthContext,
reset_auth_context,
set_current_auth_context,
)
class _ProbeCursor:
def __init__(self, healthy: bool):
self.healthy = healthy
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, traceback):
return False
async def execute(self, sql: str) -> None:
assert sql == "SELECT 1"
async def fetchone(self):
return (1,) if self.healthy else None
class _ProbeConnection:
def __init__(self, healthy: bool):
self.healthy = healthy
self.closed = False
def cursor(self):
return _ProbeCursor(self.healthy)
class _ProbePool:
def __init__(self, healthy: bool):
self.healthy = healthy
self.closed = False
self.released = []
self.close_calls = 0
async def acquire(self):
return _ProbeConnection(self.healthy)
def release(self, connection):
self.released.append(connection)
def close(self):
self.closed = True
self.close_calls += 1
async def wait_closed(self):
return None
class _BrokenAcquirePool(_ProbePool):
async def acquire(self):
raise OSError("active FE is unavailable")
def _config(*hosts: str) -> DorisConfig:
config = DorisConfig()
config.database.host = hosts[0]
config.database.hosts = list(hosts)
config.database.user = "root"
config.database.password = "secret"
config.database.connection_timeout = 1
config.database.max_connections = 2
return config
def test_environment_loads_ordered_sql_and_http_fe_hosts(monkeypatch):
monkeypatch.setenv("DORIS_HOST", "sql-primary")
monkeypatch.setenv("DORIS_HOSTS", "sql-secondary,sql-primary,sql-third")
monkeypatch.setenv("DORIS_FE_HTTP_HOST", "http-primary")
monkeypatch.setenv(
"DORIS_FE_HTTP_HOSTS",
"http-secondary,http-primary,http-third",
)
config = DorisConfig.from_env()
assert config.database.host == "sql-primary"
assert config.database.hosts == [
"sql-primary",
"sql-secondary",
"sql-third",
]
assert config.database.fe_http_host == "http-primary"
assert config.database.fe_http_hosts == [
"http-primary",
"http-secondary",
"http-third",
]
serialized = config.to_dict()["database"]
assert serialized["hosts"] == config.database.hosts
assert serialized["fe_http_hosts"] == config.database.fe_http_hosts
def test_config_file_host_lists_set_backward_compatible_primary():
config = DorisConfig._from_dict(
{
"database": {
"hosts": ["fe-a", "fe-b"],
"fe_http_hosts": ["fe-http-a", "fe-http-b"],
}
}
)
assert config.database.host == "fe-a"
assert config.database.fe_http_host == "fe-http-a"
@pytest.mark.asyncio
async def test_global_pool_fails_over_to_second_fe(monkeypatch):
manager = DorisConnectionManager(_config("fe-down", "fe-up"))
failed_pool = _ProbePool(healthy=False)
healthy_pool = _ProbePool(healthy=True)
create_pool = AsyncMock(side_effect=[failed_pool, healthy_pool])
monkeypatch.setattr(db_module.aiomysql, "create_pool", create_pool)
await manager._create_global_pool()
assert [call.kwargs["host"] for call in create_pool.await_args_list] == [
"fe-down",
"fe-up",
]
assert failed_pool.closed is True
assert manager.pool is healthy_pool
assert manager.host == "fe-up"
assert manager.active_db_config["host"] == "fe-up"
@pytest.mark.asyncio
async def test_doris_user_authentication_uses_same_fe_candidates(monkeypatch):
manager = DorisConnectionManager(_config("fe-down", "fe-up"))
good_connection = SimpleNamespace(close=lambda: None)
connect = AsyncMock(
side_effect=[OSError("unreachable"), good_connection],
)
monkeypatch.setattr(db_module.aiomysql, "connect", connect)
await manager.authenticate_doris_user("alice", "correct-password")
assert [call.kwargs["host"] for call in connect.await_args_list] == [
"fe-down",
"fe-up",
]
assert manager.host == "fe-up"
def test_token_selects_an_independent_cluster_and_http_allowlist():
manager = DorisConnectionManager(_config("global-fe"))
tenant_config = TokenDatabaseConfig(
host="tenant-a-fe-1",
hosts=["tenant-a-fe-1", "tenant-a-fe-2"],
user="tenant_a",
password="tenant-secret",
fe_http_hosts=["tenant-a-http-1", "tenant-a-http-2"],
)
manager.token_manager = SimpleNamespace(
get_database_config_by_token=lambda token: (
tenant_config if token == "tenant-a-token" else None
)
)
auth_context = AuthContext(
user_id="tenant-a",
auth_method="token",
token="tenant-a-token",
)
selected = manager.get_database_config_for_auth_context(auth_context)
assert selected is tenant_config
assert configured_fe_http_hosts(selected) == (
"tenant-a-http-1",
"tenant-a-http-2",
)
client = DorisHTTPClient.from_database_config(selected)
assert client.allowed_endpoints["fe"] == {
("tenant-a-http-1", 8030),
("tenant-a-http-2", 8030),
}
@pytest.mark.asyncio
async def test_token_bound_pool_fails_over_inside_its_own_cluster(monkeypatch):
manager = DorisConnectionManager(_config("global-fe"))
tenant_config = TokenDatabaseConfig(
host="tenant-fe-down",
hosts=["tenant-fe-down", "tenant-fe-up"],
user="tenant_user",
password="tenant-secret",
database="tenant_db",
)
manager.token_manager = SimpleNamespace(
get_database_config_by_token=lambda token: (
tenant_config if token == "tenant-token" else None
)
)
failed_pool = _ProbePool(healthy=False)
healthy_pool = _ProbePool(healthy=True)
create_pool = AsyncMock(side_effect=[failed_pool, healthy_pool])
monkeypatch.setattr(db_module.aiomysql, "create_pool", create_pool)
pool, selected_config = await manager.get_pool_for_token("tenant-token")
assert pool is healthy_pool
assert selected_config["hosts"] == ["tenant-fe-down", "tenant-fe-up"]
assert [call.kwargs["host"] for call in create_pool.await_args_list] == [
"tenant-fe-down",
"tenant-fe-up",
]
assert failed_pool.closed is True
@pytest.mark.asyncio
async def test_token_bound_pool_recreates_after_active_fe_failure(monkeypatch):
manager = DorisConnectionManager(_config("global-fe"))
tenant_config = TokenDatabaseConfig(
host="tenant-fe-1",
hosts=["tenant-fe-1", "tenant-fe-2"],
user="tenant_user",
password="tenant-secret",
database="tenant_db",
)
manager.token_manager = SimpleNamespace(
get_database_config_by_token=lambda token: (
tenant_config if token == "tenant-token" else None
)
)
token_hash = manager._get_token_hash("tenant-token")
broken_pool = _BrokenAcquirePool(healthy=False)
recovered_pool = _ProbePool(healthy=True)
manager.token_pools[token_hash] = broken_pool
manager.token_configs[token_hash] = {
"host": "tenant-fe-1",
"hosts": ["tenant-fe-1", "tenant-fe-2"],
"port": 9030,
"user": "tenant_user",
"password": "tenant-secret",
"database": "tenant_db",
"charset": "UTF8",
}
manager._token_pool_owner_ids[token_hash] = "static_token:old"
manager._token_pool_generations[token_hash] = 3
create_pool = AsyncMock(return_value=recovered_pool)
monkeypatch.setattr(manager, "_create_pool_with_config", create_pool)
connection = await manager.get_connection_for_token(
"tenant-token",
"recovering-session",
)
assert broken_pool.closed is True
assert connection.owner_pool is recovered_pool
assert manager.token_pools[token_hash] is recovered_pool
assert connection.generation == 4
create_pool.assert_awaited_once()
@pytest.mark.asyncio
async def test_adbc_fails_closed_for_token_bound_cluster():
manager = DorisConnectionManager(_config("global-fe"))
tenant_config = TokenDatabaseConfig(
host="tenant-fe",
user="tenant_user",
password="tenant-secret",
)
manager.token_manager = SimpleNamespace(
get_database_config_by_token=lambda token: (
tenant_config if token == "tenant-token" else None
)
)
context_token = set_current_auth_context(
AuthContext(
user_id="tenant",
auth_method="token",
token="tenant-token",
)
)
try:
result = await DorisADBCQueryTools(manager).exec_adbc_query("SELECT 1")
finally:
reset_auth_context(context_token)
assert result["success"] is False
assert result["error_type"] == "token_bound_adbc_unsupported"
@pytest.mark.asyncio
async def test_http_request_fails_over_only_within_allowlisted_hosts():
client = DorisHTTPClient(
user="root",
password="secret",
allowed_endpoints={
"fe": {("fe-a", 8030), ("fe-b", 8030)},
"be": set(),
},
)
expected = DorisHTTPResponse(
status=200,
headers={},
body=b"ok",
url="http://fe-b:8030/metrics",
)
client.get = AsyncMock( # type: ignore[method-assign]
side_effect=[DorisHTTPRequestError("down"), expected]
)
response = await client.get_first_available(
role="fe",
hosts=["fe-a", "fe-b"],
port=8030,
path="/metrics",
)
assert response is expected
assert [call.kwargs["host"] for call in client.get.await_args_list] == [
"fe-a",
"fe-b",
]