chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,663 @@
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Dict
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
import ray.serve._private.long_poll as long_poll_module
|
||||
from ray._common.test_utils import async_wait_for_condition, wait_for_condition
|
||||
from ray._common.utils import get_or_create_event_loop
|
||||
from ray.serve._private.common import (
|
||||
DeploymentID,
|
||||
DeploymentTargetInfo,
|
||||
EndpointInfo,
|
||||
ReplicaID,
|
||||
RunningReplicaInfo,
|
||||
)
|
||||
from ray.serve._private.long_poll import (
|
||||
LongPollClient,
|
||||
LongPollHost,
|
||||
LongPollNamespace,
|
||||
LongPollState,
|
||||
UpdatedObject,
|
||||
_get_metric_namespace_tag,
|
||||
)
|
||||
from ray.serve.generated.serve_pb2 import (
|
||||
DeploymentTargetInfo as DeploymentTargetInfoProto,
|
||||
EndpointSet,
|
||||
LongPollRequest,
|
||||
LongPollResult,
|
||||
)
|
||||
|
||||
|
||||
def test_notifier_events_cleared_without_update(serve_instance):
|
||||
"""Verify that notifier events are not leaked.
|
||||
|
||||
Previously, events were leaked if there were timeouts and no updates on the key.
|
||||
"""
|
||||
host = ray.remote(LongPollHost).remote(
|
||||
listen_for_change_request_timeout_s=(0.1, 0.1)
|
||||
)
|
||||
ray.get(host.notify_changed.remote({"key_1": 999}))
|
||||
|
||||
# Get an initial object snapshot for the key.
|
||||
object_ref = host.listen_for_change.remote({"key_1": -1})
|
||||
result: Dict[str, UpdatedObject] = ray.get(object_ref)
|
||||
assert set(result.keys()) == {"key_1"}
|
||||
assert {v.object_snapshot for v in result.values()} == {999}
|
||||
new_snapshot_ids = {k: v.snapshot_id for k, v in result.items()}
|
||||
|
||||
# Listen for changes -- this should time out without an update.
|
||||
object_ref = host.listen_for_change.remote(new_snapshot_ids)
|
||||
assert ray.get(object_ref) == LongPollState.TIME_OUT
|
||||
|
||||
# Verify that the `asyncio.Event` used for the `listen_for_change` task
|
||||
# is removed.
|
||||
assert ray.get(host._get_num_notifier_events.remote()) == 0
|
||||
|
||||
|
||||
def test_host_standalone(serve_instance):
|
||||
host = ray.remote(LongPollHost).remote()
|
||||
|
||||
# Write two values
|
||||
ray.get(host.notify_changed.remote({"key_1": 999}))
|
||||
ray.get(host.notify_changed.remote({"key_2": 999}))
|
||||
object_ref = host.listen_for_change.remote({"key_1": -1, "key_2": -1})
|
||||
|
||||
# We should be able to get the result immediately
|
||||
result: Dict[str, UpdatedObject] = ray.get(object_ref)
|
||||
assert set(result.keys()) == {"key_1", "key_2"}
|
||||
assert {v.object_snapshot for v in result.values()} == {999}
|
||||
|
||||
# Now try to pull it again, nothing should happen
|
||||
# because we have the updated snapshot_id
|
||||
new_snapshot_ids = {k: v.snapshot_id for k, v in result.items()}
|
||||
object_ref = host.listen_for_change.remote(new_snapshot_ids)
|
||||
_, not_done = ray.wait([object_ref], timeout=0.2)
|
||||
assert len(not_done) == 1
|
||||
|
||||
# Now update the value, we should immediately get updated value
|
||||
ray.get(host.notify_changed.remote({"key_2": 999}))
|
||||
result = ray.get(object_ref)
|
||||
assert len(result) == 1
|
||||
assert "key_2" in result
|
||||
|
||||
|
||||
def test_long_poll_wait_for_keys(serve_instance):
|
||||
# Variation of the basic case, but the keys are requests before any values
|
||||
# are set.
|
||||
host = ray.remote(LongPollHost).remote()
|
||||
object_ref = host.listen_for_change.remote({"key_1": -1, "key_2": -1})
|
||||
|
||||
ray.get(host.notify_changed.remote({"key_1": 123, "key_2": 456}))
|
||||
|
||||
# We should be able to get the both results immediately
|
||||
result: Dict[str, UpdatedObject] = ray.get(object_ref)
|
||||
assert result.keys() == {"key_1", "key_2"}
|
||||
assert {v.object_snapshot for v in result.values()} == {123, 456}
|
||||
|
||||
|
||||
def test_long_poll_restarts(serve_instance):
|
||||
@ray.remote(
|
||||
max_restarts=-1,
|
||||
max_task_retries=-1,
|
||||
)
|
||||
class RestartableLongPollHost:
|
||||
def __init__(self) -> None:
|
||||
print("actor started")
|
||||
self.host = LongPollHost()
|
||||
self.host.notify_changed({"timer": time.time()})
|
||||
self.should_exit = False
|
||||
|
||||
async def listen_for_change(self, key_to_ids):
|
||||
print("listening for change ", key_to_ids)
|
||||
return await self.host.listen_for_change(key_to_ids)
|
||||
|
||||
async def set_exit(self):
|
||||
self.should_exit = True
|
||||
|
||||
async def exit_if_set(self):
|
||||
if self.should_exit:
|
||||
print("actor exit")
|
||||
os._exit(1)
|
||||
|
||||
host = RestartableLongPollHost.remote()
|
||||
updated_values = ray.get(host.listen_for_change.remote({"timer": -1}))
|
||||
timer: UpdatedObject = updated_values["timer"]
|
||||
|
||||
on_going_ref = host.listen_for_change.remote({"timer": timer.snapshot_id})
|
||||
ray.get(host.set_exit.remote())
|
||||
# This task should trigger the actor to exit.
|
||||
# But the retried task will not because self.should_exit is false.
|
||||
host.exit_if_set.remote()
|
||||
|
||||
# on_going_ref should return successfully with a differnt value.
|
||||
new_timer: UpdatedObject = ray.get(on_going_ref)["timer"]
|
||||
assert new_timer.snapshot_id != timer.snapshot_id + 1
|
||||
assert new_timer.object_snapshot != timer.object_snapshot
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_callbacks(serve_instance):
|
||||
host = ray.remote(LongPollHost).remote()
|
||||
|
||||
# Write two values
|
||||
ray.get(host.notify_changed.remote({"key_1": 100}))
|
||||
ray.get(host.notify_changed.remote({"key_2": 999}))
|
||||
|
||||
callback_results = dict()
|
||||
|
||||
def key_1_callback(result):
|
||||
callback_results["key_1"] = result
|
||||
|
||||
def key_2_callback(result):
|
||||
callback_results["key_2"] = result
|
||||
|
||||
_ = LongPollClient(
|
||||
host,
|
||||
{
|
||||
"key_1": key_1_callback,
|
||||
"key_2": key_2_callback,
|
||||
},
|
||||
call_in_event_loop=get_or_create_event_loop(),
|
||||
client_id="test_client_callbacks",
|
||||
)
|
||||
|
||||
await async_wait_for_condition(
|
||||
lambda: callback_results == {"key_1": 100, "key_2": 999},
|
||||
timeout=1,
|
||||
)
|
||||
|
||||
ray.get(host.notify_changed.remote({"key_2": 1999}))
|
||||
|
||||
await async_wait_for_condition(
|
||||
lambda: callback_results == {"key_1": 100, "key_2": 999},
|
||||
timeout=1,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_threadsafe(serve_instance):
|
||||
host = ray.remote(LongPollHost).remote()
|
||||
ray.get(host.notify_changed.remote({"key_1": 100}))
|
||||
|
||||
e = asyncio.Event()
|
||||
|
||||
def key_1_callback(_):
|
||||
e.set()
|
||||
|
||||
_ = LongPollClient(
|
||||
host,
|
||||
{
|
||||
"key_1": key_1_callback,
|
||||
},
|
||||
call_in_event_loop=get_or_create_event_loop(),
|
||||
client_id="test_client_threadsafe",
|
||||
)
|
||||
|
||||
await e.wait()
|
||||
|
||||
|
||||
def test_listen_for_change_java(serve_instance):
|
||||
host = ray.remote(LongPollHost).remote()
|
||||
ray.get(host.notify_changed.remote({"key_1": 999}))
|
||||
request_1 = {"keys_to_snapshot_ids": {"key_1": -1}}
|
||||
object_ref = host.listen_for_change_java.remote(
|
||||
LongPollRequest(**request_1).SerializeToString()
|
||||
)
|
||||
result_1: bytes = ray.get(object_ref)
|
||||
poll_result_1 = LongPollResult.FromString(result_1)
|
||||
assert set(poll_result_1.updated_objects.keys()) == {"key_1"}
|
||||
assert poll_result_1.updated_objects["key_1"].object_snapshot.decode() == "999"
|
||||
request_2 = {"keys_to_snapshot_ids": {"ROUTE_TABLE": -1}}
|
||||
endpoints: Dict[DeploymentID, EndpointInfo] = dict()
|
||||
endpoints["deployment_name"] = EndpointInfo(route="/test/xlang/poll")
|
||||
endpoints["deployment_name1"] = EndpointInfo(route="/test/xlang/poll1")
|
||||
ray.get(host.notify_changed.remote({LongPollNamespace.ROUTE_TABLE: endpoints}))
|
||||
object_ref_2 = host.listen_for_change_java.remote(
|
||||
LongPollRequest(**request_2).SerializeToString()
|
||||
)
|
||||
result_2: bytes = ray.get(object_ref_2)
|
||||
poll_result_2 = LongPollResult.FromString(result_2)
|
||||
assert set(poll_result_2.updated_objects.keys()) == {"ROUTE_TABLE"}
|
||||
endpoint_set = EndpointSet.FromString(
|
||||
poll_result_2.updated_objects["ROUTE_TABLE"].object_snapshot
|
||||
)
|
||||
assert set(endpoint_set.endpoints.keys()) == {"deployment_name", "deployment_name1"}
|
||||
assert endpoint_set.endpoints["deployment_name"].route == "/test/xlang/poll"
|
||||
|
||||
request_3 = {"keys_to_snapshot_ids": {"(DEPLOYMENT_TARGETS,deployment_name)": -1}}
|
||||
replicas = [
|
||||
RunningReplicaInfo(
|
||||
replica_id=ReplicaID(
|
||||
str(i), deployment_id=DeploymentID(name="deployment_name")
|
||||
),
|
||||
node_id="node_id",
|
||||
node_ip="node_ip",
|
||||
availability_zone="some-az",
|
||||
actor_name=f"SERVE_REPLICA::default#deployment_name#{i}",
|
||||
max_ongoing_requests=1,
|
||||
)
|
||||
for i in range(2)
|
||||
]
|
||||
ray.get(
|
||||
host.notify_changed.remote(
|
||||
{
|
||||
(
|
||||
LongPollNamespace.DEPLOYMENT_TARGETS,
|
||||
"deployment_name",
|
||||
): DeploymentTargetInfo(is_available=True, running_replicas=replicas)
|
||||
}
|
||||
)
|
||||
)
|
||||
object_ref_3 = host.listen_for_change_java.remote(
|
||||
LongPollRequest(**request_3).SerializeToString()
|
||||
)
|
||||
result_3: bytes = ray.get(object_ref_3)
|
||||
poll_result_3 = LongPollResult.FromString(result_3)
|
||||
da = DeploymentTargetInfoProto.FromString(
|
||||
poll_result_3.updated_objects[
|
||||
"(DEPLOYMENT_TARGETS,deployment_name)"
|
||||
].object_snapshot
|
||||
)
|
||||
assert da.replica_names == [
|
||||
"SERVE_REPLICA::default#deployment_name#0",
|
||||
"SERVE_REPLICA::default#deployment_name#1",
|
||||
]
|
||||
|
||||
|
||||
def test_listen_for_change_java_timeout_returns_empty_result(serve_instance):
|
||||
host = ray.remote(LongPollHost).remote(
|
||||
listen_for_change_request_timeout_s=(0.5, 0.5)
|
||||
)
|
||||
ray.get(host.notify_changed.remote({"key_1": 999}))
|
||||
|
||||
initial_result = LongPollResult.FromString(
|
||||
ray.get(
|
||||
host.listen_for_change_java.remote(
|
||||
LongPollRequest(keys_to_snapshot_ids={"key_1": -1}).SerializeToString()
|
||||
)
|
||||
)
|
||||
)
|
||||
snapshot_id = initial_result.updated_objects["key_1"].snapshot_id
|
||||
|
||||
timeout_result = LongPollResult.FromString(
|
||||
ray.get(
|
||||
host.listen_for_change_java.remote(
|
||||
LongPollRequest(
|
||||
keys_to_snapshot_ids={"key_1": snapshot_id}
|
||||
).SerializeToString()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert len(timeout_result.updated_objects) == 0
|
||||
|
||||
|
||||
def test_get_metric_namespace_tag(monkeypatch):
|
||||
"""Test _get_metric_namespace_tag in both default and compact modes.
|
||||
|
||||
When RAY_SERVE_COMPACT_LONG_POLL_METRIC_TAGS is disabled (default), metric
|
||||
tags use str(key) which preserves deployment-level granularity.
|
||||
When enabled, tags use only the LongPollNamespace enum name to bound
|
||||
cardinality for workloads with many deployments.
|
||||
See https://github.com/ray-project/ray/issues/62299.
|
||||
"""
|
||||
# --- Default mode (compact=False): str(key) is used ---
|
||||
monkeypatch.setattr(
|
||||
long_poll_module, "RAY_SERVE_COMPACT_LONG_POLL_METRIC_TAGS", False
|
||||
)
|
||||
|
||||
# String keys pass through unchanged
|
||||
assert _get_metric_namespace_tag("test_key") == "test_key"
|
||||
|
||||
# LongPollNamespace enum keys use str() representation
|
||||
assert _get_metric_namespace_tag(LongPollNamespace.DEPLOYMENT_CONFIG) == str(
|
||||
LongPollNamespace.DEPLOYMENT_CONFIG
|
||||
)
|
||||
|
||||
# Tuple keys include the full string (deployment name visible)
|
||||
tuple_key = (LongPollNamespace.DEPLOYMENT_CONFIG, "app1:deployment1")
|
||||
assert _get_metric_namespace_tag(tuple_key) == str(tuple_key)
|
||||
|
||||
# --- Compact mode (compact=True): low-cardinality tags ---
|
||||
monkeypatch.setattr(
|
||||
long_poll_module, "RAY_SERVE_COMPACT_LONG_POLL_METRIC_TAGS", True
|
||||
)
|
||||
|
||||
# String keys still pass through unchanged
|
||||
assert _get_metric_namespace_tag("test_key") == "test_key"
|
||||
assert _get_metric_namespace_tag("key_1") == "key_1"
|
||||
|
||||
# LongPollNamespace enum keys return just the name
|
||||
assert (
|
||||
_get_metric_namespace_tag(LongPollNamespace.DEPLOYMENT_CONFIG)
|
||||
== "DEPLOYMENT_CONFIG"
|
||||
)
|
||||
assert (
|
||||
_get_metric_namespace_tag(LongPollNamespace.DEPLOYMENT_TARGETS)
|
||||
== "DEPLOYMENT_TARGETS"
|
||||
)
|
||||
assert _get_metric_namespace_tag(LongPollNamespace.ROUTE_TABLE) == "ROUTE_TABLE"
|
||||
|
||||
# Tuple keys extract only the namespace, discarding per-deployment identifier
|
||||
assert (
|
||||
_get_metric_namespace_tag(
|
||||
(LongPollNamespace.DEPLOYMENT_CONFIG, "app1:deployment1")
|
||||
)
|
||||
== "DEPLOYMENT_CONFIG"
|
||||
)
|
||||
assert (
|
||||
_get_metric_namespace_tag(
|
||||
(LongPollNamespace.DEPLOYMENT_TARGETS, "app2:deployment2")
|
||||
)
|
||||
== "DEPLOYMENT_TARGETS"
|
||||
)
|
||||
|
||||
# Tuple keys with long deployment identifiers (the real-world case)
|
||||
long_key = (
|
||||
LongPollNamespace.DEPLOYMENT_CONFIG,
|
||||
"Deployment(name='model', app='workday:perf-test-model-00005:"
|
||||
"key-value-v1:baked-in:global:global:1')",
|
||||
)
|
||||
assert _get_metric_namespace_tag(long_key) == "DEPLOYMENT_CONFIG"
|
||||
|
||||
# All LongPollNamespace enum values should work, both bare and in tuples
|
||||
for ns in LongPollNamespace:
|
||||
assert _get_metric_namespace_tag(ns) == ns.name
|
||||
assert _get_metric_namespace_tag((ns, "any_deployment")) == ns.name
|
||||
|
||||
# Defensive: tuple with non-enum first element falls back to str()
|
||||
assert _get_metric_namespace_tag(("custom_ns", "value")) == "custom_ns"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_clients_gauge_aggregates_across_keys(serve_instance):
|
||||
"""Test that pending_clients_by_namespace correctly aggregates across keys.
|
||||
|
||||
When compact metric tags are enabled and multiple keys share a namespace
|
||||
tag (e.g., two DEPLOYMENT_CONFIG keys for different deployments), the gauge
|
||||
must reflect the total pending clients for the namespace, not just the last
|
||||
key's count.
|
||||
See https://github.com/ray-project/ray/issues/62299.
|
||||
"""
|
||||
host = (
|
||||
ray.remote(LongPollHost)
|
||||
.options(
|
||||
runtime_env={"env_vars": {"RAY_SERVE_COMPACT_LONG_POLL_METRIC_TAGS": "1"}}
|
||||
)
|
||||
.remote(listen_for_change_request_timeout_s=(0.5, 0.5))
|
||||
)
|
||||
|
||||
key_a = (LongPollNamespace.DEPLOYMENT_CONFIG, "app1:deploy1")
|
||||
key_b = (LongPollNamespace.DEPLOYMENT_CONFIG, "app2:deploy2")
|
||||
|
||||
# Notify initial values so the keys exist
|
||||
ray.get(host.notify_changed.remote({key_a: "v1", key_b: "v2"}))
|
||||
|
||||
# Subscribe to both keys — this should register 2 pending clients
|
||||
# for the DEPLOYMENT_CONFIG namespace
|
||||
ref = host.listen_for_change.remote({key_a: -1, key_b: -1})
|
||||
result = ray.get(ref)
|
||||
assert key_a in result and key_b in result
|
||||
|
||||
# Now subscribe with up-to-date snapshot IDs so we actually block
|
||||
snapshot_ids = {k: v.snapshot_id for k, v in result.items()}
|
||||
ref = host.listen_for_change.remote(snapshot_ids)
|
||||
|
||||
# The pending clients count for DEPLOYMENT_CONFIG should be 2
|
||||
# (one event per key). We can't read the gauge directly, but we
|
||||
# verify the internal tracking counter.
|
||||
counter = ray.get(
|
||||
host._get_pending_clients_by_namespace.remote("DEPLOYMENT_CONFIG")
|
||||
)
|
||||
assert counter == 2, f"Expected 2 pending clients, got {counter}"
|
||||
|
||||
# Notify one key — should decrement by 1, not set to 0
|
||||
ray.get(host.notify_changed.remote({key_a: "v1_updated"}))
|
||||
|
||||
# Wait for the listen_for_change to return
|
||||
result2 = ray.get(ref)
|
||||
assert key_a in result2
|
||||
|
||||
# After notification, the counter should have been decremented
|
||||
counter_after = ray.get(
|
||||
host._get_pending_clients_by_namespace.remote("DEPLOYMENT_CONFIG")
|
||||
)
|
||||
# Counter should be 0 now (both events cleaned up: one by notify_changed,
|
||||
# one by the not_done cleanup in listen_for_change)
|
||||
assert counter_after == 0, f"Expected 0 pending clients, got {counter_after}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_clients_gauge_non_compact_mode(serve_instance):
|
||||
"""Test pending_clients bookkeeping when RAY_SERVE_COMPACT_LONG_POLL_METRIC_TAGS=0.
|
||||
|
||||
In non-compact mode, each key maps to its own namespace tag via str(key).
|
||||
Verify that pending client counts are correctly incremented, decremented,
|
||||
and cleaned up for each per-key tag, with no regressions in the new
|
||||
bookkeeping logic.
|
||||
See https://github.com/ray-project/ray/issues/62299.
|
||||
"""
|
||||
host = (
|
||||
ray.remote(LongPollHost)
|
||||
.options(
|
||||
runtime_env={"env_vars": {"RAY_SERVE_COMPACT_LONG_POLL_METRIC_TAGS": "0"}}
|
||||
)
|
||||
.remote(listen_for_change_request_timeout_s=(0.5, 0.5))
|
||||
)
|
||||
|
||||
key_a = (LongPollNamespace.DEPLOYMENT_CONFIG, "app1:deploy1")
|
||||
key_b = (LongPollNamespace.DEPLOYMENT_CONFIG, "app2:deploy2")
|
||||
|
||||
# Notify initial values so the keys exist
|
||||
ray.get(host.notify_changed.remote({key_a: "v1", key_b: "v2"}))
|
||||
|
||||
# Get initial snapshots
|
||||
ref = host.listen_for_change.remote({key_a: -1, key_b: -1})
|
||||
result = ray.get(ref)
|
||||
assert key_a in result and key_b in result
|
||||
|
||||
# Subscribe with up-to-date snapshot IDs so we block (pending)
|
||||
snapshot_ids = {k: v.snapshot_id for k, v in result.items()}
|
||||
ref = host.listen_for_change.remote(snapshot_ids)
|
||||
|
||||
# In non-compact mode, each key has its own namespace tag (str(key)),
|
||||
# so each should have exactly 1 pending client.
|
||||
tag_a = str(key_a)
|
||||
tag_b = str(key_b)
|
||||
counter_a = ray.get(host._get_pending_clients_by_namespace.remote(tag_a))
|
||||
counter_b = ray.get(host._get_pending_clients_by_namespace.remote(tag_b))
|
||||
assert counter_a == 1, f"Expected 1 pending client for {tag_a}, got {counter_a}"
|
||||
assert counter_b == 1, f"Expected 1 pending client for {tag_b}, got {counter_b}"
|
||||
|
||||
# Notify key_a — its pending count should drop, key_b unaffected
|
||||
ray.get(host.notify_changed.remote({key_a: "v1_updated"}))
|
||||
result2 = ray.get(ref)
|
||||
assert key_a in result2
|
||||
|
||||
# After notification + cleanup, both counters should be 0
|
||||
counter_a_after = ray.get(host._get_pending_clients_by_namespace.remote(tag_a))
|
||||
counter_b_after = ray.get(host._get_pending_clients_by_namespace.remote(tag_b))
|
||||
assert (
|
||||
counter_a_after == 0
|
||||
), f"Expected 0 pending clients for {tag_a}, got {counter_a_after}"
|
||||
assert (
|
||||
counter_b_after == 0
|
||||
), f"Expected 0 pending clients for {tag_b}, got {counter_b_after}"
|
||||
|
||||
# Verify cleanup with timeout: subscribe and let it time out
|
||||
snapshot_ids2 = {k: v.snapshot_id for k, v in result2.items()}
|
||||
# Also include key_b's latest snapshot
|
||||
snapshot_ids2[key_b] = result[key_b].snapshot_id
|
||||
ref2 = host.listen_for_change.remote(snapshot_ids2)
|
||||
timeout_result = ray.get(ref2)
|
||||
assert timeout_result == LongPollState.TIME_OUT
|
||||
|
||||
# After timeout, all pending counters should be cleaned up to 0
|
||||
counter_a_timeout = ray.get(host._get_pending_clients_by_namespace.remote(tag_a))
|
||||
counter_b_timeout = ray.get(host._get_pending_clients_by_namespace.remote(tag_b))
|
||||
assert (
|
||||
counter_a_timeout == 0
|
||||
), f"Expected 0 after timeout for {tag_a}, got {counter_a_timeout}"
|
||||
assert (
|
||||
counter_b_timeout == 0
|
||||
), f"Expected 0 after timeout for {tag_b}, got {counter_b_timeout}"
|
||||
|
||||
|
||||
def test_remove_keys_evicts_per_key_state(serve_instance):
|
||||
"""After remove_keys, a poll with a stale snapshot id must time out
|
||||
instead of returning the stale snapshot."""
|
||||
host = ray.remote(LongPollHost).remote(
|
||||
listen_for_change_request_timeout_s=(0.2, 0.2)
|
||||
)
|
||||
key = (LongPollNamespace.DEPLOYMENT_CONFIG, "app1:deploy1")
|
||||
|
||||
ray.get(host.notify_changed.remote({key: "config-v1"}))
|
||||
|
||||
initial = ray.get(host.listen_for_change.remote({key: -1}))
|
||||
assert key in initial
|
||||
stale_snapshot_ids = {key: initial[key].snapshot_id}
|
||||
|
||||
ray.get(host.remove_keys.remote([key]))
|
||||
|
||||
poll_result = ray.get(host.listen_for_change.remote(stale_snapshot_ids))
|
||||
assert poll_result == LongPollState.TIME_OUT
|
||||
|
||||
|
||||
def test_remove_keys_wakes_parked_waiters(serve_instance):
|
||||
"""Parked RPCs return promptly after remove_keys (not at the 30 s
|
||||
timeout); the evicted key is filtered out of the returned dict."""
|
||||
host = ray.remote(LongPollHost).remote(listen_for_change_request_timeout_s=(30, 30))
|
||||
key = (LongPollNamespace.DEPLOYMENT_CONFIG, "app:deploy")
|
||||
|
||||
ray.get(host.notify_changed.remote({key: "v1"}))
|
||||
initial = ray.get(host.listen_for_change.remote({key: -1}))
|
||||
snapshot_id = initial[key].snapshot_id
|
||||
|
||||
parked_ref = host.listen_for_change.remote({key: snapshot_id})
|
||||
wait_for_condition(
|
||||
lambda: ray.get(host._get_num_notifier_events.remote()) == 1,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
ray.get(host.remove_keys.remote([key]))
|
||||
|
||||
start = time.time()
|
||||
result = ray.get(parked_ref, timeout=5)
|
||||
elapsed = time.time() - start
|
||||
assert elapsed < 5, f"parked RPC took {elapsed:.2f}s; should have woken"
|
||||
assert result == {}, f"expected empty dict, got {result}"
|
||||
assert ray.get(host._get_num_notifier_events.remote()) == 0
|
||||
|
||||
|
||||
def test_remove_keys_decrements_pending_clients_gauge(serve_instance):
|
||||
"""Gauge must return to 0 after remove_keys wakes parked waiters.
|
||||
Otherwise the listen_for_change timeout cleanup skips the decrement
|
||||
on the now-missing notifier_events entry and inflates the gauge."""
|
||||
host = (
|
||||
ray.remote(LongPollHost)
|
||||
.options(
|
||||
runtime_env={"env_vars": {"RAY_SERVE_COMPACT_LONG_POLL_METRIC_TAGS": "1"}}
|
||||
)
|
||||
.remote(listen_for_change_request_timeout_s=(30, 30))
|
||||
)
|
||||
key = (LongPollNamespace.DEPLOYMENT_CONFIG, "app:deploy")
|
||||
|
||||
ray.get(host.notify_changed.remote({key: "v1"}))
|
||||
initial = ray.get(host.listen_for_change.remote({key: -1}))
|
||||
snapshot_id = initial[key].snapshot_id
|
||||
|
||||
parked_ref = host.listen_for_change.remote({key: snapshot_id})
|
||||
|
||||
wait_for_condition(
|
||||
lambda: ray.get(
|
||||
host._get_pending_clients_by_namespace.remote("DEPLOYMENT_CONFIG")
|
||||
)
|
||||
== 1,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
ray.get(host.remove_keys.remote([key]))
|
||||
ray.get(parked_ref, timeout=5)
|
||||
|
||||
assert (
|
||||
ray.get(host._get_pending_clients_by_namespace.remote("DEPLOYMENT_CONFIG")) == 0
|
||||
)
|
||||
|
||||
|
||||
def test_delete_tombstone_reaches_parked_subscriber(serve_instance):
|
||||
"""Parked subscriber must receive the DEPLOYMENT_TARGETS tombstone.
|
||||
|
||||
Evicting the tombstoned key in the same sync tick would race the
|
||||
waiter: it wakes after update() returns, sees the key gone, and
|
||||
drops the tombstone.
|
||||
"""
|
||||
host = ray.remote(LongPollHost).remote(listen_for_change_request_timeout_s=(30, 30))
|
||||
|
||||
targets_key = (LongPollNamespace.DEPLOYMENT_TARGETS, "d1")
|
||||
config_key = (LongPollNamespace.DEPLOYMENT_CONFIG, "d1")
|
||||
|
||||
target_info_initial = DeploymentTargetInfo(is_available=True, running_replicas=[])
|
||||
ray.get(
|
||||
host.notify_changed.remote(
|
||||
{targets_key: target_info_initial, config_key: "config-v1"}
|
||||
)
|
||||
)
|
||||
|
||||
initial = ray.get(host.listen_for_change.remote({targets_key: -1, config_key: -1}))
|
||||
targets_snapshot_id = initial[targets_key].snapshot_id
|
||||
|
||||
parked_ref = host.listen_for_change.remote({targets_key: targets_snapshot_id})
|
||||
wait_for_condition(
|
||||
lambda: ray.get(host._get_num_notifier_events.remote()) == 1,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
# Simulate the delete path: tombstone TARGETS, evict only CONFIG.
|
||||
tombstone = DeploymentTargetInfo(is_available=False, running_replicas=[])
|
||||
ray.get(host.notify_changed.remote({targets_key: tombstone}))
|
||||
ray.get(host.remove_keys.remote([config_key]))
|
||||
|
||||
result = ray.get(parked_ref, timeout=5)
|
||||
assert targets_key in result, f"Subscriber missed the tombstone: {result}"
|
||||
delivered = result[targets_key].object_snapshot
|
||||
assert delivered.is_available is False
|
||||
assert delivered.running_replicas == []
|
||||
|
||||
|
||||
def test_long_poll_client_disable_propagates_to_host_log():
|
||||
host = LongPollHost()
|
||||
host_actor = MagicMock()
|
||||
host_actor.notify_long_poll_client_disabled.remote.side_effect = (
|
||||
host.notify_client_disabled
|
||||
)
|
||||
|
||||
serve_logger = logging.getLogger("ray.serve")
|
||||
buf = io.StringIO()
|
||||
handler = logging.StreamHandler(buf)
|
||||
serve_logger.addHandler(handler)
|
||||
loop = asyncio.new_event_loop() # never run; is_running() == False
|
||||
try:
|
||||
client = LongPollClient(
|
||||
host_actor, {}, call_in_event_loop=loop, client_id="TestClient"
|
||||
)
|
||||
client._schedule_to_event_loop(lambda: None)
|
||||
finally:
|
||||
loop.close()
|
||||
serve_logger.removeHandler(handler)
|
||||
|
||||
assert client.is_running is False
|
||||
output = buf.getvalue()
|
||||
assert "TestClient" in output, output
|
||||
assert "not running" in output.lower(), output
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", "-s", __file__]))
|
||||
Reference in New Issue
Block a user