Files
2026-07-13 13:22:34 +08:00

320 lines
8.8 KiB
Python

import signal
import socket
import subprocess
import sys
import time
from unittest import mock
import pytest
from mlflow import server
from mlflow.environment_variables import _MLFLOW_SGI_NAME
from mlflow.exceptions import MlflowException
from mlflow.utils import find_free_port
from mlflow.utils.os import is_windows
@pytest.fixture
def mock_exec_cmd():
with mock.patch("mlflow.server._exec_cmd") as m:
yield m
def _wait_for_port(host: str, port: int, proc: subprocess.Popen, timeout: int = 15) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
if proc.poll() is not None:
stdout, stderr = proc.communicate()
raise AssertionError(
"MLflow server exited before accepting connections.\n"
f"stdout:\n{stdout}\n"
f"stderr:\n{stderr}"
)
try:
with socket.create_connection((host, port), timeout=1):
return
except OSError:
time.sleep(0.1)
raise AssertionError(f"Timed out waiting for {host}:{port} to accept connections")
def _wait_for_port_closed(host: str, port: int, timeout: int = 15) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
try:
with socket.create_connection((host, port), timeout=1):
time.sleep(0.1)
except OSError:
return
raise AssertionError(f"Timed out waiting for {host}:{port} to close")
def test_find_app_custom_app_plugin():
assert server._find_app("custom_app") == "mlflow_test_plugin.app:custom_app"
def test_find_app_non_existing_app():
with pytest.raises(MlflowException, match=r"Failed to find app 'does_not_exist'"):
server._find_app("does_not_exist")
def test_build_waitress_command():
assert server._build_waitress_command(
"", "localhost", "5000", f"{server.__name__}:app", is_factory=True
) == [
sys.executable,
"-m",
"waitress",
"--host=localhost",
"--port=5000",
"--ident=mlflow",
"--call",
"mlflow.server:app",
]
assert server._build_waitress_command(
"", "localhost", "5000", f"{server.__name__}:app", is_factory=False
) == [
sys.executable,
"-m",
"waitress",
"--host=localhost",
"--port=5000",
"--ident=mlflow",
"mlflow.server:app",
]
def test_build_gunicorn_command():
assert server._build_gunicorn_command(
"", "localhost", "5000", "4", f"{server.__name__}:app"
) == [
sys.executable,
"-m",
"gunicorn",
"-b",
"localhost:5000",
"-w",
"4",
"mlflow.server:app",
]
def test_build_uvicorn_command():
assert server._build_uvicorn_command(
"", "localhost", "5000", "4", "mlflow.server.fastapi_app:app"
) == [
sys.executable,
"-m",
"uvicorn",
"--log-config",
str(server._UVICORN_LOG_CONFIG),
"--host",
"localhost",
"--port",
"5000",
"--workers",
"4",
"mlflow.server.fastapi_app:app",
]
# Test with custom uvicorn options
assert server._build_uvicorn_command(
"--reload --log-level debug", "localhost", "5000", "4", "mlflow.server.fastapi_app:app"
) == [
sys.executable,
"-m",
"uvicorn",
"--reload",
"--log-level",
"debug",
"--log-config",
str(server._UVICORN_LOG_CONFIG),
"--host",
"localhost",
"--port",
"5000",
"--workers",
"4",
"mlflow.server.fastapi_app:app",
]
assert server._build_uvicorn_command(
"", "localhost", "5000", "4", "mlflow.server.fastapi_app:app", None, is_factory=True
) == [
sys.executable,
"-m",
"uvicorn",
"--log-config",
str(server._UVICORN_LOG_CONFIG),
"--host",
"localhost",
"--port",
"5000",
"--workers",
"4",
"--factory",
"mlflow.server.fastapi_app:app",
]
def test_build_uvicorn_command_with_env_file():
cmd = server._build_uvicorn_command(
uvicorn_opts=None,
host="localhost",
port=5000,
workers=4,
app_name="app:app",
env_file="/path/to/.env",
)
assert "--env-file" in cmd
assert "/path/to/.env" in cmd
assert "--log-config" in cmd
# Verify the order - env-file should come before the app name
env_file_idx = cmd.index("--env-file")
env_file_path_idx = cmd.index("/path/to/.env")
app_name_idx = cmd.index("app:app")
assert env_file_idx < app_name_idx
assert env_file_path_idx == env_file_idx + 1
assert env_file_path_idx < app_name_idx
def test_run_server(mock_exec_cmd, monkeypatch):
monkeypatch.setenv("MLFLOW_SERVER_ENABLE_JOB_EXECUTION", "false")
with mock.patch("sys.platform", return_value="linux"):
server._run_server(
file_store_path="",
registry_store_uri="",
default_artifact_root="",
serve_artifacts="",
artifacts_only="",
artifacts_destination="",
host="",
port="",
)
mock_exec_cmd.assert_called_once()
def test_run_server_win32(mock_exec_cmd, monkeypatch):
monkeypatch.setenv("MLFLOW_SERVER_ENABLE_JOB_EXECUTION", "false")
with mock.patch("sys.platform", return_value="win32"):
server._run_server(
file_store_path="",
registry_store_uri="",
default_artifact_root="",
serve_artifacts="",
artifacts_only="",
artifacts_destination="",
host="",
port="",
)
mock_exec_cmd.assert_called_once()
def test_run_server_with_uvicorn(mock_exec_cmd, monkeypatch):
monkeypatch.setenv("MLFLOW_SERVER_ENABLE_JOB_EXECUTION", "false")
with mock.patch("sys.platform", return_value="linux"):
server._run_server(
file_store_path="",
registry_store_uri="",
default_artifact_root="",
serve_artifacts="",
artifacts_only="",
artifacts_destination="",
host="localhost",
port="5000",
uvicorn_opts="--reload",
)
expected_command = [
sys.executable,
"-m",
"uvicorn",
"--reload",
"--log-config",
str(server._UVICORN_LOG_CONFIG),
"--host",
"localhost",
"--port",
"5000",
"--workers",
"4",
"mlflow.server.fastapi_app:app",
]
mock_exec_cmd.assert_called_once_with(
expected_command,
extra_env={
_MLFLOW_SGI_NAME.name: "uvicorn",
},
capture_output=False,
synchronous=False,
)
@pytest.mark.parametrize(
"uvicorn_opts",
[
"--log-config /custom/path.yaml",
"--log-config=/custom/path.yaml",
],
)
def test_build_uvicorn_command_user_log_config_takes_precedence(uvicorn_opts):
cmd = server._build_uvicorn_command(
uvicorn_opts, "localhost", "5000", "4", "mlflow.server.fastapi_app:app"
)
assert not any("uvicorn_log_config.yaml" in o for o in cmd)
@pytest.mark.parametrize(
"sig",
[
pytest.param(
signal.SIGTERM,
marks=pytest.mark.skipif(is_windows(), reason="SIGTERM is a hard kill on Windows"),
),
signal.SIGINT,
],
)
def test_mlflow_server_shuts_down_on_signal(sig: signal.Signals, tmp_path):
port = find_free_port()
db_path = tmp_path / "mlflow.db"
cmd = [
sys.executable,
"-m",
"mlflow",
"server",
"--host",
"127.0.0.1",
"--port",
str(port),
"--workers",
"1",
"--backend-store-uri",
f"sqlite:///{db_path}",
]
if is_windows():
proc = subprocess.Popen(cmd, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
else:
proc = subprocess.Popen(cmd)
try:
_wait_for_port("127.0.0.1", port, proc, timeout=60 if is_windows() else 15)
if is_windows():
proc.send_signal(signal.CTRL_BREAK_EVENT)
else:
proc.send_signal(sig)
proc.wait(timeout=30 if is_windows() else 15)
_wait_for_port_closed("127.0.0.1", port)
# Exit code 0 means graceful shutdown (signal was caught and handled)
# -sig or 128+sig means the process was killed by the signal
# On Windows, CTRL_BREAK_EVENT maps to 0xC000013A.
if is_windows():
assert proc.returncode in (0, 0xC000013A)
else:
assert proc.returncode in (0, -sig, 128 + sig)
finally:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()