Skip to content

Commit 36660da

Browse files
Added end to end tests for dependency ordered shutdown
Signed-off-by: YashwanthRanjanSingaravel <yashwanth.ranjansingaravel@anyscale.com>
1 parent a98eab7 commit 36660da

2 files changed

Lines changed: 392 additions & 0 deletions

File tree

python/ray/serve/tests/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ py_test_module_list(
160160
py_test_module_list(
161161
size = "large",
162162
files = [
163+
"test_dependency_ordered_shutdown.py",
163164
"test_deployment_scheduler.py",
164165
],
165166
tags = [
Lines changed: 391 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,391 @@
1+
import asyncio
2+
import sys
3+
from typing import List, Set, Tuple
4+
5+
import pytest
6+
7+
import ray
8+
from ray import serve
9+
from ray._common.test_utils import wait_for_condition
10+
from ray.serve._private.constants import SERVE_NAMESPACE
11+
from ray.serve._private.test_utils import Accumulator
12+
from ray.serve.api import get_deployment_handle
13+
from ray.serve.context import _get_global_client
14+
from ray.serve.handle import DeploymentHandle
15+
16+
17+
@pytest.fixture
18+
def serve_shutdown_instance(request, monkeypatch):
19+
"""A Serve instance the test itself is allowed to shut down."""
20+
for name, value in getattr(request, "param", {}).items():
21+
monkeypatch.setenv(name, value)
22+
23+
ray.init(num_cpus=16, namespace="test_dependency_ordered_shutdown")
24+
yield
25+
serve.shutdown()
26+
ray.shutdown()
27+
28+
29+
class _RecordsShutdown:
30+
"""Record a deployment name when its replica is torn down."""
31+
32+
def _record_shutdown_as(self, name: str, recorder: ray.actor.ActorHandle):
33+
self._shutdown_name = name
34+
self._shutdown_recorder = recorder
35+
36+
async def __del__(self):
37+
await self._shutdown_recorder.add.remote(self._shutdown_name)
38+
39+
40+
@serve.deployment
41+
class Node(_RecordsShutdown):
42+
"""A deployment that calls its downstream handles and records its teardown."""
43+
44+
def __init__(
45+
self, name: str, recorder: ray.actor.ActorHandle, *downstream: DeploymentHandle
46+
):
47+
self._record_shutdown_as(name, recorder)
48+
self._downstream = downstream
49+
50+
async def __call__(self) -> str:
51+
results = [await handle.remote() for handle in self._downstream]
52+
return "/".join([self._shutdown_name, *results])
53+
54+
55+
@serve.deployment
56+
class LinkedNode(_RecordsShutdown):
57+
"""Builds its downstream handle in the constructor.
58+
59+
Used for edges a bind graph cannot express, such as an edge into another
60+
app or an edge that closes a cycle.
61+
"""
62+
63+
def __init__(
64+
self,
65+
name: str,
66+
recorder: ray.actor.ActorHandle,
67+
target_name: str,
68+
target_app: str,
69+
):
70+
self._record_shutdown_as(name, recorder)
71+
self._downstream = get_deployment_handle(target_name, target_app)
72+
73+
async def __call__(self) -> str:
74+
return f"{self._shutdown_name}/{await self._downstream.remote()}"
75+
76+
77+
@serve.deployment
78+
class LazyNode(_RecordsShutdown):
79+
"""Builds its downstream handle per request, after it reported as ready."""
80+
81+
def __init__(
82+
self,
83+
name: str,
84+
recorder: ray.actor.ActorHandle,
85+
target_name: str,
86+
target_app: str,
87+
):
88+
self._record_shutdown_as(name, recorder)
89+
self._target = (target_name, target_app)
90+
91+
async def __call__(self) -> str:
92+
handle = get_deployment_handle(*self._target)
93+
return f"{self._shutdown_name}/{await handle.remote()}"
94+
95+
96+
@serve.deployment
97+
class PlainNode:
98+
"""Same shape as Node but does not record its teardown."""
99+
100+
def __init__(self, *downstream: DeploymentHandle):
101+
self._downstream = downstream
102+
103+
async def __call__(self) -> str:
104+
for handle in self._downstream:
105+
await handle.remote()
106+
return "plain"
107+
108+
109+
@serve.deployment(graceful_shutdown_timeout_s=1000)
110+
class WedgedNode:
111+
"""A deployment whose replica never finishes shutting down."""
112+
113+
def __init__(self, *downstream: DeploymentHandle):
114+
self._downstream = downstream
115+
116+
async def __call__(self) -> str:
117+
for handle in self._downstream:
118+
await handle.remote()
119+
return "wedged"
120+
121+
async def __del__(self):
122+
await asyncio.sleep(1000)
123+
124+
125+
def _node(name: str, recorder: ray.actor.ActorHandle, *downstream):
126+
return Node.options(name=name).bind(name, recorder, *downstream)
127+
128+
129+
def _linked(
130+
name: str, recorder: ray.actor.ActorHandle, target_name: str, target_app: str
131+
):
132+
return LinkedNode.options(name=name).bind(name, recorder, target_name, target_app)
133+
134+
135+
def _lazy(
136+
name: str, recorder: ray.actor.ActorHandle, target_name: str, target_app: str
137+
):
138+
return LazyNode.options(name=name).bind(name, recorder, target_name, target_app)
139+
140+
141+
def _plain(name: str, *downstream):
142+
return PlainNode.options(name=name).bind(*downstream)
143+
144+
145+
def _outbound(app_name: str, deployment_name: str) -> Set[Tuple[str, str]]:
146+
"""The controller's view of what a deployment calls, as (app, name) pairs."""
147+
details = _get_global_client().get_serve_details()
148+
topology = details["applications"][app_name]["deployment_topology"]
149+
return {
150+
(dep["app_name"], dep["name"])
151+
for dep in topology["nodes"][deployment_name]["outbound_deployments"]
152+
}
153+
154+
155+
def _shutdown_order(recorder: ray.actor.ActorHandle) -> List[str]:
156+
return ray.get(recorder.get.remote())
157+
158+
159+
def _replica_actor(deployment_name: str, app_name: str) -> ray.actor.ActorHandle:
160+
"""Handle to a live replica actor, raising if the replica is gone."""
161+
prefix = f"SERVE_REPLICA::{app_name}#{deployment_name}#"
162+
for actor in ray.util.list_named_actors(all_namespaces=True):
163+
if actor["name"].startswith(prefix):
164+
return ray.get_actor(actor["name"], namespace=SERVE_NAMESPACE)
165+
166+
raise RuntimeError(f"No live replica for {deployment_name} in app {app_name}.")
167+
168+
169+
class TestDependencyOrderedShutdown:
170+
"""Teardown order for topologies the controller fully knows."""
171+
172+
def test_linear_chain(self, serve_shutdown_instance):
173+
recorder = Accumulator.remote()
174+
175+
handle = serve.run(
176+
_node(
177+
"Ingress", recorder, _node("Middle", recorder, _node("Leaf", recorder))
178+
),
179+
name="chain",
180+
)
181+
assert handle.remote().result() == "Ingress/Middle/Leaf"
182+
183+
wait_for_condition(
184+
lambda: _outbound("chain", "Ingress") == {("chain", "Middle")}
185+
and _outbound("chain", "Middle") == {("chain", "Leaf")}
186+
and _outbound("chain", "Leaf") == set()
187+
)
188+
189+
serve.shutdown()
190+
191+
assert _shutdown_order(recorder) == ["Ingress", "Middle", "Leaf"]
192+
193+
def test_diamond_shared_leaf_last(self, serve_shutdown_instance):
194+
recorder = Accumulator.remote()
195+
196+
leaf = _node("Leaf", recorder)
197+
handle = serve.run(
198+
_node(
199+
"Ingress",
200+
recorder,
201+
_node("M1", recorder, leaf),
202+
_node("M2", recorder, leaf),
203+
),
204+
name="diamond",
205+
)
206+
assert handle.remote().result() == "Ingress/M1/Leaf/M2/Leaf"
207+
208+
wait_for_condition(
209+
lambda: _outbound("diamond", "Ingress")
210+
== {("diamond", "M1"), ("diamond", "M2")}
211+
and _outbound("diamond", "M1") == {("diamond", "Leaf")}
212+
and _outbound("diamond", "M2") == {("diamond", "Leaf")}
213+
)
214+
215+
serve.shutdown()
216+
217+
order = _shutdown_order(recorder)
218+
assert order[0] == "Ingress"
219+
assert order[-1] == "Leaf"
220+
assert set(order[1:3]) == {"M1", "M2"}
221+
222+
def test_independent_apps(self, serve_shutdown_instance):
223+
recorder = Accumulator.remote()
224+
225+
serve.run(
226+
_node("Ingress1", recorder, _node("Backend1", recorder)),
227+
name="app1",
228+
route_prefix="/app1",
229+
)
230+
serve.run(
231+
_node("Ingress2", recorder, _node("Backend2", recorder)),
232+
name="app2",
233+
route_prefix="/app2",
234+
)
235+
236+
wait_for_condition(
237+
lambda: _outbound("app1", "Ingress1") == {("app1", "Backend1")}
238+
and _outbound("app2", "Ingress2") == {("app2", "Backend2")}
239+
)
240+
241+
serve.shutdown()
242+
243+
order = _shutdown_order(recorder)
244+
assert set(order) == {"Ingress1", "Backend1", "Ingress2", "Backend2"}
245+
assert max(order.index("Ingress1"), order.index("Ingress2")) < min(
246+
order.index("Backend1"), order.index("Backend2")
247+
)
248+
249+
def test_cross_app_chain(self, serve_shutdown_instance):
250+
"""A caller in one app is torn down before its callee in another."""
251+
recorder = Accumulator.remote()
252+
253+
serve.run(
254+
_node("Middle", recorder, _node("Leaf", recorder)),
255+
name="backend",
256+
route_prefix="/backend",
257+
)
258+
handle = serve.run(
259+
_linked("Caller", recorder, "Middle", "backend"),
260+
name="frontend",
261+
route_prefix="/frontend",
262+
)
263+
assert handle.remote().result() == "Caller/Middle/Leaf"
264+
265+
wait_for_condition(
266+
lambda: _outbound("frontend", "Caller") == {("backend", "Middle")}
267+
and _outbound("backend", "Middle") == {("backend", "Leaf")}
268+
)
269+
270+
serve.shutdown()
271+
272+
assert _shutdown_order(recorder) == ["Caller", "Middle", "Leaf"]
273+
274+
275+
class TestBestEffortTopologyShutdown:
276+
"""Shutdown when the topology is incomplete, cyclic, or cannot drain."""
277+
278+
def test_incomplete_topology(self, serve_shutdown_instance):
279+
"""Handles created at request time are missing from the topology."""
280+
recorder = Accumulator.remote()
281+
282+
serve.run(_node("Leaf", recorder), name="leaf_app", route_prefix="/leaf")
283+
handle = serve.run(
284+
_node("Ingress", recorder, _lazy("Middle", recorder, "Leaf", "leaf_app")),
285+
name="main",
286+
route_prefix="/main",
287+
)
288+
289+
assert handle.remote().result() == "Ingress/Middle/Leaf"
290+
291+
wait_for_condition(lambda: _outbound("main", "Ingress") == {("main", "Middle")})
292+
assert _outbound("main", "Middle") == set()
293+
294+
serve.shutdown()
295+
296+
order = _shutdown_order(recorder)
297+
assert set(order) == {"Ingress", "Middle", "Leaf"}
298+
299+
# The known edge is still respected.
300+
assert order.index("Ingress") < order.index("Middle")
301+
302+
def test_cycle(self, serve_shutdown_instance):
303+
"""A cycle has no caller first order, so it is torn down as a group."""
304+
recorder = Accumulator.remote()
305+
306+
# Redeploy A after B is up to build the cycle.
307+
serve.run(_plain("A"), name="app_a", route_prefix="/a")
308+
serve.run(_linked("B", recorder, "A", "app_a"), name="app_b", route_prefix="/b")
309+
serve.run(_linked("A", recorder, "B", "app_b"), name="app_a", route_prefix="/a")
310+
311+
wait_for_condition(
312+
lambda: _outbound("app_a", "A") == {("app_b", "B")}
313+
and _outbound("app_b", "B") == {("app_a", "A")},
314+
timeout=20,
315+
)
316+
317+
serve.shutdown()
318+
319+
assert sorted(_shutdown_order(recorder)) == ["A", "B"]
320+
321+
def test_ingress_into_cycle(self, serve_shutdown_instance):
322+
"""An ingress feeding a cycle drains before the cyclic remainder."""
323+
recorder = Accumulator.remote()
324+
325+
serve.run(_plain("Ingress", _plain("A")), name="app_a", route_prefix="/a")
326+
serve.run(_linked("B", recorder, "A", "app_a"), name="app_b", route_prefix="/b")
327+
serve.run(
328+
_node("Ingress", recorder, _linked("A", recorder, "B", "app_b")),
329+
name="app_a",
330+
route_prefix="/a",
331+
)
332+
333+
wait_for_condition(
334+
lambda: _outbound("app_a", "Ingress") == {("app_a", "A")}
335+
and _outbound("app_a", "A") == {("app_b", "B")}
336+
and _outbound("app_b", "B") == {("app_a", "A")},
337+
timeout=20,
338+
)
339+
340+
serve.shutdown()
341+
342+
order = _shutdown_order(recorder)
343+
assert order[0] == "Ingress"
344+
assert sorted(order[1:]) == ["A", "B"]
345+
346+
@pytest.mark.parametrize(
347+
"serve_shutdown_instance",
348+
[{"RAY_SERVE_SHUTDOWN_TIER_TIMEOUT_S": "2"}],
349+
indirect=True,
350+
)
351+
def test_tier_that_never_drains(self, serve_shutdown_instance):
352+
"""A replica that refuses to stop does not block the tiers behind it."""
353+
recorder = Accumulator.remote()
354+
355+
handle = serve.run(
356+
_node(
357+
"Ingress",
358+
recorder,
359+
WedgedNode.options(name="Middle").bind(_node("Leaf", recorder)),
360+
),
361+
name="chain",
362+
)
363+
assert handle.remote().result() == "Ingress/wedged"
364+
365+
# Start the shutdown without blocking the driver on the stuck replica.
366+
client = _get_global_client()
367+
ray.get(client._controller.graceful_shutdown.remote(False))
368+
369+
wait_for_condition(lambda: "Leaf" in _shutdown_order(recorder), timeout=60)
370+
assert _shutdown_order(recorder) == ["Ingress", "Leaf"]
371+
372+
# Leaf was torn down while Middle was still stopping, which is only
373+
# possible if shutdown advanced past the tier that never drained.
374+
middle_replica = _replica_actor("Middle", "chain")
375+
ray.kill(middle_replica)
376+
377+
def test_no_applications(self, serve_shutdown_instance):
378+
"""Shutting down an instance with nothing deployed completes."""
379+
serve.start()
380+
381+
serve.shutdown()
382+
383+
assert not [
384+
actor
385+
for actor in ray.util.list_named_actors(all_namespaces=True)
386+
if actor["name"].startswith("SERVE")
387+
]
388+
389+
390+
if __name__ == "__main__":
391+
sys.exit(pytest.main(["-v", "-s", __file__]))

0 commit comments

Comments
 (0)