dynamo/tests/frontend/grpc/test_tensor_mocker_engine.py

220 lines
7.8 KiB
Python

# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# Parallelization: Hermetic test (xdist-safe via dynamic ports).
# Tested on: Linux (Ubuntu 24.04 container), Intel(R) Core(TM) i9-14900K, 32 vCPU.
# Combined pre_merge wall time (this file + test_tensor_parameters.py):
# - Serialized: 87.48s.
# - Parallel (-n auto): 25.27s (62.21s saved, 3.46x).
# GPU Requirement: gpu_0 (CPU-only, echo worker does not use GPU)
"""gRPC tensor echo test with mocker worker."""
from __future__ import annotations
import logging
import os
import queue
import shutil
from functools import partial
import numpy as np
import pytest
import triton_echo_client
import tritonclient.grpc as grpcclient
from tests.utils.constants import QWEN
from tests.utils.managed_process import ManagedProcess
logger = logging.getLogger(__name__)
TEST_MODEL = QWEN
class MockWorkerProcess(ManagedProcess):
def __init__(self, request, system_port: int, worker_id: str = "mocker-worker"):
self.worker_id = worker_id
self.system_port = system_port
command = [
"python3",
os.path.join(os.path.dirname(__file__), "echo_tensor_worker.py"),
]
env = os.environ.copy()
env["DYN_LOG"] = "debug"
env["DYN_SYSTEM_USE_ENDPOINT_HEALTH_STATUS"] = '["generate"]'
env["DYN_SYSTEM_PORT"] = str(system_port)
log_dir = f"{request.node.name}_{worker_id}"
try:
shutil.rmtree(log_dir)
except FileNotFoundError:
pass
super().__init__(
command=command,
env=env,
health_check_urls=[
# gRPC doesn't expose endpoint for listing models, so skip this check
# (f"http://localhost:{grpc_port}/v1/models", check_models_api),
(f"http://localhost:{system_port}/health", self.is_ready),
],
timeout=300,
display_output=True,
terminate_all_matching_process_names=False,
stragglers=[],
straggler_commands=["echo_tensor_worker.py"],
log_dir=log_dir,
)
def is_ready(self, response) -> bool:
try:
status = (response.json() or {}).get("status")
except ValueError:
logger.warning("%s health response is not valid JSON", self.worker_id)
return False
is_ready = status == "ready"
if is_ready:
logger.info("%s status is ready", self.worker_id)
else:
logger.warning("%s status is not ready: %s", self.worker_id, status)
return is_ready
@pytest.fixture(scope="function")
def start_services_with_echo_worker(request, start_services_with_grpc):
"""Start echo worker with the shared gRPC frontend.
Function-scoped to allow parallel test execution.
Each test gets its own gRPC frontend + echo worker on unique ports.
No namespace conflicts because runtime_services_dynamic_ports provides isolated Etcd/NATS.
"""
frontend_port, system_port = start_services_with_grpc
with MockWorkerProcess(request, system_port):
logger.info(f"gRPC Echo Worker started for test on port {frontend_port}")
yield frontend_port
@pytest.mark.pre_merge
@pytest.mark.gpu_0 # Echo worker is CPU-only (no GPU required)
@pytest.mark.parallel
@pytest.mark.integration
@pytest.mark.model(TEST_MODEL)
def test_echo(start_services_with_echo_worker) -> None:
frontend_port = start_services_with_echo_worker
# Use a per-test client instance to avoid cross-test/global state issues.
client = triton_echo_client.TritonEchoClient(grpc_port=frontend_port)
client.check_health()
client.run_infer()
client.run_stream_infer()
client.get_config()
@pytest.mark.e2e
@pytest.mark.pre_merge
@pytest.mark.gpu_0 # Echo tensor worker is CPU-only (no GPU required)
@pytest.mark.parallel
@pytest.mark.parametrize(
"request_params",
[
{"malformed_response": True},
{"raise_exception": True},
],
ids=["malformed_response", "raise_exception"],
)
def test_model_infer_failure(start_services_with_echo_worker, request_params):
"""Test gRPC request-level parameters are echoed through tensor models.
The worker acts as an identity function: echoes input tensors unchanged and
returns all request parameters plus a "processed" flag to verify the complete
parameter flow through the gRPC frontend.
"""
frontend_port = start_services_with_echo_worker
client = grpcclient.InferenceServerClient(f"localhost:{frontend_port}")
input_data = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float32)
inputs = [grpcclient.InferInput("INPUT", input_data.shape, "FP32")]
inputs[0].set_data_from_numpy(input_data)
# expect exception during inference
with pytest.raises(Exception) as excinfo:
client.infer("echo", inputs=inputs, parameters=request_params)
if "malformed_response" in request_params:
assert "missing field `data_type`" in str(excinfo.value).lower()
elif "raise_exception" in request_params:
assert "intentional exception" in str(excinfo.value).lower()
@pytest.mark.e2e
@pytest.mark.pre_merge
@pytest.mark.gpu_0 # Echo tensor worker is CPU-only (no GPU required)
@pytest.mark.parallel
@pytest.mark.parametrize(
"request_params",
[
{"malformed_response": True},
{"raise_exception": True},
{"data_mismatch": True},
],
ids=["malformed_response", "raise_exception", "data_mismatch"],
)
def test_model_stream_infer_failure(start_services_with_echo_worker, request_params):
"""Test gRPC request-level parameters are echoed through tensor models.
The worker acts as an identity function: echoes input tensors unchanged and
returns all request parameters plus a "processed" flag to verify the complete
parameter flow through the gRPC frontend.
"""
frontend_port = start_services_with_echo_worker
client = grpcclient.InferenceServerClient(f"localhost:{frontend_port}")
input_data = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float32)
inputs = [grpcclient.InferInput("INPUT", input_data.shape, "FP32")]
inputs[0].set_data_from_numpy(input_data)
class UserData:
def __init__(self):
self._completed_requests: queue.Queue[
grpcclient.InferResult | Exception
] = queue.Queue()
# Define the callback function. Note the last two parameters should be
# result and error. InferenceServerClient would povide the results of an
# inference as grpcclient.InferResult in result. For successful
# inference, error will be None, otherwise it will be an object of
# tritonclientutils.InferenceServerException holding the error details
def callback(user_data, result, error):
print("Received callback")
if error:
user_data._completed_requests.put(error)
else:
user_data._completed_requests.put(result)
user_data = UserData()
client.start_stream(
callback=partial(callback, user_data),
)
client.async_stream_infer(
model_name="echo",
inputs=inputs,
parameters=request_params,
)
# For stream infer, the exception and error will pass to the callback but not
# raised
with pytest.raises(Exception) as excinfo:
data_item = user_data._completed_requests.get(timeout=5)
if isinstance(data_item, Exception):
print("Raising exception received from stream infer callback")
raise data_item
if "malformed_response" in request_params:
assert "missing field `data_type`" in str(excinfo.value).lower()
elif "data_mismatch" in request_params:
assert "shape implies" in str(excinfo.value).lower()
elif "raise_exception" in request_params:
assert "intentional exception" in str(excinfo.value).lower()