This is an automated email from the ASF dual-hosted git repository.
FreeOnePlus pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-mcp-server.git
The following commit(s) were added to refs/heads/master by this push:
new b77ce8e feat: separate liveness and readiness probes (#131)
b77ce8e is described below
commit b77ce8e69a0a7cc53576733d1b6d8cdbfbd73239
Author: Yijia Su <[email protected]>
AuthorDate: Thu Jul 30 01:46:10 2026 +0800
feat: separate liveness and readiness probes (#131)
---
README.md | 22 ++-
doris_mcp_server/auth/auth_middleware.py | 13 +-
doris_mcp_server/auth/operation_policy.py | 2 +
doris_mcp_server/health.py | 90 +++++++++
doris_mcp_server/main.py | 36 +++-
doris_mcp_server/multiworker_app.py | 70 ++++++-
doris_mcp_server/utils/db.py | 27 ++-
test/integration/test_real_doris_transports.py | 49 ++++-
test/protocol/test_health_endpoints.py | 250 +++++++++++++++++++++++++
test/protocol/test_multiworker_config.py | 47 ++++-
10 files changed, 570 insertions(+), 36 deletions(-)
diff --git a/README.md b/README.md
index ac44bbc..edc2123 100644
--- a/README.md
+++ b/README.md
@@ -189,7 +189,8 @@ doris-mcp-server --help
doris-mcp-client --help
# Test HTTP mode (in another terminal)
-curl http://localhost:3000/health
+curl --fail http://localhost:3000/live
+curl --fail http://localhost:3000/ready
```
### Environment Variables (Optional)
@@ -383,7 +384,14 @@ docker run -d -p <port>:<port> -v
/*your-host*/doris-mcp-server/.env:/app/.env -
**Service Endpoints:**
* **Streamable HTTP**: `http://<host>:<port>/mcp` (MCP messages use `POST`;
do not depend on `GET` or `DELETE` compatibility behavior)
-* **Health Check**: `http://<host>:<port>/health`
+* **Liveness**: `http://<host>:<port>/live` — process and protocol service
are running; does not depend on Doris
+* **Readiness**: `http://<host>:<port>/ready` — returns 200 only when a
bounded Doris `SELECT 1` probe succeeds; otherwise returns 503
+* **Legacy Health Check**: `http://<host>:<port>/health` —
backward-compatible liveness alias; do not use it to decide whether to route
database work
+
+The readiness probe has a fixed short timeout and exposes only stable status
+fields, not connection errors or credentials. Stdio mode has no HTTP health
+surface; supervise the process and use the MCP initialization/discovery
+handshake for transport-level availability.
> **Note**: The server uses Streamable HTTP for web-based communication,
> providing unified request/response and streaming capabilities.
@@ -1568,8 +1576,9 @@ Recommendations:
2. **Check network connectivity**:
```bash
- # Test database connection
- curl http://localhost:3000/health
+ # /live verifies the process; /ready also verifies Doris
+ curl --fail http://localhost:3000/live
+ curl --fail http://localhost:3000/ready
```
3. **Optimize connection pool configuration**:
@@ -1615,8 +1624,9 @@ tail -f logs/doris_mcp_server_info.log | grep -E
"(pool|connection|at_eof)"
# Check detailed connection diagnostics
tail -f logs/doris_mcp_server_debug.log | grep "connection health"
-# View connection pool metrics
-curl http://localhost:8000/health # If running in HTTP mode
+# Check process liveness and Doris readiness
+curl --fail http://localhost:8000/live
+curl --fail http://localhost:8000/ready
```
#### Configuration for Optimal Connection Performance:
diff --git a/doris_mcp_server/auth/auth_middleware.py
b/doris_mcp_server/auth/auth_middleware.py
index 4e7137e..ab6dd91 100644
--- a/doris_mcp_server/auth/auth_middleware.py
+++ b/doris_mcp_server/auth/auth_middleware.py
@@ -152,7 +152,13 @@ class AuthMiddleware:
Returns:
ASGI middleware function
"""
- skip_paths = skip_paths or ['/health', '/docs', '/openapi.json']
+ skip_paths = skip_paths or [
+ '/health',
+ '/live',
+ '/ready',
+ '/docs',
+ '/openapi.json',
+ ]
async def middleware(scope, receive, send):
"""HTTP authentication middleware"""
@@ -163,7 +169,10 @@ class AuthMiddleware:
path = scope.get('path', '')
# Check if authentication should be skipped
- if any(path.startswith(skip) for skip in skip_paths):
+ if any(
+ path == skip or path.startswith(f"{skip}/")
+ for skip in skip_paths
+ ):
return await self.app(scope, receive, send)
# Extract authentication information
diff --git a/doris_mcp_server/auth/operation_policy.py
b/doris_mcp_server/auth/operation_policy.py
index d97f10b..ee52b68 100644
--- a/doris_mcp_server/auth/operation_policy.py
+++ b/doris_mcp_server/auth/operation_policy.py
@@ -338,6 +338,8 @@ OPERATION_POLICIES: dict[str, OperationPolicy] = {
"get_prompt": OperationPolicy("get_prompt", "prompt:get", "deny", "mysql",
"medium"),
"http:/": OperationPolicy("http:/", None, "allow"),
"http:/health": OperationPolicy("http:/health", None, "allow"),
+ "http:/live": OperationPolicy("http:/live", None, "allow"),
+ "http:/ready": OperationPolicy("http:/ready", None, "allow"),
"http:/auth/login": OperationPolicy("http:/auth/login", None, "deny",
"external_oauth", "medium"),
"http:/auth/callback": OperationPolicy("http:/auth/callback", None,
"deny", "external_oauth", "medium"),
"http:/auth/provider": OperationPolicy("http:/auth/provider", None,
"deny", "external_oauth", "medium"),
diff --git a/doris_mcp_server/health.py b/doris_mcp_server/health.py
new file mode 100644
index 0000000..8503d4f
--- /dev/null
+++ b/doris_mcp_server/health.py
@@ -0,0 +1,90 @@
+# 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.
+"""Liveness and readiness response helpers."""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Mapping
+from typing import Any, Protocol
+
+DEFAULT_READINESS_TIMEOUT_SECONDS = 2.0
+
+
+class ReadinessProbe(Protocol):
+ """Minimal dependency contract used by the HTTP readiness endpoint."""
+
+ async def check_readiness(self, *, timeout_seconds: float) -> bool: ...
+
+
+def liveness_payload(
+ *,
+ service: str,
+ version: str,
+ legacy: bool = False,
+ details: Mapping[str, Any] | None = None,
+) -> dict[str, Any]:
+ """Build a database-independent liveness payload."""
+ payload: dict[str, Any] = {
+ "status": "healthy" if legacy else "alive",
+ "service": service,
+ "version": version,
+ "checks": {"process": "alive"},
+ }
+ if details:
+ payload.update(details)
+ return payload
+
+
+async def readiness_payload(
+ connection_manager: ReadinessProbe | None,
+ *,
+ service: str,
+ version: str,
+ initialized: bool = True,
+ details: Mapping[str, Any] | None = None,
+ timeout_seconds: float = DEFAULT_READINESS_TIMEOUT_SECONDS,
+) -> tuple[dict[str, Any], int]:
+ """Build a bounded readiness payload and its HTTP status code."""
+ doris_ready = False
+ if initialized and connection_manager is not None:
+ try:
+ doris_ready = bool(
+ await asyncio.wait_for(
+ connection_manager.check_readiness(
+ timeout_seconds=timeout_seconds,
+ ),
+ timeout=timeout_seconds,
+ )
+ )
+ except Exception:
+ doris_ready = False
+
+ ready = initialized and doris_ready
+ payload: dict[str, Any] = {
+ "status": "ready" if ready else "not_ready",
+ "service": service,
+ "version": version,
+ "checks": {
+ "service": "ready" if initialized else "not_ready",
+ "doris": "ready" if doris_ready else "not_ready",
+ },
+ "timeout_seconds": timeout_seconds,
+ }
+ if details:
+ payload.update(details)
+ return payload, 200 if ready else 503
diff --git a/doris_mcp_server/main.py b/doris_mcp_server/main.py
index f53aca1..7102194 100644
--- a/doris_mcp_server/main.py
+++ b/doris_mcp_server/main.py
@@ -29,6 +29,7 @@ import os
import sys
from ._version import __version__
+from .health import liveness_payload, readiness_payload
from .protocol import create_doris_mcp_server, create_transport_security
from .tools.prompts_manager import DorisPromptsManager
from .tools.resources_manager import DorisResourcesManager
@@ -225,12 +226,28 @@ class DorisServer:
# Health check endpoint
async def health_check(request):
return JSONResponse(
- {
- "status": "healthy",
- "service": self.config.server_name,
- "version": __version__,
- }
+ liveness_payload(
+ service=self.config.server_name,
+ version=__version__,
+ legacy=True,
+ )
+ )
+
+ async def live_check(request):
+ return JSONResponse(
+ liveness_payload(
+ service=self.config.server_name,
+ version=__version__,
+ )
+ )
+
+ async def readiness_check(request):
+ payload, status_code = await readiness_payload(
+ self.connection_manager,
+ service=self.config.server_name,
+ version=__version__,
)
+ return JSONResponse(payload, status_code=status_code)
# OAuth endpoints
from .auth.oauth_handlers import OAuthHandlers
@@ -293,7 +310,11 @@ class DorisServer:
self.logger.info("Application is shutting down...")
effective_auth = get_effective_auth_config(self.config)
- routes = [Route("/health", health_check, methods=["GET"])]
+ routes = [
+ Route("/health", health_check, methods=["GET"]),
+ Route("/live", live_check, methods=["GET"]),
+ Route("/ready", readiness_check, methods=["GET"]),
+ ]
if effective_auth.enable_external_oauth_auth:
routes.extend([
Route(
@@ -355,7 +376,8 @@ class DorisServer:
await response(scope, receive, send)
return
- if (path.startswith("/health") or
+ if (
+ path.rstrip("/") in {"/health", "/live", "/ready"}
or
path.startswith("/auth/") or
path.startswith("/token/") or
path.startswith("/.well-known/") or
diff --git a/doris_mcp_server/multiworker_app.py
b/doris_mcp_server/multiworker_app.py
index 89b924c..74d1335 100644
--- a/doris_mcp_server/multiworker_app.py
+++ b/doris_mcp_server/multiworker_app.py
@@ -31,6 +31,7 @@ from starlette.responses import JSONResponse
from starlette.routing import Route
from ._version import __version__
+from .health import liveness_payload, readiness_payload
from .protocol import create_doris_mcp_server, create_transport_security
from .tools.prompts_manager import DorisPromptsManager
from .tools.resources_manager import DorisResourcesManager
@@ -95,7 +96,17 @@ async def initialize_worker():
_worker_security_manager.connection_manager =
_worker_connection_manager
_worker_security_manager.auth_provider.configure_doris_oauth(_worker_connection_manager)
- await _worker_connection_manager.initialize()
+ global_pool_created = (
+ await _worker_connection_manager.initialize_for_http_mode()
+ )
+ if (
+ not global_pool_created
+ and _worker_effective_auth.enable_doris_oauth_auth
+ ):
+ raise RuntimeError(
+ "Doris OAuth requires the configured service/global Doris
account "
+ "to initialize successfully"
+ )
# Create managers
resources_manager = DorisResourcesManager(_worker_connection_manager)
@@ -149,15 +160,50 @@ async def initialize_worker():
raise
async def health_check(request):
- """Health check endpoint that shows worker PID"""
- return JSONResponse({
- "status": "healthy",
- "service": "doris-mcp-server",
- "worker_pid": os.getpid(),
- "worker_mode": "multi-process-full-mcp",
- "mcp_initialized": _worker_initialized,
- "version": __version__,
- })
+ """Backward-compatible liveness endpoint."""
+ return JSONResponse(
+ liveness_payload(
+ service="doris-mcp-server",
+ version=__version__,
+ legacy=True,
+ details={
+ "worker_pid": os.getpid(),
+ "worker_mode": "multi-process-full-mcp",
+ "mcp_initialized": _worker_initialized,
+ },
+ )
+ )
+
+
+async def live_check(request):
+ """Database-independent liveness endpoint."""
+ return JSONResponse(
+ liveness_payload(
+ service="doris-mcp-server",
+ version=__version__,
+ details={
+ "worker_pid": os.getpid(),
+ "worker_mode": "multi-process-full-mcp",
+ "mcp_initialized": _worker_initialized,
+ },
+ )
+ )
+
+
+async def readiness_check(request):
+ """Bounded readiness endpoint for this worker."""
+ payload, status_code = await readiness_payload(
+ _worker_connection_manager,
+ service="doris-mcp-server",
+ version=__version__,
+ initialized=_worker_initialized,
+ details={
+ "worker_pid": os.getpid(),
+ "worker_mode": "multi-process-full-mcp",
+ "mcp_initialized": _worker_initialized,
+ },
+ )
+ return JSONResponse(payload, status_code=status_code)
# OAuth and Token handlers (initialize after worker setup)
_oauth_handlers = None
@@ -304,6 +350,8 @@ async def root_info(request):
"version": __version__,
"endpoints": {
"health": "/health",
+ "live": "/live",
+ "ready": "/ready",
"mcp": "/mcp"
}
})
@@ -399,6 +447,8 @@ basic_app = Starlette(
routes=[
Route("/", root_info, methods=["GET"]),
Route("/health", health_check, methods=["GET"]),
+ Route("/live", live_check, methods=["GET"]),
+ Route("/ready", readiness_check, methods=["GET"]),
# OAuth endpoints
Route("/auth/login", oauth_login, methods=["GET"]),
Route("/auth/callback", oauth_callback, methods=["GET"]),
diff --git a/doris_mcp_server/utils/db.py b/doris_mcp_server/utils/db.py
index ac06b4b..5e1f37a 100644
--- a/doris_mcp_server/utils/db.py
+++ b/doris_mcp_server/utils/db.py
@@ -1507,7 +1507,32 @@ class DorisConnectionManager:
except Exception as e:
self.logger.error(f"Database connectivity test failed: {e}")
return False
-
+
+ async def check_readiness(self, *, timeout_seconds: float = 2.0) -> bool:
+ """Run a bounded, side-effect-free Doris readiness probe."""
+ if not self._has_valid_global_config():
+ return False
+ try:
+ timeout = float(timeout_seconds)
+ except (TypeError, ValueError):
+ timeout = 2.0
+ timeout = min(max(timeout, 0.05), 5.0)
+ try:
+ await asyncio.wait_for(
+ self._test_basic_connectivity(),
+ timeout=timeout,
+ )
+ return True
+ except TimeoutError:
+ self.logger.debug(
+ "Doris readiness probe timed out after %s seconds",
+ timeout,
+ )
+ return False
+ except Exception as exc:
+ self.logger.debug("Doris readiness probe failed: %s", exc)
+ return False
+
async def _test_basic_connectivity(self) -> None:
"""
Test basic database connectivity without connection pool
diff --git a/test/integration/test_real_doris_transports.py
b/test/integration/test_real_doris_transports.py
index 3f3dc01..0c70a04 100644
--- a/test/integration/test_real_doris_transports.py
+++ b/test/integration/test_real_doris_transports.py
@@ -247,21 +247,22 @@ async def _wait_for_http_server(
pytrace=False,
)
try:
- response = await client.get(f"http://127.0.0.1:{port}/health")
+ response = await client.get(f"http://127.0.0.1:{port}/live")
if response.status_code == 200:
assert response.json()["version"] == __version__
+ assert response.json()["status"] == "alive"
return
except httpx.HTTPError:
pass
await asyncio.sleep(0.1)
pytest.fail(
- "HTTP MCP process did not become healthy:\n" +
_read_process_log(log_file),
+ "HTTP MCP process did not become live:\n" +
_read_process_log(log_file),
pytrace=False,
)
@asynccontextmanager
-async def _http_client(environment: dict[str, str]) -> AsyncIterator[Client]:
+async def _http_process(environment: dict[str, str]) -> AsyncIterator[str]:
port = _free_tcp_port()
with tempfile.TemporaryFile() as log_file:
process = await asyncio.create_subprocess_exec(
@@ -281,15 +282,21 @@ async def _http_client(environment: dict[str, str]) ->
AsyncIterator[Client]:
)
try:
await _wait_for_http_server(process, port, log_file)
- async with Client(
- f"http://127.0.0.1:{port}/mcp",
- read_timeout_seconds=15,
- ) as client:
- yield client
+ yield f"http://127.0.0.1:{port}"
finally:
await _stop_process(process)
+@asynccontextmanager
+async def _http_client(environment: dict[str, str]) -> AsyncIterator[Client]:
+ async with _http_process(environment) as base_url:
+ async with Client(
+ f"{base_url}/mcp",
+ read_timeout_seconds=15,
+ ) as client:
+ yield client
+
+
@asynccontextmanager
async def _stdio_client(environment: dict[str, str]) -> AsyncIterator[Client]:
parameters = StdioServerParameters(
@@ -346,6 +353,32 @@ async def _exec_query(
return result, result.structured_content
+async def test_real_doris_liveness_and_readiness(
+ doris_sandbox: DorisSandbox,
+) -> None:
+ environment = _server_environment(
+ doris_sandbox.settings,
+ user=doris_sandbox.settings.user,
+ password=doris_sandbox.settings.password,
+ )
+ async with _http_process(environment) as base_url:
+ async with httpx.AsyncClient(timeout=5) as client:
+ health = await client.get(f"{base_url}/health")
+ live = await client.get(f"{base_url}/live")
+ ready = await client.get(f"{base_url}/ready")
+
+ assert health.status_code == 200
+ assert health.json()["status"] == "healthy"
+ assert live.status_code == 200
+ assert live.json()["status"] == "alive"
+ assert ready.status_code == 200
+ assert ready.json()["status"] == "ready"
+ assert ready.json()["checks"] == {
+ "service": "ready",
+ "doris": "ready",
+ }
+
+
@pytest.mark.parametrize("transport", ["http", "stdio"])
async def test_real_doris_read_write_permission_timeout_and_recovery(
transport: str,
diff --git a/test/protocol/test_health_endpoints.py
b/test/protocol/test_health_endpoints.py
new file mode 100644
index 0000000..84c0114
--- /dev/null
+++ b/test/protocol/test_health_endpoints.py
@@ -0,0 +1,250 @@
+# 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.
+"""Liveness and readiness regression tests."""
+
+from __future__ import annotations
+
+import asyncio
+import os
+import socket
+import subprocess
+import sys
+import tempfile
+import time
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import httpx
+import pytest
+from mcp import Client
+
+from doris_mcp_server import __version__
+from doris_mcp_server.health import liveness_payload, readiness_payload
+from doris_mcp_server.utils.config import DorisConfig
+from doris_mcp_server.utils.db import DorisConnectionManager
+
+PROJECT_ROOT = Path(__file__).resolve().parents[2]
+
+
+def _free_tcp_port() -> int:
+ with socket.socket() as sock:
+ sock.bind(("127.0.0.1", 0))
+ return int(sock.getsockname()[1])
+
+
+async def _stop_process(process: asyncio.subprocess.Process) -> None:
+ if process.returncode is not None:
+ return
+ process.terminate()
+ try:
+ await asyncio.wait_for(process.wait(), timeout=10)
+ except TimeoutError:
+ process.kill()
+ await process.wait()
+
+
+async def _wait_for_live(
+ process: asyncio.subprocess.Process,
+ port: int,
+ log_file,
+) -> None:
+ deadline = time.monotonic() + 15
+ async with httpx.AsyncClient(timeout=0.5) as client:
+ while time.monotonic() < deadline:
+ if process.returncode is not None:
+ log_file.seek(0)
+ pytest.fail(
+ "HTTP process exited before liveness became available:\n"
+ + log_file.read().decode("utf-8", errors="replace"),
+ pytrace=False,
+ )
+ try:
+ response = await client.get(f"http://127.0.0.1:{port}/live")
+ if response.status_code == 200:
+ assert response.json()["status"] == "alive"
+ return
+ except httpx.HTTPError:
+ pass
+ await asyncio.sleep(0.1)
+ log_file.seek(0)
+ pytest.fail(
+ "HTTP process did not expose liveness:\n"
+ + log_file.read().decode("utf-8", errors="replace"),
+ pytrace=False,
+ )
+
+
+def test_liveness_payload_is_database_independent() -> None:
+ assert liveness_payload(
+ service="doris-mcp-server",
+ version=__version__,
+ ) == {
+ "status": "alive",
+ "service": "doris-mcp-server",
+ "version": __version__,
+ "checks": {"process": "alive"},
+ }
+
+
[email protected]
+async def test_readiness_recovers_without_changing_liveness() -> None:
+ manager = SimpleNamespace(
+ check_readiness=AsyncMock(side_effect=[False, True]),
+ )
+
+ unavailable, unavailable_status = await readiness_payload(
+ manager,
+ service="doris-mcp-server",
+ version=__version__,
+ )
+ recovered, recovered_status = await readiness_payload(
+ manager,
+ service="doris-mcp-server",
+ version=__version__,
+ )
+
+ assert unavailable_status == 503
+ assert unavailable["status"] == "not_ready"
+ assert unavailable["checks"]["doris"] == "not_ready"
+ assert recovered_status == 200
+ assert recovered["status"] == "ready"
+ assert recovered["checks"]["doris"] == "ready"
+ assert (
+ liveness_payload(
+ service="doris-mcp-server",
+ version=__version__,
+ )["status"]
+ == "alive"
+ )
+
+
[email protected]
+async def test_readiness_has_a_hard_timeout() -> None:
+ async def never_finishes(*, timeout_seconds: float) -> bool:
+ del timeout_seconds
+ await asyncio.sleep(60)
+ return True
+
+ manager = SimpleNamespace(check_readiness=never_finishes)
+ started = time.monotonic()
+ payload, status_code = await readiness_payload(
+ manager,
+ service="doris-mcp-server",
+ version=__version__,
+ timeout_seconds=0.01,
+ )
+
+ assert time.monotonic() - started < 0.5
+ assert status_code == 503
+ assert payload["status"] == "not_ready"
+
+
[email protected]
+async def test_connection_readiness_uses_a_direct_probe() -> None:
+ config = DorisConfig()
+ manager = DorisConnectionManager(config)
+ manager._test_basic_connectivity = AsyncMock(return_value=None)
+
+ assert await manager.check_readiness(timeout_seconds=60) is True
+ manager._test_basic_connectivity.assert_awaited_once_with()
+
+
[email protected]
+async def test_connection_readiness_direct_probe_times_out() -> None:
+ config = DorisConfig()
+ manager = DorisConnectionManager(config)
+
+ async def slow_connectivity() -> None:
+ await asyncio.sleep(60)
+
+ manager._test_basic_connectivity = slow_connectivity
+ started = time.monotonic()
+ assert await manager.check_readiness(timeout_seconds=0.01) is False
+ assert time.monotonic() - started < 0.5
+
+
[email protected]
[email protected]("workers", [1, 2])
+async def test_http_process_stays_live_when_doris_is_unavailable(
+ workers: int,
+) -> None:
+ server_port = _free_tcp_port()
+ unavailable_doris_port = _free_tcp_port()
+ environment = os.environ.copy()
+ environment.update(
+ {
+ "DORIS_HOST": "127.0.0.1",
+ "DORIS_PORT": str(unavailable_doris_port),
+ "DORIS_USER": "root",
+ "DORIS_PASSWORD": "",
+ "DORIS_DATABASE": "information_schema",
+ "DORIS_CONNECTION_TIMEOUT": "1",
+ "ENABLE_TOKEN_AUTH": "false",
+ "ENABLE_JWT_AUTH": "false",
+ "ENABLE_OAUTH_AUTH": "false",
+ "ENABLE_DORIS_OAUTH_AUTH": "false",
+ "LOG_LEVEL": "ERROR",
+ "PYTHONUNBUFFERED": "1",
+ }
+ )
+
+ with tempfile.TemporaryFile() as log_file:
+ process = await asyncio.create_subprocess_exec(
+ sys.executable,
+ "-m",
+ "doris_mcp_server",
+ "--transport",
+ "http",
+ "--host",
+ "127.0.0.1",
+ "--port",
+ str(server_port),
+ "--workers",
+ str(workers),
+ cwd=PROJECT_ROOT,
+ env=environment,
+ stdout=log_file,
+ stderr=subprocess.STDOUT,
+ )
+ try:
+ await _wait_for_live(process, server_port, log_file)
+ async with httpx.AsyncClient(timeout=3) as client:
+ health = await
client.get(f"http://127.0.0.1:{server_port}/health")
+ live = await client.get(f"http://127.0.0.1:{server_port}/live")
+ started = time.monotonic()
+ ready = await
client.get(f"http://127.0.0.1:{server_port}/ready")
+ readiness_elapsed = time.monotonic() - started
+
+ assert health.status_code == 200
+ assert health.json()["status"] == "healthy"
+ assert live.status_code == 200
+ assert live.json()["status"] == "alive"
+ assert ready.status_code == 503
+ assert ready.json()["status"] == "not_ready"
+ assert readiness_elapsed < 3
+
+ async with Client(
+ f"http://127.0.0.1:{server_port}/mcp",
+ read_timeout_seconds=10,
+ ) as client:
+ tools = await client.list_tools(cache_mode="bypass")
+ assert tools.tools
+
+ assert process.returncode is None
+ finally:
+ await _stop_process(process)
diff --git a/test/protocol/test_multiworker_config.py
b/test/protocol/test_multiworker_config.py
index fdc5810..cccd204 100644
--- a/test/protocol/test_multiworker_config.py
+++ b/test/protocol/test_multiworker_config.py
@@ -1,10 +1,17 @@
import json
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
import pytest
from doris_mcp_server import __version__
from doris_mcp_server.main import _multiworker_environment
-from doris_mcp_server.multiworker_app import health_check, root_info
+from doris_mcp_server.multiworker_app import (
+ health_check,
+ live_check,
+ readiness_check,
+ root_info,
+)
from doris_mcp_server.utils.config import DorisConfig
@@ -44,9 +51,45 @@ def
test_multiworker_environment_preserves_resolved_parent_config(monkeypatch):
@pytest.mark.asyncio
async def test_multiworker_http_identity_reports_product_version():
- for handler in (health_check, root_info):
+ for handler in (health_check, live_check, root_info):
response = await handler(None)
payload = json.loads(response.body)
assert payload["service"] == "doris-mcp-server"
assert payload["version"] == __version__
assert "mcp_version" not in payload
+
+
[email protected]
+async def test_multiworker_readiness_requires_initialization(monkeypatch):
+ from doris_mcp_server import multiworker_app
+
+ readiness_probe = AsyncMock(
+ side_effect=AssertionError("uninitialized worker must not probe Doris")
+ )
+ manager = SimpleNamespace(check_readiness=readiness_probe)
+ monkeypatch.setattr(multiworker_app, "_worker_initialized", False)
+ monkeypatch.setattr(multiworker_app, "_worker_connection_manager", manager)
+
+ response = await readiness_check(None)
+ payload = json.loads(response.body)
+ assert response.status_code == 503
+ assert payload["status"] == "not_ready"
+ assert payload["checks"]["service"] == "not_ready"
+ readiness_probe.assert_not_awaited()
+
+
[email protected]
+async def test_multiworker_readiness_uses_worker_database_probe(monkeypatch):
+ from doris_mcp_server import multiworker_app
+
+ readiness_probe = AsyncMock(return_value=True)
+ manager = SimpleNamespace(check_readiness=readiness_probe)
+ monkeypatch.setattr(multiworker_app, "_worker_initialized", True)
+ monkeypatch.setattr(multiworker_app, "_worker_connection_manager", manager)
+
+ response = await readiness_check(None)
+ payload = json.loads(response.body)
+ assert response.status_code == 200
+ assert payload["status"] == "ready"
+ assert payload["checks"]["doris"] == "ready"
+ readiness_probe.assert_awaited_once_with(timeout_seconds=2.0)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]