1075 lines
36 KiB
Python
1075 lines
36 KiB
Python
"""
|
|
HAProxy metrics tests for Ray Serve.
|
|
|
|
These tests verify that Ray Serve metrics work correctly when HAProxy is enabled
|
|
as a replacement for the default Serve HTTP proxy.
|
|
|
|
Key differences from the default Serve proxy:
|
|
1. When HAProxy is enabled, RAY_SERVE_ENABLE_DIRECT_INGRESS is automatically set.
|
|
2. HTTP proxy metrics (serve_num_http_requests, etc.) are emitted from replicas when
|
|
they receive direct ingress requests from HAProxy.
|
|
3. 404 errors for non-existent routes are handled by HAProxy itself (not forwarded to
|
|
replicas), so these won't generate Serve metrics. Tests that need to verify 404
|
|
metrics must deploy an application that returns 404s.
|
|
4. HAProxy has its own metrics exposed on a separate port (default 9101), but these
|
|
tests focus on Serve metrics exposed via the Ray metrics port (9999).
|
|
"""
|
|
import http
|
|
import json
|
|
import sys
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from typing import Dict, Optional
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from starlette.requests import Request
|
|
from starlette.responses import PlainTextResponse
|
|
|
|
import ray
|
|
from ray import serve
|
|
from ray._common.network_utils import parse_address
|
|
from ray._common.test_utils import (
|
|
PrometheusTimeseries,
|
|
SignalActor,
|
|
fetch_prometheus_metrics,
|
|
wait_for_condition,
|
|
)
|
|
from ray._common.utils import reset_ray_address
|
|
from ray.serve import HTTPOptions
|
|
from ray.serve._private.long_poll import LongPollHost, UpdatedObject
|
|
from ray.serve._private.test_utils import (
|
|
expected_proxy_actors,
|
|
get_application_url,
|
|
get_metric_dictionaries,
|
|
)
|
|
from ray.serve._private.utils import block_until_http_ready
|
|
from ray.serve.tests.conftest import (
|
|
TEST_METRICS_EXPORT_PORT,
|
|
wait_for_metrics_endpoint,
|
|
wait_for_metrics_port_free,
|
|
)
|
|
from ray.util.state import list_actors
|
|
|
|
|
|
@pytest.fixture
|
|
def metrics_start_shutdown(request):
|
|
"""Fixture provides a fresh Ray cluster to prevent metrics state sharing."""
|
|
param = request.param if hasattr(request, "param") else None
|
|
request_timeout_s = param if param else None
|
|
wait_for_metrics_port_free()
|
|
ray.init(
|
|
_metrics_export_port=TEST_METRICS_EXPORT_PORT,
|
|
_system_config={
|
|
"metrics_report_interval_ms": 100,
|
|
"task_retry_delay_ms": 50,
|
|
},
|
|
)
|
|
try:
|
|
session_name = ray._private.worker._global_node.session_name
|
|
wait_for_metrics_endpoint(session_name)
|
|
yield serve.start(
|
|
http_options=HTTPOptions(
|
|
host="0.0.0.0",
|
|
request_timeout_s=request_timeout_s,
|
|
),
|
|
)
|
|
finally:
|
|
serve.shutdown()
|
|
ray.shutdown()
|
|
reset_ray_address()
|
|
|
|
|
|
def extract_tags(line: str) -> Dict[str, str]:
|
|
"""Extracts any tags from the metrics line."""
|
|
|
|
try:
|
|
tags_string = line.replace("{", "}").split("}")[1]
|
|
except IndexError:
|
|
# No tags were found in this line.
|
|
return {}
|
|
|
|
detected_tags = {}
|
|
for tag_pair in tags_string.split(","):
|
|
sanitized_pair = tag_pair.replace('"', "")
|
|
tag, value = sanitized_pair.split("=")
|
|
detected_tags[tag] = value
|
|
|
|
return detected_tags
|
|
|
|
|
|
def contains_tags(line: str, expected_tags: Optional[Dict[str, str]] = None) -> bool:
|
|
"""Checks if the metrics line contains the expected tags.
|
|
|
|
Does nothing if expected_tags is None.
|
|
"""
|
|
|
|
if expected_tags is not None:
|
|
detected_tags = extract_tags(line)
|
|
|
|
# Check if expected_tags is a subset of detected_tags
|
|
return expected_tags.items() <= detected_tags.items()
|
|
else:
|
|
return True
|
|
|
|
|
|
def get_metric_float(
|
|
metric: str, expected_tags: Optional[Dict[str, str]] = None
|
|
) -> float:
|
|
"""Gets the float value of metric.
|
|
|
|
If tags is specified, searched for metric with matching tags.
|
|
|
|
Returns -1 if the metric isn't available.
|
|
"""
|
|
|
|
metrics = httpx.get("http://127.0.0.1:9999").text
|
|
metric_value = -1
|
|
for line in metrics.split("\n"):
|
|
if metric in line and contains_tags(line, expected_tags):
|
|
metric_value = line.split(" ")[-1]
|
|
return metric_value
|
|
|
|
|
|
def check_metric_float_eq(
|
|
metric: str, expected: float, expected_tags: Optional[Dict[str, str]] = None
|
|
) -> bool:
|
|
metric_value = get_metric_float(metric, expected_tags)
|
|
assert float(metric_value) == expected
|
|
return True
|
|
|
|
|
|
def check_sum_metric_eq(
|
|
metric_name: str,
|
|
expected: float,
|
|
tags: Optional[Dict[str, str]] = None,
|
|
) -> bool:
|
|
if tags is None:
|
|
tags = {}
|
|
|
|
metrics = fetch_prometheus_metrics(["localhost:9999"])
|
|
metrics = {k: v for k, v in metrics.items() if "ray_serve_" in k}
|
|
metric_samples = metrics.get(metric_name, None)
|
|
if metric_samples is None:
|
|
metric_sum = 0
|
|
else:
|
|
metric_samples = [
|
|
sample for sample in metric_samples if tags.items() <= sample.labels.items()
|
|
]
|
|
metric_sum = sum(sample.value for sample in metric_samples)
|
|
|
|
# Check the metrics sum to the expected number
|
|
assert float(metric_sum) == float(expected), (
|
|
f"The following metrics don't sum to {expected}: "
|
|
f"{json.dumps(metric_samples, indent=4)}\n."
|
|
f"All metrics: {json.dumps(metrics, indent=4)}"
|
|
)
|
|
|
|
# # For debugging
|
|
if metric_samples:
|
|
print(f"The following sum to {expected} for '{metric_name}' and tags {tags}:")
|
|
for sample in metric_samples:
|
|
print(sample)
|
|
|
|
return True
|
|
|
|
|
|
def test_serve_metrics_for_successful_connection(metrics_start_shutdown):
|
|
@serve.deployment(name="metrics")
|
|
async def f(request):
|
|
return "hello"
|
|
|
|
app_name = "app1"
|
|
handle = serve.run(target=f.bind(), name=app_name)
|
|
|
|
http_url = get_application_url(app_name=app_name)
|
|
# send 10 concurrent requests
|
|
ray.get([block_until_http_ready.remote(http_url) for _ in range(10)])
|
|
[handle.remote(http_url) for _ in range(10)]
|
|
|
|
def verify_metrics(do_assert=False):
|
|
try:
|
|
resp = httpx.get("http://127.0.0.1:9999").text
|
|
# Requests will fail if we are crashing the controller
|
|
except httpx.HTTPError:
|
|
return False
|
|
|
|
# NOTE: These metrics should be documented at
|
|
# https://docs.ray.io/en/latest/serve/monitoring.html#metrics
|
|
# Any updates to here should be reflected there too.
|
|
expected_metrics = [
|
|
# counter
|
|
"ray_serve_num_router_requests",
|
|
"ray_serve_num_http_requests",
|
|
"ray_serve_deployment_queued_queries",
|
|
"ray_serve_deployment_request_counter",
|
|
"ray_serve_deployment_replica_starts",
|
|
# histogram
|
|
"ray_serve_deployment_processing_latency_ms_bucket",
|
|
"ray_serve_deployment_processing_latency_ms_count",
|
|
"ray_serve_deployment_processing_latency_ms_sum",
|
|
"ray_serve_deployment_processing_latency_ms",
|
|
# gauge
|
|
"ray_serve_replica_processing_queries",
|
|
"ray_serve_deployment_replica_healthy",
|
|
# handle
|
|
"ray_serve_handle_request_counter",
|
|
]
|
|
|
|
for metric in expected_metrics:
|
|
# For the final error round
|
|
if do_assert:
|
|
assert metric in resp
|
|
# For the wait_for_condition
|
|
else:
|
|
if metric not in resp:
|
|
return False
|
|
return True
|
|
|
|
try:
|
|
wait_for_condition(verify_metrics, retry_interval_ms=500, timeout=40)
|
|
except RuntimeError:
|
|
verify_metrics(do_assert=True)
|
|
|
|
|
|
def test_http_replica_gauge_metrics(metrics_start_shutdown):
|
|
"""Test http replica gauge metrics"""
|
|
signal = SignalActor.remote()
|
|
|
|
@serve.deployment(graceful_shutdown_timeout_s=0.0001)
|
|
class A:
|
|
async def __call__(self):
|
|
await signal.wait.remote()
|
|
|
|
handle = serve.run(A.bind(), name="app1")
|
|
_ = handle.remote()
|
|
|
|
processing_requests = get_metric_dictionaries(
|
|
"ray_serve_replica_processing_queries", timeout=5
|
|
)
|
|
assert len(processing_requests) == 1
|
|
assert processing_requests[0]["deployment"] == "A"
|
|
assert processing_requests[0]["application"] == "app1"
|
|
print("ray_serve_replica_processing_queries exists.")
|
|
|
|
def ensure_request_processing():
|
|
resp = httpx.get("http://127.0.0.1:9999").text
|
|
resp = resp.split("\n")
|
|
for metrics in resp:
|
|
if "# HELP" in metrics or "# TYPE" in metrics:
|
|
continue
|
|
if "ray_serve_replica_processing_queries" in metrics:
|
|
assert "1.0" in metrics
|
|
return True
|
|
|
|
wait_for_condition(ensure_request_processing, timeout=5)
|
|
|
|
|
|
def test_proxy_metrics_not_found(metrics_start_shutdown):
|
|
# NOTE: When using HAProxy, 404 errors for non-existent routes are handled
|
|
# by HAProxy itself (not forwarded to replicas), so we need to deploy an
|
|
# application and test 404s within that application's context.
|
|
# These metrics should be documented at
|
|
# https://docs.ray.io/en/latest/serve/monitoring.html#metrics
|
|
# Any updates here should be reflected there too.
|
|
expected_metrics = [
|
|
"ray_serve_num_http_requests",
|
|
"ray_serve_num_http_error_requests_total",
|
|
"ray_serve_num_deployment_http_error_requests",
|
|
"ray_serve_http_request_latency_ms",
|
|
]
|
|
|
|
app = FastAPI()
|
|
|
|
@serve.deployment(name="A")
|
|
@serve.ingress(app)
|
|
class A:
|
|
@app.get("/existing-path") # Only this path is defined
|
|
async def handler(self, request: Request):
|
|
return {"message": "success"}
|
|
|
|
app_name = "app"
|
|
serve.run(A.bind(), name=app_name, route_prefix="/A")
|
|
|
|
def verify_metrics(_expected_metrics, do_assert=False):
|
|
try:
|
|
resp = httpx.get("http://127.0.0.1:9999").text
|
|
# Requests will fail if we are crashing the controller
|
|
except httpx.HTTPError:
|
|
return False
|
|
for metric in _expected_metrics:
|
|
if do_assert:
|
|
assert metric in resp
|
|
if metric not in resp:
|
|
return False
|
|
return True
|
|
|
|
# Trigger HTTP 404 error via the deployed application
|
|
httpx.get("http://127.0.0.1:8000/A/nonexistent")
|
|
httpx.get("http://127.0.0.1:8000/A/nonexistent")
|
|
|
|
# Ensure all expected metrics are present.
|
|
try:
|
|
wait_for_condition(
|
|
verify_metrics,
|
|
retry_interval_ms=1000,
|
|
timeout=10,
|
|
expected_metrics=expected_metrics,
|
|
)
|
|
except RuntimeError:
|
|
verify_metrics(expected_metrics, True)
|
|
|
|
def verify_error_count(do_assert=False):
|
|
resp = httpx.get("http://127.0.0.1:9999").text
|
|
resp = resp.split("\n")
|
|
http_error_count = 0
|
|
deployment_404_count = 0
|
|
|
|
for metrics in resp:
|
|
if "# HELP" in metrics or "# TYPE" in metrics:
|
|
continue
|
|
# Skip health check metrics
|
|
if "/-/healthz" in metrics:
|
|
continue
|
|
if (
|
|
"ray_serve_num_http_error_requests_total" in metrics
|
|
and 'route="/A"' in metrics
|
|
):
|
|
# Accumulate error counts from route "/A"
|
|
http_error_count += int(float(metrics.split(" ")[-1]))
|
|
elif (
|
|
"ray_serve_num_deployment_http_error_requests_total" in metrics
|
|
and 'route="/A"' in metrics
|
|
and 'error_code="404"' in metrics
|
|
):
|
|
# Count deployment 404 errors
|
|
deployment_404_count += int(float(metrics.split(" ")[-1]))
|
|
|
|
# We expect 2 requests total, both should be 404 errors from the deployment
|
|
if do_assert:
|
|
assert (
|
|
http_error_count == 2
|
|
), f"Expected at least 2 HTTP errors, got {http_error_count}"
|
|
assert (
|
|
deployment_404_count == 2
|
|
), f"Expected 2 deployment 404 errors, got {deployment_404_count}"
|
|
|
|
return http_error_count >= 2 and deployment_404_count == 2
|
|
|
|
# There is a latency in updating the counter
|
|
try:
|
|
wait_for_condition(verify_error_count, retry_interval_ms=1000, timeout=20)
|
|
except RuntimeError:
|
|
verify_error_count(do_assert=True)
|
|
|
|
|
|
def test_proxy_metrics_internal_error(metrics_start_shutdown):
|
|
# NOTE: When using HAProxy, we need the replica to stay alive to emit metrics.
|
|
# Instead of crashing the actor (which prevents metric emission), we return
|
|
# a 500 error explicitly.
|
|
# These metrics should be documented at
|
|
# https://docs.ray.io/en/latest/serve/monitoring.html#metrics
|
|
# Any updates here should be reflected there too.
|
|
expected_metrics = [
|
|
"ray_serve_num_http_requests",
|
|
"ray_serve_num_http_error_requests_total",
|
|
"ray_serve_num_deployment_http_error_requests",
|
|
"ray_serve_http_request_latency_ms",
|
|
]
|
|
|
|
def verify_metrics(_expected_metrics, do_assert=False):
|
|
try:
|
|
resp = httpx.get("http://127.0.0.1:9999", timeout=None).text
|
|
# Requests will fail if we are crashing the controller
|
|
except httpx.HTTPError:
|
|
return False
|
|
for metric in _expected_metrics:
|
|
if do_assert:
|
|
assert metric in resp
|
|
if metric not in resp:
|
|
return False
|
|
return True
|
|
|
|
@serve.deployment(name="A")
|
|
class A:
|
|
async def __init__(self):
|
|
pass
|
|
|
|
async def __call__(self, request: Request):
|
|
# Return 500 Internal Server Error
|
|
return PlainTextResponse("Internal Server Error", status_code=500)
|
|
|
|
app_name = "app"
|
|
serve.run(A.bind(), name=app_name, route_prefix="/")
|
|
|
|
resp1 = httpx.get("http://localhost:8000/", timeout=None)
|
|
resp2 = httpx.get("http://localhost:8000/", timeout=None)
|
|
assert resp1.status_code == 500
|
|
assert resp2.status_code == 500
|
|
|
|
# Ensure all expected metrics are present.
|
|
try:
|
|
wait_for_condition(
|
|
verify_metrics,
|
|
retry_interval_ms=1000,
|
|
timeout=10,
|
|
expected_metrics=expected_metrics,
|
|
)
|
|
except RuntimeError:
|
|
verify_metrics(expected_metrics, True)
|
|
|
|
def verify_error_count(do_assert=False):
|
|
resp = httpx.get("http://127.0.0.1:9999", timeout=None).text
|
|
resp = resp.split("\n")
|
|
http_error_count = 0
|
|
deployment_error_count = 0
|
|
|
|
for metrics in resp:
|
|
if "# HELP" in metrics or "# TYPE" in metrics:
|
|
continue
|
|
if (
|
|
"ray_serve_num_http_error_requests_total" in metrics
|
|
and 'route="/"' in metrics
|
|
and 'error_code="500"' in metrics
|
|
):
|
|
http_error_count += int(float(metrics.split(" ")[-1]))
|
|
elif (
|
|
"ray_serve_num_deployment_http_error_requests" in metrics
|
|
and 'deployment="A"' in metrics
|
|
and 'error_code="500"' in metrics
|
|
):
|
|
deployment_error_count += int(float(metrics.split(" ")[-1]))
|
|
|
|
# We expect 2 requests total, both should be 500 errors
|
|
if do_assert:
|
|
assert (
|
|
http_error_count == 2
|
|
), f"Expected at least 2 HTTP 500 errors, got {http_error_count}"
|
|
assert (
|
|
deployment_error_count == 2
|
|
), f"Expected at least 2 deployment 500 errors, got {deployment_error_count}"
|
|
|
|
return http_error_count == 2 and deployment_error_count == 2
|
|
|
|
# There is a latency in updating the counter
|
|
try:
|
|
wait_for_condition(verify_error_count, retry_interval_ms=1000, timeout=30)
|
|
except RuntimeError:
|
|
verify_error_count(do_assert=True)
|
|
|
|
|
|
def test_proxy_metrics_fields_not_found(metrics_start_shutdown):
|
|
"""Tests the proxy metrics' fields' behavior for not found.
|
|
|
|
Note: When using HAProxy, we need to deploy an application that returns 404,
|
|
as HAProxy handles non-existent route 404s itself without forwarding to replicas.
|
|
"""
|
|
# These metrics should be documented at
|
|
# https://docs.ray.io/en/latest/serve/monitoring.html#metrics
|
|
# Any updates here should be reflected there too.
|
|
expected_metrics = [
|
|
"ray_serve_num_http_requests",
|
|
"ray_serve_num_http_error_requests_total",
|
|
"ray_serve_num_deployment_http_error_requests",
|
|
"ray_serve_http_request_latency_ms",
|
|
]
|
|
|
|
app = FastAPI()
|
|
|
|
@serve.deployment(name="test_app")
|
|
@serve.ingress(app)
|
|
class NotFoundApp:
|
|
@app.get("/existing-path") # Only this path is defined
|
|
async def handler(self, request: Request):
|
|
return {"message": "success"}
|
|
|
|
app_name = "app"
|
|
serve.run(NotFoundApp.bind(), name=app_name, route_prefix="/test")
|
|
|
|
def verify_metrics(_expected_metrics, do_assert=False):
|
|
try:
|
|
resp = httpx.get("http://127.0.0.1:9999").text
|
|
# Requests will fail if we are crashing the controller
|
|
except httpx.HTTPError:
|
|
return False
|
|
for metric in _expected_metrics:
|
|
if do_assert:
|
|
assert metric in resp
|
|
if metric not in resp:
|
|
return False
|
|
return True
|
|
|
|
# Trigger HTTP 404 error via the deployed application
|
|
httpx.get("http://127.0.0.1:8000/test/nonexistent")
|
|
httpx.get("http://127.0.0.1:8000/test/nonexistent")
|
|
|
|
# Ensure all expected metrics are present.
|
|
try:
|
|
wait_for_condition(
|
|
verify_metrics,
|
|
retry_interval_ms=1000,
|
|
timeout=10,
|
|
expected_metrics=expected_metrics,
|
|
)
|
|
except RuntimeError:
|
|
verify_metrics(expected_metrics, True)
|
|
|
|
def verify_error_count(do_assert=False):
|
|
resp = httpx.get("http://127.0.0.1:9999").text
|
|
resp = resp.split("\n")
|
|
http_error_count = 0
|
|
deployment_404_count = 0
|
|
|
|
for metrics in resp:
|
|
if "# HELP" in metrics or "# TYPE" in metrics:
|
|
continue
|
|
# Skip health check metrics
|
|
if "/-/healthz" in metrics:
|
|
continue
|
|
if (
|
|
"ray_serve_num_http_error_requests_total" in metrics
|
|
and 'route="/test"' in metrics
|
|
):
|
|
# Accumulate error counts from route "/test"
|
|
http_error_count += int(float(metrics.split(" ")[-1]))
|
|
elif (
|
|
"ray_serve_num_deployment_http_error_requests_total" in metrics
|
|
and 'route="/test"' in metrics
|
|
and 'error_code="404"' in metrics
|
|
):
|
|
# Count deployment 404 errors
|
|
deployment_404_count += int(float(metrics.split(" ")[-1]))
|
|
|
|
# We expect 2 requests total, both should be 404 errors from the deployment
|
|
if do_assert:
|
|
assert (
|
|
http_error_count == 2
|
|
), f"Expected at least 2 HTTP errors, got {http_error_count}"
|
|
assert (
|
|
deployment_404_count == 2
|
|
), f"Expected 2 deployment 404 errors, got {deployment_404_count}"
|
|
|
|
return http_error_count >= 2 and deployment_404_count == 2
|
|
|
|
# There is a latency in updating the counter
|
|
try:
|
|
wait_for_condition(verify_error_count, retry_interval_ms=1000, timeout=20)
|
|
except RuntimeError:
|
|
verify_error_count(do_assert=True)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"metrics_start_shutdown",
|
|
[
|
|
1,
|
|
],
|
|
indirect=True,
|
|
)
|
|
def test_proxy_timeout_metrics(metrics_start_shutdown):
|
|
"""Test that HTTP timeout metrics are reported correctly."""
|
|
signal = SignalActor.remote()
|
|
|
|
@serve.deployment
|
|
async def return_status_code_with_timeout(request: Request):
|
|
await signal.wait.remote()
|
|
return
|
|
|
|
serve.run(
|
|
return_status_code_with_timeout.bind(),
|
|
route_prefix="/status_code_timeout",
|
|
name="status_code_timeout",
|
|
)
|
|
|
|
http_url = get_application_url("HTTP", app_name="status_code_timeout")
|
|
|
|
r = httpx.get(http_url)
|
|
assert r.status_code == 408
|
|
ray.get(signal.send.remote(clear=True))
|
|
|
|
num_errors = get_metric_dictionaries("ray_serve_num_http_error_requests_total")
|
|
assert len(num_errors) == 1
|
|
assert num_errors[0]["route"] == "/status_code_timeout"
|
|
assert num_errors[0]["error_code"] == "408"
|
|
assert num_errors[0]["method"] == "GET"
|
|
assert num_errors[0]["application"] == "status_code_timeout"
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows")
|
|
def test_proxy_disconnect_http_metrics(metrics_start_shutdown):
|
|
"""Test that HTTP disconnect metrics are reported correctly."""
|
|
|
|
signal = SignalActor.remote()
|
|
|
|
@serve.deployment
|
|
class Disconnect:
|
|
async def __call__(self, request: Request):
|
|
await signal.wait.remote()
|
|
return
|
|
|
|
serve.run(
|
|
Disconnect.bind(),
|
|
route_prefix="/disconnect",
|
|
name="disconnect",
|
|
)
|
|
|
|
# Simulate an HTTP disconnect
|
|
http_url = get_application_url("HTTP", app_name="disconnect")
|
|
ip_port = http_url.replace("http://", "").split("/")[0] # remove the route prefix
|
|
ip, port = parse_address(ip_port)
|
|
conn = http.client.HTTPConnection(ip, int(port))
|
|
conn.request("GET", "/disconnect")
|
|
wait_for_condition(
|
|
lambda: ray.get(signal.cur_num_waiters.remote()) == 1, timeout=10
|
|
)
|
|
conn.close() # Forcefully close the connection
|
|
ray.get(signal.send.remote(clear=True))
|
|
|
|
num_errors = get_metric_dictionaries("ray_serve_num_http_error_requests_total")
|
|
assert len(num_errors) == 1
|
|
assert num_errors[0]["route"] == "/disconnect"
|
|
assert num_errors[0]["error_code"] == "499"
|
|
assert num_errors[0]["method"] == "GET"
|
|
assert num_errors[0]["application"] == "disconnect"
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows")
|
|
def test_no_499_misclassification_after_successful_response(metrics_start_shutdown):
|
|
"""Reproduce the race where response is sent (body without more_body) but
|
|
response_finished stays False, then disconnect arrives and we incorrectly log 499.
|
|
|
|
convert_object_to_asgi_messages omits more_body in the final body chunk (valid
|
|
per ASGI spec). The fix treats omitted more_body as final so we don't
|
|
misclassify successful responses as 499 when client disconnects after response.
|
|
"""
|
|
|
|
@serve.deployment
|
|
async def fast_return(request: Request):
|
|
return "ok"
|
|
|
|
serve.run(
|
|
fast_return.bind(),
|
|
route_prefix="/race_test",
|
|
name="race_test",
|
|
)
|
|
|
|
http_url = get_application_url("HTTP", app_name="race_test")
|
|
|
|
def _request_then_close_immediately():
|
|
"""Send request, read 1 byte of response, then close. This creates the race:
|
|
server has sent full response (body without more_body) but client closes
|
|
before request_task exits. Without the fix, response_finished stays False
|
|
and we incorrectly log 499."""
|
|
with httpx.Client() as client:
|
|
with client.stream("GET", http_url) as response:
|
|
next(
|
|
response.iter_bytes(1)
|
|
) # Read 1 byte, then exit - connection closes
|
|
|
|
# Run many times to hit the race (disconnect arrives before request_task exits)
|
|
num_requests = 500
|
|
with ThreadPoolExecutor(max_workers=10) as executor:
|
|
futures = [
|
|
executor.submit(_request_then_close_immediately)
|
|
for _ in range(num_requests)
|
|
]
|
|
for f in as_completed(futures):
|
|
f.result()
|
|
|
|
# First assert all requests were processed
|
|
def check_request_count_and_no_499_errors():
|
|
check_sum_metric_eq(
|
|
"ray_serve_num_http_requests_total",
|
|
num_requests,
|
|
tags={"route": "/race_test"},
|
|
)
|
|
check_sum_metric_eq(
|
|
"ray_serve_num_http_error_requests_total",
|
|
0,
|
|
tags={"route": "/race_test", "error_code": "499"},
|
|
)
|
|
return True
|
|
|
|
wait_for_condition(check_request_count_and_no_499_errors, timeout=30)
|
|
|
|
|
|
def test_proxy_metrics_fields_internal_error(metrics_start_shutdown):
|
|
"""Tests the proxy metrics' fields' behavior for internal error."""
|
|
|
|
@serve.deployment()
|
|
def f(*args):
|
|
return 1 / 0
|
|
|
|
real_app_name = "app"
|
|
real_app_name2 = "app2"
|
|
serve.run(f.bind(), name=real_app_name, route_prefix="/real_route")
|
|
serve.run(f.bind(), name=real_app_name2, route_prefix="/real_route2")
|
|
|
|
# Deployment should generate divide-by-zero errors
|
|
correct_url = get_application_url("HTTP", real_app_name)
|
|
_ = httpx.get(correct_url).text
|
|
print("Sent requests to correct URL.")
|
|
|
|
num_deployment_errors = get_metric_dictionaries(
|
|
"ray_serve_num_deployment_http_error_requests_total"
|
|
)
|
|
assert len(num_deployment_errors) == 1
|
|
assert num_deployment_errors[0]["deployment"] == "f"
|
|
assert num_deployment_errors[0]["error_code"] == "500"
|
|
assert num_deployment_errors[0]["method"] == "GET"
|
|
assert num_deployment_errors[0]["application"] == "app"
|
|
print("ray_serve_num_deployment_http_error_requests working as expected.")
|
|
|
|
latency_metrics = get_metric_dictionaries("ray_serve_http_request_latency_ms_sum")
|
|
# Filter out health check metrics - HAProxy generates health checks to /-/healthz
|
|
latency_metrics = [m for m in latency_metrics if m["route"] != "/-/healthz"]
|
|
assert len(latency_metrics) == 1
|
|
assert latency_metrics[0]["method"] == "GET"
|
|
assert latency_metrics[0]["route"] == "/real_route"
|
|
assert latency_metrics[0]["application"] == "app"
|
|
assert latency_metrics[0]["status_code"] == "500"
|
|
print("ray_serve_http_request_latency_ms working as expected.")
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows")
|
|
def test_proxy_metrics_http_status_code_is_error(metrics_start_shutdown):
|
|
"""Verify that 2xx and 3xx status codes aren't errors, others are."""
|
|
|
|
def check_request_count_metrics(
|
|
expected_error_count: int,
|
|
expected_success_count: int,
|
|
):
|
|
resp = httpx.get("http://127.0.0.1:9999").text
|
|
error_count = 0
|
|
success_count = 0
|
|
for line in resp.split("\n"):
|
|
# Skip health check metrics
|
|
if "/-/healthz" in line:
|
|
continue
|
|
if line.startswith("ray_serve_num_http_error_requests_total"):
|
|
error_count += int(float(line.split(" ")[-1]))
|
|
if line.startswith("ray_serve_num_http_requests_total"):
|
|
success_count += int(float(line.split(" ")[-1]))
|
|
|
|
assert error_count == expected_error_count
|
|
assert success_count == expected_success_count
|
|
return True
|
|
|
|
@serve.deployment
|
|
async def return_status_code(request: Request):
|
|
code = int((await request.body()).decode("utf-8"))
|
|
return PlainTextResponse("", status_code=code)
|
|
|
|
serve.run(return_status_code.bind())
|
|
|
|
http_url = get_application_url("HTTP")
|
|
|
|
# 200 is not an error.
|
|
r = httpx.request("GET", http_url, content=b"200")
|
|
assert r.status_code == 200
|
|
wait_for_condition(
|
|
check_request_count_metrics,
|
|
expected_error_count=0,
|
|
expected_success_count=1,
|
|
)
|
|
|
|
# 2xx is not an error.
|
|
r = httpx.request("GET", http_url, content=b"250")
|
|
assert r.status_code == 250
|
|
wait_for_condition(
|
|
check_request_count_metrics,
|
|
expected_error_count=0,
|
|
expected_success_count=2,
|
|
)
|
|
|
|
# 3xx is not an error.
|
|
r = httpx.request("GET", http_url, content=b"300")
|
|
assert r.status_code == 300
|
|
wait_for_condition(
|
|
check_request_count_metrics,
|
|
expected_error_count=0,
|
|
expected_success_count=3,
|
|
)
|
|
|
|
# 4xx is an error.
|
|
r = httpx.request("GET", http_url, content=b"400")
|
|
assert r.status_code == 400
|
|
wait_for_condition(
|
|
check_request_count_metrics,
|
|
expected_error_count=1,
|
|
expected_success_count=4,
|
|
)
|
|
|
|
# 5xx is an error.
|
|
r = httpx.request("GET", http_url, content=b"500")
|
|
assert r.status_code == 500
|
|
wait_for_condition(
|
|
check_request_count_metrics,
|
|
expected_error_count=2,
|
|
expected_success_count=5,
|
|
)
|
|
|
|
|
|
def test_replica_metrics_fields(metrics_start_shutdown):
|
|
"""Test replica metrics fields"""
|
|
|
|
@serve.deployment
|
|
def f():
|
|
return "hello"
|
|
|
|
@serve.deployment
|
|
def g():
|
|
return "world"
|
|
|
|
serve.run(f.bind(), name="app1", route_prefix="/f")
|
|
serve.run(g.bind(), name="app2", route_prefix="/g")
|
|
url_f = get_application_url("HTTP", "app1")
|
|
url_g = get_application_url("HTTP", "app2")
|
|
|
|
assert "hello" == httpx.post(url_f).text
|
|
assert "world" == httpx.post(url_g).text
|
|
|
|
wait_for_condition(
|
|
lambda: len(
|
|
get_metric_dictionaries(
|
|
"ray_serve_deployment_request_counter_total", wait=False
|
|
)
|
|
)
|
|
== 2,
|
|
timeout=40,
|
|
)
|
|
|
|
metrics = get_metric_dictionaries("ray_serve_deployment_request_counter_total")
|
|
assert len(metrics) == 2
|
|
expected_output = {
|
|
("/f", "f", "app1"),
|
|
("/g", "g", "app2"),
|
|
}
|
|
assert {
|
|
(
|
|
metric["route"],
|
|
metric["deployment"],
|
|
metric["application"],
|
|
)
|
|
for metric in metrics
|
|
} == expected_output
|
|
|
|
start_metrics = get_metric_dictionaries("ray_serve_deployment_replica_starts_total")
|
|
assert len(start_metrics) == 2
|
|
expected_output = {("f", "app1"), ("g", "app2")}
|
|
assert {
|
|
(start_metric["deployment"], start_metric["application"])
|
|
for start_metric in start_metrics
|
|
} == expected_output
|
|
|
|
# Latency metrics
|
|
wait_for_condition(
|
|
lambda: len(
|
|
get_metric_dictionaries(
|
|
"ray_serve_deployment_processing_latency_ms_count", wait=False
|
|
)
|
|
)
|
|
== 2,
|
|
timeout=40,
|
|
)
|
|
for metric_name in [
|
|
"ray_serve_deployment_processing_latency_ms_count",
|
|
"ray_serve_deployment_processing_latency_ms_sum",
|
|
]:
|
|
latency_metrics = get_metric_dictionaries(metric_name)
|
|
print(f"checking metric {metric_name}, {latency_metrics}")
|
|
assert len(latency_metrics) == 2
|
|
expected_output = {("f", "app1"), ("g", "app2")}
|
|
assert {
|
|
(latency_metric["deployment"], latency_metric["application"])
|
|
for latency_metric in latency_metrics
|
|
} == expected_output
|
|
|
|
wait_for_condition(
|
|
lambda: len(
|
|
get_metric_dictionaries("ray_serve_replica_processing_queries", wait=False)
|
|
)
|
|
== 2
|
|
)
|
|
processing_queries = get_metric_dictionaries("ray_serve_replica_processing_queries")
|
|
expected_output = {("f", "app1"), ("g", "app2")}
|
|
assert {
|
|
(processing_query["deployment"], processing_query["application"])
|
|
for processing_query in processing_queries
|
|
} == expected_output
|
|
|
|
@serve.deployment
|
|
def h():
|
|
return 1 / 0
|
|
|
|
serve.run(h.bind(), name="app3", route_prefix="/h")
|
|
url_h = get_application_url("HTTP", "app3")
|
|
assert 500 == httpx.get(url_h).status_code
|
|
wait_for_condition(
|
|
lambda: len(
|
|
get_metric_dictionaries(
|
|
"ray_serve_deployment_error_counter_total", wait=False
|
|
)
|
|
)
|
|
== 1,
|
|
timeout=40,
|
|
)
|
|
err_requests = get_metric_dictionaries("ray_serve_deployment_error_counter_total")
|
|
assert len(err_requests) == 1
|
|
expected_output = ("/h", "h", "app3")
|
|
assert (
|
|
err_requests[0]["route"],
|
|
err_requests[0]["deployment"],
|
|
err_requests[0]["application"],
|
|
) == expected_output
|
|
|
|
expected_deployments = {("f", "app1"), ("g", "app2"), ("h", "app3")}
|
|
health_timeseries = PrometheusTimeseries()
|
|
|
|
def _check_replica_healthy():
|
|
metrics = get_metric_dictionaries(
|
|
"ray_serve_deployment_replica_healthy",
|
|
wait=False,
|
|
timeseries=health_timeseries,
|
|
)
|
|
return {
|
|
(m["deployment"], m["application"]) for m in metrics
|
|
} >= expected_deployments
|
|
|
|
wait_for_condition(_check_replica_healthy, timeout=40)
|
|
health_metrics = get_metric_dictionaries(
|
|
"ray_serve_deployment_replica_healthy", timeseries=health_timeseries
|
|
)
|
|
assert {
|
|
(health_metric["deployment"], health_metric["application"])
|
|
for health_metric in health_metrics
|
|
} >= expected_deployments
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows")
|
|
def test_multiplexed_metrics(metrics_start_shutdown):
|
|
"""Tests multiplexed API corresponding metrics."""
|
|
|
|
@serve.deployment
|
|
class Model:
|
|
@serve.multiplexed(max_num_models_per_replica=2)
|
|
async def get_model(self, model_id: str):
|
|
return model_id
|
|
|
|
async def __call__(self, model_id: str):
|
|
await self.get_model(model_id)
|
|
return
|
|
|
|
# Multiplexing is not supported on the ingress deployment when direct ingress /
|
|
# HAProxy is enabled, so keep the multiplexed deployment downstream of a plain
|
|
# ingress.
|
|
@serve.deployment
|
|
class Ingress:
|
|
def __init__(self, model):
|
|
self._model = model
|
|
|
|
async def __call__(self, model_id: str):
|
|
await self._model.remote(model_id)
|
|
|
|
handle = serve.run(Ingress.bind(Model.bind()), name="app", route_prefix="/app")
|
|
handle.remote("model1")
|
|
handle.remote("model2")
|
|
# Trigger model eviction.
|
|
handle.remote("model3")
|
|
expected_metrics = [
|
|
"ray_serve_multiplexed_model_load_latency_ms",
|
|
"ray_serve_multiplexed_model_unload_latency_ms",
|
|
"ray_serve_num_multiplexed_models",
|
|
"ray_serve_multiplexed_models_load_counter",
|
|
"ray_serve_multiplexed_models_unload_counter",
|
|
]
|
|
|
|
def verify_metrics():
|
|
try:
|
|
resp = httpx.get("http://127.0.0.1:9999").text
|
|
# Requests will fail if we are crashing the controller
|
|
except httpx.HTTPError:
|
|
return False
|
|
for metric in expected_metrics:
|
|
assert metric in resp
|
|
return True
|
|
|
|
wait_for_condition(
|
|
verify_metrics,
|
|
timeout=40,
|
|
retry_interval_ms=1000,
|
|
)
|
|
|
|
|
|
def test_long_poll_host_sends_counted(serve_instance):
|
|
"""Check that the transmissions by the long_poll are counted."""
|
|
|
|
host = ray.remote(LongPollHost).remote(
|
|
listen_for_change_request_timeout_s=(0.01, 0.01)
|
|
)
|
|
|
|
# Write a value.
|
|
ray.get(host.notify_changed.remote({"key_1": 999}))
|
|
object_ref = host.listen_for_change.remote({"key_1": -1})
|
|
|
|
# Check that the result's size is reported.
|
|
result_1: Dict[str, UpdatedObject] = ray.get(object_ref)
|
|
wait_for_condition(
|
|
check_metric_float_eq,
|
|
timeout=15,
|
|
metric="ray_serve_long_poll_host_transmission_counter",
|
|
expected=1,
|
|
expected_tags={"namespace_or_state": "key_1"},
|
|
)
|
|
|
|
# Write two new values.
|
|
ray.get(host.notify_changed.remote({"key_1": 1000}))
|
|
ray.get(host.notify_changed.remote({"key_2": 1000}))
|
|
object_ref = host.listen_for_change.remote(
|
|
{"key_1": result_1["key_1"].snapshot_id, "key_2": -1}
|
|
)
|
|
|
|
# Check that the new objects are transmitted.
|
|
result_2: Dict[str, UpdatedObject] = ray.get(object_ref)
|
|
wait_for_condition(
|
|
check_metric_float_eq,
|
|
timeout=15,
|
|
metric="ray_serve_long_poll_host_transmission_counter",
|
|
expected=1,
|
|
expected_tags={"namespace_or_state": "key_2"},
|
|
)
|
|
wait_for_condition(
|
|
check_metric_float_eq,
|
|
timeout=15,
|
|
metric="ray_serve_long_poll_host_transmission_counter",
|
|
expected=2,
|
|
expected_tags={"namespace_or_state": "key_1"},
|
|
)
|
|
|
|
# Check that a timeout result is counted.
|
|
object_ref = host.listen_for_change.remote({"key_2": result_2["key_2"].snapshot_id})
|
|
_ = ray.get(object_ref)
|
|
wait_for_condition(
|
|
check_metric_float_eq,
|
|
timeout=15,
|
|
metric="ray_serve_long_poll_host_transmission_counter",
|
|
expected=1,
|
|
expected_tags={"namespace_or_state": "TIMEOUT"},
|
|
)
|
|
|
|
|
|
def test_actor_summary(serve_instance):
|
|
@serve.deployment
|
|
def f():
|
|
pass
|
|
|
|
serve.run(f.bind(), name="app")
|
|
actors = list_actors(filters=[("state", "=", "ALIVE")])
|
|
class_names = {actor["class_name"] for actor in actors}
|
|
assert class_names.issuperset(
|
|
{"ServeController", *expected_proxy_actors(), "ServeReplica:app:f"}
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main(["-v", "-s", __file__]))
|