Files
2026-07-13 13:17:40 +08:00

1273 lines
43 KiB
Python

import asyncio
import concurrent.futures
import os
import sys
import threading
import time
from typing import Dict, List
import httpx
import pytest
import redis
from starlette.requests import Request
import ray
from ray import serve
from ray._common.test_utils import PrometheusTimeseries, SignalActor, wait_for_condition
from ray.serve._private.common import DeploymentID
from ray.serve._private.constants import (
RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP,
RAY_SERVE_RUN_USER_CODE_IN_SEPARATE_THREAD,
SERVE_CONTROLLER_NAME,
SERVE_NAMESPACE,
)
from ray.serve._private.long_poll import LongPollClient, LongPollHost, UpdatedObject
from ray.serve._private.queue_monitor import (
create_queue_monitor_actor,
kill_queue_monitor_actor,
)
from ray.serve._private.test_utils import (
check_metric_float_eq,
get_application_url,
get_metric_dictionaries,
get_metric_float,
)
from ray.tests.conftest import external_redis # noqa: F401
from ray.util.state import list_actors
def test_deployment_and_application_status_metrics(metrics_start_shutdown):
"""Test that deployment and application status metrics are exported correctly.
These metrics track the numeric status of deployments and applications:
- serve_deployment_status: 0=UNKNOWN, 1=DEPLOY_FAILED, 2=UNHEALTHY,
3=UPDATING, 4=UPSCALING, 5=DOWNSCALING, 6=HEALTHY
- serve_application_status: 0=UNKNOWN, 1=NOT_STARTED, 2=DEPLOYING,
3=DEPLOY_FAILED, 4=RUNNING, 5=UNHEALTHY, 6=DELETING
"""
signal = SignalActor.remote()
@serve.deployment(name="deployment_a")
class DeploymentA:
async def __init__(self):
await signal.wait.remote()
async def __call__(self):
return "hello"
@serve.deployment
def deployment_b():
return "world"
# Deploy two applications with different deployments
serve._run(DeploymentA.bind(), name="app1", route_prefix="/app1", _blocking=False)
serve._run(deployment_b.bind(), name="app2", route_prefix="/app2", _blocking=False)
timeseries = PrometheusTimeseries()
# Wait for deployments to become healthy
def check_status_metrics():
# Check deployment status metrics
deployment_metrics = get_metric_dictionaries(
"ray_serve_deployment_status", timeseries=timeseries, wait=False
)
if len(deployment_metrics) < 2:
return False
# Check application status metrics
app_metrics = get_metric_dictionaries(
"ray_serve_application_status", timeseries=timeseries, wait=False
)
if len(app_metrics) < 2:
return False
return True
wait_for_condition(check_status_metrics, timeout=30)
wait_for_condition(
check_metric_float_eq,
metric="ray_serve_deployment_status",
expected=3, # UPDATING
expected_tags={"deployment": "deployment_a", "application": "app1"},
timeseries=timeseries,
)
wait_for_condition(
check_metric_float_eq,
metric="ray_serve_application_status",
expected=5, # DEPLOYING
expected_tags={"application": "app1"},
timeseries=timeseries,
)
wait_for_condition(
check_metric_float_eq,
metric="ray_serve_deployment_status",
expected=6,
expected_tags={"deployment": "deployment_b", "application": "app2"},
timeseries=timeseries,
)
wait_for_condition(
check_metric_float_eq,
metric="ray_serve_application_status",
expected=6,
expected_tags={"application": "app2"},
timeseries=timeseries,
)
ray.get(signal.send.remote())
wait_for_condition(
check_metric_float_eq,
metric="ray_serve_deployment_status",
expected=6,
expected_tags={"deployment": "deployment_a", "application": "app1"},
timeseries=timeseries,
)
wait_for_condition(
check_metric_float_eq,
metric="ray_serve_application_status",
expected=6,
expected_tags={"application": "app1"},
timeseries=timeseries,
)
def test_replica_startup_and_initialization_latency_metrics(metrics_start_shutdown):
"""Test that replica startup and initialization latency metrics are recorded."""
@serve.deployment(num_replicas=2)
class MyDeployment:
def __init__(self):
time.sleep(1)
def __call__(self):
return "hello"
serve.run(MyDeployment.bind(), name="app", route_prefix="/f")
url = get_application_url("HTTP", "app")
assert "hello" == httpx.get(url).text
# Verify startup latency: two replicas aggregate into one time series (_count == 2).
wait_for_condition(
check_metric_float_eq,
timeout=20,
metric="ray_serve_replica_startup_latency_ms_count",
expected=2,
expected_tags={"deployment": "MyDeployment", "application": "app"},
)
# Verify initialization latency _count matches (one observation per replica).
wait_for_condition(
check_metric_float_eq,
timeout=20,
metric="ray_serve_replica_initialization_latency_ms_count",
expected=2,
expected_tags={"deployment": "MyDeployment", "application": "app"},
)
# Verify initialization latency metric value is greater than 500ms
def check_initialization_latency_value():
value = get_metric_float(
"ray_serve_replica_initialization_latency_ms_sum",
expected_tags={"deployment": "MyDeployment", "application": "app"},
)
assert (
value > 500
), f"Initialization latency value is {value}, expected to be greater than 500ms"
return True
wait_for_condition(check_initialization_latency_value, timeout=20)
# One aggregated time series per deployment (no per-replica label).
def check_single_series_no_replica_label():
metrics = get_metric_dictionaries(
"ray_serve_replica_initialization_latency_ms_count",
wait=False,
)
assert len(metrics) == 1, f"Expected 1 metric series, got {len(metrics)}"
assert metrics[0]["deployment"] == "MyDeployment"
assert metrics[0]["application"] == "app"
assert "replica" not in metrics[0]
return True
wait_for_condition(check_single_series_no_replica_label, timeout=20)
def test_replica_reconfigure_latency_metrics(metrics_start_shutdown):
"""Test that replica reconfigure latency metrics are recorded when user_config changes."""
@serve.deployment
class Configurable:
def __init__(self):
self.config = None
def reconfigure(self, config):
time.sleep(1)
self.config = config
def __call__(self):
return self.config
# Use an internal code version to exercise in-place reconfigure.
Configurable = Configurable.options(_internal=True, version="1")
serve.run(
Configurable.options(user_config={"version": 1}).bind(),
name="app",
route_prefix="/config",
)
url = get_application_url("HTTP", "app")
assert httpx.get(url).json() == {"version": 1}
# Update user_config to trigger in-place reconfigure (same version, different config)
serve.run(
Configurable.options(user_config={"version": 2}).bind(),
name="app",
route_prefix="/config",
)
# Wait for the new config to take effect
def config_updated():
return httpx.get(url).json() == {"version": 2}
wait_for_condition(config_updated, timeout=20)
# Verify reconfigure latency metric count is exactly 1 (one reconfigure happened)
wait_for_condition(
check_metric_float_eq,
timeout=20,
metric="ray_serve_replica_reconfigure_latency_ms_count",
expected=1,
expected_tags={"deployment": "Configurable", "application": "app"},
)
# Verify reconfigure latency metric value is greater than 500ms (we slept for 1s)
def check_reconfigure_latency_value():
value = get_metric_float(
"ray_serve_replica_reconfigure_latency_ms_sum",
expected_tags={"deployment": "Configurable", "application": "app"},
)
assert value > 500, f"Reconfigure latency value is {value}, expected > 500ms"
return True
wait_for_condition(check_reconfigure_latency_value, timeout=20)
def test_health_check_latency_metrics(metrics_start_shutdown):
"""Test that health check latency metrics are recorded."""
@serve.deployment(health_check_period_s=1)
class MyDeployment:
def __call__(self):
return "hello"
def check_health(self):
time.sleep(1)
serve.run(MyDeployment.bind(), name="app", route_prefix="/f")
url = get_application_url("HTTP", "app")
assert "hello" == httpx.get(url).text
# Wait for at least one health check to complete and verify metric is recorded
def check_health_check_latency_metrics():
value = get_metric_float(
"ray_serve_health_check_latency_ms_count",
expected_tags={"deployment": "MyDeployment", "application": "app"},
)
# Health check count should be at least 1
assert value >= 1, f"Health check count is {value}, expected to be 1"
return True
wait_for_condition(check_health_check_latency_metrics, timeout=30)
# Verify health check latency metric value is greater than 500ms
def check_health_check_latency_value():
value = get_metric_float(
"ray_serve_health_check_latency_ms_sum",
expected_tags={"deployment": "MyDeployment", "application": "app"},
)
assert (
value > 500
), f"Health check latency value is {value}, expected to be greater than 500ms"
return True
wait_for_condition(check_health_check_latency_value, timeout=30)
def test_health_check_failures_metrics(metrics_start_shutdown):
"""Test that health check failure metrics are recorded when health checks fail."""
@serve.deployment(health_check_period_s=1, health_check_timeout_s=2)
class FailingHealthCheck:
def __init__(self):
self.should_fail = False
async def check_health(self):
if self.should_fail:
raise Exception("Health check failed!")
async def __call__(self, request):
action = (await request.body()).decode("utf-8")
if action == "fail":
self.should_fail = True
return "ok"
serve.run(FailingHealthCheck.bind(), name="app", route_prefix="/health")
url = get_application_url("HTTP", "app")
# Verify deployment is healthy initially
assert httpx.get(url).text == "ok"
# Trigger health check failure
httpx.request("GET", url, content=b"fail")
# Wait for at least one health check failure to be recorded
def check_health_check_failure_metrics():
value = get_metric_float(
"ray_serve_health_check_failures_total",
expected_tags={"deployment": "FailingHealthCheck", "application": "app"},
)
# Should have at least 1 failure
return value >= 1
wait_for_condition(check_health_check_failure_metrics, timeout=30)
def test_replica_shutdown_duration_metrics(metrics_start_shutdown):
"""Test that replica shutdown duration metrics are recorded."""
@serve.deployment
class MyDeployment:
def __call__(self):
return "hello"
def __del__(self):
time.sleep(1)
# Deploy the application
serve.run(MyDeployment.bind(), name="app", route_prefix="/f")
url = get_application_url("HTTP", "app")
assert "hello" == httpx.get(url).text
# Delete the application to trigger shutdown
serve.delete("app", _blocking=True)
# Verify shutdown duration metric count is exactly 1 (one replica stopped)
wait_for_condition(
check_metric_float_eq,
timeout=30,
metric="ray_serve_replica_shutdown_duration_ms_count",
expected=1,
expected_tags={"deployment": "MyDeployment", "application": "app"},
)
print("serve_replica_shutdown_duration_ms working as expected.")
# Verify shutdown duration metric value is greater than 500ms
def check_shutdown_duration_value():
value = get_metric_float(
"ray_serve_replica_shutdown_duration_ms_sum",
expected_tags={"deployment": "MyDeployment", "application": "app"},
)
assert (
value > 500
), f"Shutdown duration value is {value}, expected to be greater than 500ms"
return True
wait_for_condition(check_shutdown_duration_value, timeout=30)
def test_batching_metrics(metrics_start_shutdown):
@serve.deployment
class BatchedDeployment:
@serve.batch(max_batch_size=4, batch_wait_timeout_s=0.5)
async def batch_handler(self, requests: List[str]) -> List[str]:
# Simulate some processing time
await asyncio.sleep(0.05)
return [f"processed:{r}" for r in requests]
async def __call__(self, request: Request):
data = await request.body()
return await self.batch_handler(data.decode())
app_name = "batched_app"
serve.run(BatchedDeployment.bind(), name=app_name, route_prefix="/batch")
http_url = "http://localhost:8000/batch"
# Send multiple concurrent requests to trigger batching
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor:
futures = [
executor.submit(lambda i=i: httpx.post(http_url, content=f"req{i}"))
for i in range(8)
]
results = [f.result() for f in futures]
# Verify all requests succeeded
assert all(r.status_code == 200 for r in results)
# Verify specific metric values and tags
timeseries = PrometheusTimeseries()
expected_tags = {
"deployment": "BatchedDeployment",
"application": app_name,
"function_name": "batch_handler",
}
# Check batches_processed_total counter exists and has correct tags
wait_for_condition(
lambda: check_metric_float_eq(
"ray_serve_batches_processed_total",
expected=2,
expected_tags=expected_tags,
timeseries=timeseries,
),
timeout=10,
)
# Check batch_wait_time_ms histogram was recorded for 2 batches
wait_for_condition(
lambda: check_metric_float_eq(
"ray_serve_batch_wait_time_ms_count",
expected=2,
expected_tags=expected_tags,
timeseries=timeseries,
),
timeout=10,
)
# Check batch_execution_time_ms histogram was recorded for 2 batches
wait_for_condition(
lambda: check_metric_float_eq(
"ray_serve_batch_execution_time_ms_count",
expected=2,
expected_tags=expected_tags,
timeseries=timeseries,
),
timeout=10,
)
# Check batch_utilization_percent histogram: 2 batches at 100% each = 200 sum
wait_for_condition(
lambda: check_metric_float_eq(
"ray_serve_batch_utilization_percent_count",
expected=2,
expected_tags=expected_tags,
timeseries=timeseries,
),
timeout=10,
)
# Check actual_batch_size histogram: 2 batches of 4 requests each = 8 sum
wait_for_condition(
lambda: check_metric_float_eq(
"ray_serve_actual_batch_size_count",
expected=2,
expected_tags=expected_tags,
timeseries=timeseries,
),
timeout=10,
)
# Check batch_queue_length gauge exists (should be 0 after processing)
wait_for_condition(
lambda: check_metric_float_eq(
"ray_serve_batch_queue_length",
expected=0,
expected_tags=expected_tags,
timeseries=timeseries,
),
timeout=10,
)
def test_autoscaling_metrics(metrics_start_shutdown):
"""Test that autoscaling metrics are emitted correctly.
This tests the following metrics:
- ray_serve_autoscaling_target_replicas: Target number of replicas
Tags: deployment, application
- ray_serve_autoscaling_desired_replicas: Raw decision before bounds
Tags: deployment, application
- ray_serve_autoscaling_total_requests: Total requests seen by autoscaler
Tags: deployment, application
- ray_serve_autoscaling_policy_execution_time_ms: Policy execution time
Tags: deployment, application, policy_scope
- ray_serve_autoscaling_target_ongoing_requests: Configured target ongoing
requests per replica
Tags: deployment, application
- ray_serve_autoscaling_replica_metrics_delay_ms: Replica metrics delay
Tags: deployment, application
- ray_serve_autoscaling_handle_metrics_delay_ms: Handle metrics delay
Tags: deployment, application
"""
signal = SignalActor.remote()
@serve.deployment(
autoscaling_config={
"metrics_interval_s": 0.1,
"min_replicas": 1,
"max_replicas": 5,
"target_ongoing_requests": 2,
"upscale_delay_s": 0,
"downscale_delay_s": 5,
"look_back_period_s": 1,
},
max_ongoing_requests=10,
graceful_shutdown_timeout_s=0.1,
)
class AutoscalingDeployment:
async def __call__(self):
await signal.wait.remote()
serve.run(AutoscalingDeployment.bind(), name="autoscaling_app")
# Send requests to trigger autoscaling
handle = serve.get_deployment_handle("AutoscalingDeployment", "autoscaling_app")
[handle.remote() for _ in range(10)]
timeseries = PrometheusTimeseries()
base_tags = {
"deployment": "AutoscalingDeployment",
"application": "autoscaling_app",
}
# Test 1: Check that target_replicas metric is 5 (10 requests / target_ongoing_requests=2)
wait_for_condition(
check_metric_float_eq,
timeout=15,
metric="ray_serve_autoscaling_target_replicas",
expected=5,
expected_tags=base_tags,
timeseries=timeseries,
)
print("Target replicas metric verified.")
# Test 2: Check that autoscaling decision metric is 5 (10 requests / target_ongoing_requests=2)
wait_for_condition(
check_metric_float_eq,
timeout=15,
metric="ray_serve_autoscaling_desired_replicas",
expected=5,
expected_tags=base_tags,
timeseries=timeseries,
)
print("Autoscaling decision metric verified.")
# Test 3: Check that total requests metric is 10
wait_for_condition(
check_metric_float_eq,
timeout=15,
metric="ray_serve_autoscaling_total_requests",
expected=10,
expected_tags=base_tags,
timeseries=timeseries,
)
print("Total requests metric verified.")
# Test 4: Check that policy execution time metric is emitted with policy_scope=deployment
def check_policy_execution_time_metric():
value = get_metric_float(
"ray_serve_autoscaling_policy_execution_time_ms",
expected_tags={**base_tags, "policy_scope": "deployment"},
timeseries=timeseries,
)
assert value >= 0
return True
wait_for_condition(check_policy_execution_time_metric, timeout=15)
print("Policy execution time metric verified.")
# Test 5: Check that target_ongoing_requests metric is 2 (matches config)
wait_for_condition(
check_metric_float_eq,
timeout=15,
metric="ray_serve_autoscaling_target_ongoing_requests",
expected=2,
expected_tags=base_tags,
timeseries=timeseries,
)
print("Target ongoing requests metric verified.")
# Test 6: Check that the metrics delay histograms are emitted. These are
# aggregated by Prometheus across all sources, so they carry no
# per-replica/handle tag.
def check_metrics_delay_metrics():
found = False
for metric_name in (
"ray_serve_autoscaling_handle_metrics_delay_ms_count",
"ray_serve_autoscaling_replica_metrics_delay_ms_count",
):
metrics_dicts = get_metric_dictionaries(
metric_name,
timeout=5,
timeseries=timeseries,
wait=False,
)
for m in metrics_dicts:
if (
m.get("deployment") == "AutoscalingDeployment"
and m.get("application") == "autoscaling_app"
):
assert "replica" not in m
assert "handle" not in m
found = True
return found
wait_for_condition(check_metrics_delay_metrics, timeout=15)
print("Metrics delay metrics verified.")
# Release signal to complete requests
ray.get(signal.send.remote())
@pytest.mark.skipif(
sys.platform == "win32",
reason="Async Inference feature testing is flaky on Windows.",
)
def test_async_inference_task_queue_metrics_delay(
metrics_start_shutdown, external_redis # noqa: F811
):
"""Test that async inference task queue metrics delay is emitted correctly.
This tests the metric:
- ray_serve_autoscaling_async_inference_task_queue_metrics_delay_ms
Tags: deployment, application
The QueueMonitor periodically pushes queue length metrics to the controller,
and the controller records the delay between when the metrics were collected
and when they were received.
"""
# Setup Redis client
redis_address = os.environ.get("RAY_REDIS_ADDRESS")
host, port = redis_address.split(":")
redis_client = redis.Redis(host=host, port=int(port), db=0)
redis_broker_url = f"redis://{redis_address}/0"
test_deployment_id = DeploymentID("test_deployment", "test_app")
test_queue_name = "test_metrics_queue"
try:
# Create QueueMonitor with the Serve controller
controller = ray.get_actor(SERVE_CONTROLLER_NAME, namespace=SERVE_NAMESPACE)
queue_monitor = create_queue_monitor_actor(
deployment_id=test_deployment_id,
broker_url=redis_broker_url,
queue_name=test_queue_name,
controller_handle=controller,
namespace=SERVE_NAMESPACE,
)
# Push some messages to the queue so the metrics pusher has data to report
for i in range(5):
redis_client.lpush(test_queue_name, f"message_{i}")
# Wait for the queue length to be picked up
def check_length():
return ray.get(queue_monitor.get_queue_length.remote()) == 5
wait_for_condition(check_length, timeout=30)
timeseries = PrometheusTimeseries()
base_tags = {
"deployment": test_deployment_id.name,
"application": test_deployment_id.app_name,
}
# Wait for the metrics delay metric to be emitted with correct tags and value
def check_metrics_delay_metric():
value = get_metric_float(
"ray_serve_autoscaling_async_inference_task_queue_metrics_delay_ms",
expected_tags=base_tags,
timeseries=timeseries,
)
if value <= 0:
return False
# Verify correct tags are attached
metrics_dicts = get_metric_dictionaries(
"ray_serve_autoscaling_async_inference_task_queue_metrics_delay_ms",
timeout=5,
timeseries=timeseries,
wait=False,
)
for m in metrics_dicts:
if (
m.get("deployment") == test_deployment_id.name
and m.get("application") == test_deployment_id.app_name
):
# Verify both required tags exist
assert "deployment" in m, "Missing 'deployment' tag"
assert "application" in m, "Missing 'application' tag"
return True
return False
wait_for_condition(check_metrics_delay_metric, timeout=30)
finally:
# Cleanup
redis_client.delete(test_queue_name)
redis_client.close()
try:
kill_queue_monitor_actor(test_deployment_id, namespace=SERVE_NAMESPACE)
except ValueError:
pass # Actor may already be killed
def test_user_autoscaling_stats_metrics(metrics_start_shutdown):
"""Test that user-defined autoscaling stats metrics are emitted correctly.
This tests the following metrics:
- ray_serve_user_autoscaling_stats_latency_ms: Time to execute user stats function
Tags: application, deployment, replica
- ray_serve_record_autoscaling_stats_failed_total: Failed stats collection
Tags: application, deployment, replica, exception_name
"""
@serve.deployment(
autoscaling_config={
"metrics_interval_s": 0.1,
"min_replicas": 1,
"max_replicas": 5,
"target_ongoing_requests": 2,
},
)
class DeploymentWithCustomStats:
def __init__(self):
self.call_count = 0
async def record_autoscaling_stats(self):
"""Custom autoscaling stats function."""
self.call_count += 1
return {"custom_metric": self.call_count}
def __call__(self):
return "ok"
serve.run(DeploymentWithCustomStats.bind(), name="custom_stats_app")
# Make a request to ensure the deployment is running
handle = serve.get_deployment_handle(
"DeploymentWithCustomStats", "custom_stats_app"
)
handle.remote().result()
timeseries = PrometheusTimeseries()
base_tags = {
"deployment": "DeploymentWithCustomStats",
"application": "custom_stats_app",
}
# Test: Check that user autoscaling stats latency metric is emitted
def check_user_stats_latency_metric():
value = get_metric_float(
"ray_serve_user_autoscaling_stats_latency_ms_sum",
expected_tags=base_tags,
timeseries=timeseries,
)
if value >= 0:
# Verify replica tag exists
metrics_dicts = get_metric_dictionaries(
"ray_serve_user_autoscaling_stats_latency_ms_sum",
timeout=5,
timeseries=timeseries,
wait=False,
)
for m in metrics_dicts:
if (
m.get("deployment") == "DeploymentWithCustomStats"
and m.get("application") == "custom_stats_app"
):
assert m.get("replica") is not None
print(
f"User stats latency metric verified with replica tag: {m.get('replica')}"
)
return True
return False
wait_for_condition(check_user_stats_latency_metric, timeout=15)
print("User autoscaling stats latency metric verified.")
def test_user_autoscaling_stats_failure_metrics(metrics_start_shutdown):
"""Test that user autoscaling stats failure metrics are emitted on error."""
@serve.deployment(
autoscaling_config={
"metrics_interval_s": 0.1,
"min_replicas": 1,
"max_replicas": 5,
"target_ongoing_requests": 2,
},
)
class DeploymentWithFailingStats:
async def record_autoscaling_stats(self):
"""Custom autoscaling stats function that raises an error."""
raise ValueError("Intentional error for testing")
def __call__(self):
return "ok"
serve.run(DeploymentWithFailingStats.bind(), name="failing_stats_app")
# Make a request to ensure the deployment is running
handle = serve.get_deployment_handle(
"DeploymentWithFailingStats", "failing_stats_app"
)
handle.remote().result()
timeseries = PrometheusTimeseries()
# Test: Check that failure counter is incremented
def check_stats_failure_metric():
metrics_dicts = get_metric_dictionaries(
"ray_serve_record_autoscaling_stats_failed_total",
timeout=5,
timeseries=timeseries,
wait=False,
)
for m in metrics_dicts:
if (
m.get("deployment") == "DeploymentWithFailingStats"
and m.get("application") == "failing_stats_app"
):
assert m.get("replica") is not None
assert m.get("exception_name") == "ValueError"
print(
f"Stats failure metric verified with exception_name: {m.get('exception_name')}"
)
return True
return False
wait_for_condition(check_stats_failure_metric, timeout=15)
print("User autoscaling stats failure metric verified.")
def test_long_poll_pending_clients_metric(metrics_start_shutdown):
"""Check that pending clients gauge is tracked correctly."""
timeseries = PrometheusTimeseries()
# Create a LongPollHost with a longer timeout so we can observe pending state
host = ray.remote(LongPollHost).remote(
listen_for_change_request_timeout_s=(5.0, 5.0)
)
# Write initial values
ray.get(host.notify_changed.remote({"key_1": 100}))
ray.get(host.notify_changed.remote({"key_2": 200}))
# Get the current snapshot IDs
result = ray.get(host.listen_for_change.remote({"key_1": -1, "key_2": -1}))
key_1_snapshot_id = result["key_1"].snapshot_id
key_2_snapshot_id = result["key_2"].snapshot_id
# Start a listen call that will block waiting for updates
# (since we're using up-to-date snapshot IDs)
pending_ref = host.listen_for_change.remote(
{"key_1": key_1_snapshot_id, "key_2": key_2_snapshot_id}
)
# Check that pending clients gauge shows 1 for each key
# (wait_for_condition will retry until the metric is available)
wait_for_condition(
check_metric_float_eq,
timeout=15,
metric="ray_serve_long_poll_pending_clients",
expected=1,
expected_tags={"namespace": "key_1"},
timeseries=timeseries,
)
wait_for_condition(
check_metric_float_eq,
timeout=15,
metric="ray_serve_long_poll_pending_clients",
expected=1,
expected_tags={"namespace": "key_2"},
timeseries=timeseries,
)
# Trigger an update for key_1
ray.get(host.notify_changed.remote({"key_1": 101}))
# Wait for the pending call to complete
ray.get(pending_ref)
# After update, pending clients for key_1 should be 0
wait_for_condition(
check_metric_float_eq,
timeout=15,
metric="ray_serve_long_poll_pending_clients",
expected=0,
expected_tags={"namespace": "key_1"},
timeseries=timeseries,
)
def test_long_poll_latency_metric(metrics_start_shutdown):
"""Check that long poll latency histogram is recorded on the client side."""
timeseries = PrometheusTimeseries()
# Create a LongPollHost
host = ray.remote(LongPollHost).remote(
listen_for_change_request_timeout_s=(0.5, 0.5)
)
# Write initial value so the key exists
ray.get(host.notify_changed.remote({"test_key": "initial_value"}))
# Track received updates
received_updates = []
update_event = threading.Event()
def on_update(value):
received_updates.append(value)
update_event.set()
# Create event loop for the client
loop = asyncio.new_event_loop()
def run_loop():
asyncio.set_event_loop(loop)
loop.run_forever()
loop_thread = threading.Thread(target=run_loop, daemon=True)
loop_thread.start()
# Create the LongPollClient
client = LongPollClient(
host_actor=host,
key_listeners={"test_key": on_update},
call_in_event_loop=loop,
client_id="test_metrics_client",
)
# Wait for initial update (client starts with snapshot_id -1)
assert update_event.wait(timeout=10), "Timed out waiting for initial update"
assert len(received_updates) == 1
assert received_updates[0] == "initial_value"
# Clear event and trigger another update
update_event.clear()
ray.get(host.notify_changed.remote({"test_key": "updated_value"}))
# Wait for the update to be received
assert update_event.wait(timeout=10), "Timed out waiting for update"
assert len(received_updates) == 2
assert received_updates[1] == "updated_value"
# Stop the client
client.stop()
loop.call_soon_threadsafe(loop.stop)
loop_thread.join(timeout=5)
# Check that latency metric was recorded
# The metric should have at least 2 observations (initial + update)
def check_latency_metric_exists():
metric_value = get_metric_float(
"ray_serve_long_poll_latency_ms_count",
expected_tags={"namespace": "test_key"},
timeseries=timeseries,
)
# Should have at least 2 observations
return metric_value == 2
wait_for_condition(check_latency_metric_exists, timeout=15)
# Verify the latency sum is positive (latency > 0)
latency_sum = get_metric_float(
"ray_serve_long_poll_latency_ms_sum",
expected_tags={"namespace": "test_key"},
timeseries=timeseries,
)
assert latency_sum > 0, "Latency sum should be positive"
def test_long_poll_host_sends_counted(metrics_start_shutdown):
"""Check that the transmissions by the long_poll are counted."""
timeseries = PrometheusTimeseries()
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_total",
expected=1,
expected_tags={"namespace_or_state": "key_1"},
timeseries=timeseries,
)
# 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_total",
expected=1,
expected_tags={"namespace_or_state": "key_2"},
timeseries=timeseries,
)
wait_for_condition(
check_metric_float_eq,
timeout=15,
metric="ray_serve_long_poll_host_transmission_counter_total",
expected=2,
expected_tags={"namespace_or_state": "key_1"},
timeseries=timeseries,
)
# 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_total",
expected=1,
expected_tags={"namespace_or_state": "TIMEOUT"},
timeseries=timeseries,
)
def test_event_loop_monitoring_metrics(metrics_start_shutdown):
"""Test that event loop monitoring metrics are emitted correctly.
This tests the following metrics:
- serve_event_loop_scheduling_latency_ms: Event loop lag in milliseconds
Tags: component, loop_type, actor_id
- serve_event_loop_monitoring_iterations: Heartbeat counter
Tags: component, loop_type, actor_id
- serve_event_loop_tasks: Number of pending asyncio tasks
Tags: component, loop_type, actor_id
Components monitored:
- Proxy: main loop only
- Replica: main loop + user_code loop (when separate thread enabled)
- Router: router loop (when separate loop enabled, runs on replica)
"""
@serve.deployment(name="g")
class ChildDeployment:
def __call__(self):
return "child"
@serve.deployment(name="f")
class SimpleDeployment:
def __init__(self, child):
self.child = child
async def __call__(self):
return await self.child.remote()
serve.run(
SimpleDeployment.bind(ChildDeployment.bind()), name="app", route_prefix="/test"
)
# Make a request to ensure everything is running
url = get_application_url("HTTP", "app")
assert httpx.get(url).text == "child"
timeseries = PrometheusTimeseries()
# Test 1: Check proxy main loop metrics
def check_proxy_main_loop_metrics():
metrics = get_metric_dictionaries(
"ray_serve_event_loop_monitoring_iterations_total",
timeout=10,
timeseries=timeseries,
wait=False,
)
for m in metrics:
if m.get("component") == "proxy" and m.get("loop_type") == "main":
assert "actor_id" in m, "actor_id tag should be present"
print(f"Proxy main loop metric found: {m}")
return True
return False
wait_for_condition(check_proxy_main_loop_metrics, timeout=30)
print("Proxy main loop monitoring metrics verified.")
# Test 1a: Check proxy router loop metrics
def check_proxy_router_loop_metrics():
metrics = get_metric_dictionaries(
"ray_serve_event_loop_monitoring_iterations_total",
timeout=10,
timeseries=timeseries,
wait=False,
)
for m in metrics:
if m.get("component") == "proxy" and m.get("loop_type") == "router":
assert "actor_id" in m, "actor_id tag should be present"
print(f"Proxy router loop metric found: {m}")
return True
return False
if RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP:
wait_for_condition(check_proxy_router_loop_metrics, timeout=30)
print("Proxy router loop monitoring metrics verified.")
else:
print("Proxy router loop monitoring metrics not verified.")
# Test 2: Check replica main loop metrics
def check_replica_main_loop_metrics():
metrics = get_metric_dictionaries(
"ray_serve_event_loop_monitoring_iterations_total",
timeout=10,
timeseries=timeseries,
wait=False,
)
for m in metrics:
if m.get("component") == "replica" and m.get("loop_type") == "main":
assert "actor_id" in m, "actor_id tag should be present"
assert m.get("deployment") in [
"f",
"g",
], "deployment tag should be 'f' or 'g'"
assert m.get("application") == "app", "application tag should be 'app'"
print(f"Replica main loop metric found: {m}")
return True
return False
wait_for_condition(check_replica_main_loop_metrics, timeout=30)
print("Replica main loop monitoring metrics verified.")
# Test 3: Check replica user_code loop metrics (enabled by default)
def check_replica_user_code_loop_metrics():
metrics = get_metric_dictionaries(
"ray_serve_event_loop_monitoring_iterations_total",
timeout=10,
timeseries=timeseries,
wait=False,
)
for m in metrics:
if m.get("component") == "replica" and m.get("loop_type") == "user_code":
assert "actor_id" in m, "actor_id tag should be present"
assert m.get("deployment") in [
"f",
"g",
], "deployment tag should be 'f' or 'g'"
assert m.get("application") == "app", "application tag should be 'app'"
print(f"Replica user_code loop metric found: {m}")
return True
return False
if RAY_SERVE_RUN_USER_CODE_IN_SEPARATE_THREAD:
wait_for_condition(check_replica_user_code_loop_metrics, timeout=30)
print("Replica user_code loop monitoring metrics verified.")
else:
print("Replica user_code loop monitoring metrics not verified.")
# Test 4: Check router loop metrics (enabled by default)
def check_router_loop_metrics():
metrics = get_metric_dictionaries(
"ray_serve_event_loop_monitoring_iterations_total",
timeout=10,
timeseries=timeseries,
wait=False,
)
for m in metrics:
if m.get("component") == "replica" and m.get("loop_type") == "router":
assert "actor_id" in m, "actor_id tag should be present"
print(f"Router loop metric found: {m}")
return True
return False
if RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP:
wait_for_condition(check_router_loop_metrics, timeout=30)
print("Router loop monitoring metrics verified.")
else:
print("Router loop monitoring metrics not verified.")
# Test 5: Check that scheduling latency histogram exists and has reasonable values
def check_scheduling_latency_metric():
# Check for the histogram count metric
metrics = get_metric_dictionaries(
"ray_serve_event_loop_scheduling_latency_ms_count",
timeout=10,
timeseries=timeseries,
wait=False,
)
# Should have metrics for proxy main, replica main, replica user_code, router
component_loop_pairs = set()
for m in metrics:
component = m.get("component")
loop_type = m.get("loop_type")
if component and loop_type:
component_loop_pairs.add((component, loop_type))
expected_pairs = {
("proxy", "main"),
("replica", "main"),
}
if RAY_SERVE_RUN_USER_CODE_IN_SEPARATE_THREAD:
expected_pairs.add(("replica", "user_code"))
if RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP:
expected_pairs.add(("replica", "router"))
expected_pairs.add(("proxy", "router"))
return expected_pairs.issubset(component_loop_pairs)
wait_for_condition(check_scheduling_latency_metric, timeout=30)
print("Scheduling latency histogram metrics verified.")
# Test 6: Check that tasks gauge exists
def check_tasks_gauge_metric():
metrics = get_metric_dictionaries(
"ray_serve_event_loop_tasks",
timeout=10,
timeseries=timeseries,
wait=False,
)
# Should have metrics for proxy main, replica main, replica user_code, router
component_loop_pairs = set()
for m in metrics:
component = m.get("component")
loop_type = m.get("loop_type")
if component and loop_type:
component_loop_pairs.add((component, loop_type))
expected_pairs = {
("proxy", "main"),
("replica", "main"),
}
if RAY_SERVE_RUN_USER_CODE_IN_SEPARATE_THREAD:
expected_pairs.add(("replica", "user_code"))
if RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP:
expected_pairs.add(("replica", "router"))
expected_pairs.add(("proxy", "router"))
return expected_pairs.issubset(component_loop_pairs)
wait_for_condition(check_tasks_gauge_metric, timeout=30)
print("Event loop tasks gauge metrics verified.")
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", "ProxyActor", "ServeReplica:app:f"}
)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))