blob: 75945b20c4ef3df5301f53ab5746efaca02327a1 [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.
"""Bounded streaming and cancellation tests for Arrow Flight SQL queries."""
from __future__ import annotations
import asyncio
import threading
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
import pytest
from doris_mcp_server.result_limits import ResultLimits
from doris_mcp_server.utils.adbc_query_tools import DorisADBCQueryTools
from doris_mcp_server.utils.security import (
AuthContext,
reset_auth_context,
set_current_auth_context,
)
def _manager() -> SimpleNamespace:
return SimpleNamespace(
config=SimpleNamespace(
security=SimpleNamespace(max_result_rows=50),
performance=SimpleNamespace(
max_result_bytes=4096,
query_timeout=20,
),
adbc=SimpleNamespace(
default_max_rows=20,
default_timeout=10,
default_return_format="dict",
connection_timeout=30,
),
database=SimpleNamespace(
be_hosts=["be-1"],
user="root",
password="",
),
),
host="fe-1",
security_manager=None,
)
class _StreamingCursor:
description = (("id", "BIGINT"), ("payload", "VARCHAR"))
def __init__(self, rows: list[tuple[Any, ...]]) -> None:
self._rows = rows
self._offset = 0
self.executed_sql: str | None = None
self.fetch_sizes: list[int] = []
self.closed = False
def execute(self, sql: str) -> None:
self.executed_sql = sql
def fetchmany(self, size: int) -> list[tuple[Any, ...]]:
self.fetch_sizes.append(size)
batch = self._rows[self._offset : self._offset + size]
self._offset += len(batch)
return batch
def close(self) -> None:
self.closed = True
def adbc_cancel(self) -> None:
raise AssertionError("completed queries must not be cancelled")
class _BlockingCursor:
description: tuple[()] = ()
def __init__(self) -> None:
self.started = threading.Event()
self.cancelled = threading.Event()
self.closed = False
self.cancel_calls = 0
def execute(self, sql: str) -> None:
del sql
self.started.set()
self.cancelled.wait(timeout=5)
def fetchmany(self, size: int) -> list[tuple[Any, ...]]:
del size
return []
def adbc_cancel(self) -> None:
self.cancel_calls += 1
self.cancelled.set()
def close(self) -> None:
self.closed = True
@pytest.mark.asyncio
async def test_adbc_streams_to_row_limit_without_fetchall() -> None:
cursor = _StreamingCursor([(1, "one"), (2, "two"), (3, "three")])
tools = DorisADBCQueryTools(_manager())
tools.adbc_client = SimpleNamespace(cursor=lambda: cursor)
result = await tools._execute_query_with_adbc(
"SELECT id, payload FROM bounded",
ResultLimits(max_rows=2, max_bytes=4096, timeout_seconds=5),
"dict",
)
assert result["success"] is True
assert result["result"]["data"] == [
{"id": 1, "payload": "one"},
{"id": 2, "payload": "two"},
]
assert result["truncated"] is True
assert result["truncation_reason"] == "max_rows"
assert result["result_bytes"] <= 4096
assert cursor.closed is True
assert cursor.fetch_sizes == [3]
@pytest.mark.asyncio
async def test_adbc_streams_to_byte_limit() -> None:
cursor = _StreamingCursor([(1, "x" * 180), (2, "y" * 180)])
tools = DorisADBCQueryTools(_manager())
tools.adbc_client = SimpleNamespace(cursor=lambda: cursor)
result = await tools._execute_query_with_adbc(
"SELECT id, payload FROM bounded",
ResultLimits(max_rows=10, max_bytes=256, timeout_seconds=5),
"pandas",
)
assert result["success"] is True
assert result["result"]["num_rows"] == 1
assert result["truncated"] is True
assert result["truncation_reason"] == "max_bytes"
assert result["result_bytes"] <= 256
assert cursor.closed is True
@pytest.mark.asyncio
async def test_adbc_cancellation_calls_driver_and_reaps_worker() -> None:
cursor = _BlockingCursor()
tools = DorisADBCQueryTools(_manager())
tools.adbc_client = SimpleNamespace(cursor=lambda: cursor)
task = asyncio.create_task(
tools._execute_query_with_adbc(
"SELECT SLEEP(5)",
ResultLimits(max_rows=1, max_bytes=256, timeout_seconds=10),
"dict",
)
)
assert await asyncio.to_thread(cursor.started.wait, 1)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert cursor.cancel_calls == 1
assert cursor.closed is True
@pytest.mark.asyncio
async def test_adbc_rejects_limit_escalation_before_network_checks() -> None:
tools = DorisADBCQueryTools(_manager())
result = await tools.exec_adbc_query(
"SELECT 1",
max_rows=51,
max_bytes=1024,
timeout=5,
)
assert result["success"] is False
assert result["error_type"] == "invalid_result_limits"
assert result["error"] == ("max_rows exceeds the configured maximum of 50")
@pytest.mark.asyncio
async def test_adbc_fails_closed_for_doris_oauth_before_probing() -> None:
tools = DorisADBCQueryTools(_manager())
tools._check_arrow_flight_ports = MagicMock( # type: ignore[method-assign]
side_effect=AssertionError("ADBC endpoint probe must not run")
)
context_token = set_current_auth_context(
AuthContext(
auth_method="doris_oauth",
doris_user="analyst",
)
)
try:
result = await tools.exec_adbc_query("SELECT 1")
finally:
reset_auth_context(context_token)
assert result["success"] is False
assert result["error_type"] == "token_bound_adbc_unsupported"
tools._check_arrow_flight_ports.assert_not_called()
@pytest.mark.asyncio
async def test_adbc_capability_probe_uses_bounded_socket_timeout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
tools = DorisADBCQueryTools(_manager())
connectivity = MagicMock(return_value=True)
tools._check_port_connectivity = connectivity # type: ignore[method-assign]
monkeypatch.setenv("FE_ARROW_FLIGHT_SQL_PORT", "8070")
monkeypatch.setenv("BE_ARROW_FLIGHT_SQL_PORT", "8060")
result = await tools._check_arrow_flight_ports(
connectivity_timeout=0.25,
)
assert result["success"] is True
assert connectivity.call_args_list == [
(("fe-1", 8070, 0.25),),
(("be-1", 8060, 0.25),),
]