11import asyncio
22import sys
3- from typing import List , Set , Tuple
3+ from typing import Dict , List , Set , Tuple
44
55import pytest
66
1515
1616
1717@pytest .fixture
18- def serve_shutdown_instance (request , monkeypatch ):
19- """A Serve instance the test itself is allowed to shut down."""
18+ def shutdown_test_cluster (request , monkeypatch ):
19+ """A Ray cluster whose Serve instance the test itself shuts down."""
2020 for name , value in getattr (request , "param" , {}).items ():
2121 monkeypatch .setenv (name , value )
2222
@@ -95,14 +95,12 @@ async def __call__(self) -> str:
9595
9696@serve .deployment
9797class PlainNode :
98- """Same shape as Node but does not record its teardown ."""
98+ """A placeholder for a deployment a LinkedNode needs to already exist ."""
9999
100100 def __init__ (self , * downstream : DeploymentHandle ):
101101 self ._downstream = downstream
102102
103- async def __call__ (self ) -> str :
104- for handle in self ._downstream :
105- await handle .remote ()
103+ def __call__ (self ) -> str :
106104 return "plain"
107105
108106
@@ -113,9 +111,7 @@ class WedgedNode:
113111 def __init__ (self , * downstream : DeploymentHandle ):
114112 self ._downstream = downstream
115113
116- async def __call__ (self ) -> str :
117- for handle in self ._downstream :
118- await handle .remote ()
114+ def __call__ (self ) -> str :
119115 return "wedged"
120116
121117 async def __del__ (self ):
@@ -152,6 +148,20 @@ def _outbound(app_name: str, deployment_name: str) -> Set[Tuple[str, str]]:
152148 }
153149
154150
151+ def _wait_for_topology (expected : Dict [Tuple [str , str ], Set [Tuple [str , str ]]]):
152+ """Wait until the controller sees exactly these caller to callee edges."""
153+ seen = {}
154+
155+ def _matches () -> bool :
156+ seen .update ({node : _outbound (* node ) for node in expected })
157+ return seen == expected
158+
159+ try :
160+ wait_for_condition (_matches , timeout = 20 )
161+ except RuntimeError :
162+ raise AssertionError (f"Expected topology { expected } , controller sees { seen } ." )
163+
164+
155165def _shutdown_order (recorder : ray .actor .ActorHandle ) -> List [str ]:
156166 return ray .get (recorder .get .remote ())
157167
@@ -166,10 +176,10 @@ def _replica_actor(deployment_name: str, app_name: str) -> ray.actor.ActorHandle
166176 raise RuntimeError (f"No live replica for { deployment_name } in app { app_name } ." )
167177
168178
169- class TestDependencyOrderedShutdown :
179+ class TestKnownTopologyShutdown :
170180 """Teardown order for topologies the controller fully knows."""
171181
172- def test_linear_chain (self , serve_shutdown_instance ):
182+ def test_linear_chain (self , shutdown_test_cluster ):
173183 recorder = Accumulator .remote ()
174184
175185 handle = serve .run (
@@ -180,73 +190,19 @@ def test_linear_chain(self, serve_shutdown_instance):
180190 )
181191 assert handle .remote ().result () == "Ingress/Middle/Leaf"
182192
183- wait_for_condition (
184- lambda : _outbound ("chain" , "Ingress" ) == {("chain" , "Middle" )}
185- and _outbound ("chain" , "Middle" ) == {("chain" , "Leaf" )}
186- and _outbound ("chain" , "Leaf" ) == set ()
193+ _wait_for_topology (
194+ {
195+ ("chain" , "Ingress" ): {("chain" , "Middle" )},
196+ ("chain" , "Middle" ): {("chain" , "Leaf" )},
197+ ("chain" , "Leaf" ): set (),
198+ }
187199 )
188200
189201 serve .shutdown ()
190202
191203 assert _shutdown_order (recorder ) == ["Ingress" , "Middle" , "Leaf" ]
192204
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 ):
205+ def test_cross_app_chain (self , shutdown_test_cluster ):
250206 """A caller in one app is torn down before its callee in another."""
251207 recorder = Accumulator .remote ()
252208
@@ -262,9 +218,11 @@ def test_cross_app_chain(self, serve_shutdown_instance):
262218 )
263219 assert handle .remote ().result () == "Caller/Middle/Leaf"
264220
265- wait_for_condition (
266- lambda : _outbound ("frontend" , "Caller" ) == {("backend" , "Middle" )}
267- and _outbound ("backend" , "Middle" ) == {("backend" , "Leaf" )}
221+ _wait_for_topology (
222+ {
223+ ("frontend" , "Caller" ): {("backend" , "Middle" )},
224+ ("backend" , "Middle" ): {("backend" , "Leaf" )},
225+ }
268226 )
269227
270228 serve .shutdown ()
@@ -275,7 +233,7 @@ def test_cross_app_chain(self, serve_shutdown_instance):
275233class TestBestEffortTopologyShutdown :
276234 """Shutdown when the topology is incomplete, cyclic, or cannot drain."""
277235
278- def test_incomplete_topology (self , serve_shutdown_instance ):
236+ def test_incomplete_topology (self , shutdown_test_cluster ):
279237 """Handles created at request time are missing from the topology."""
280238 recorder = Accumulator .remote ()
281239
@@ -288,8 +246,12 @@ def test_incomplete_topology(self, serve_shutdown_instance):
288246
289247 assert handle .remote ().result () == "Ingress/Middle/Leaf"
290248
291- wait_for_condition (lambda : _outbound ("main" , "Ingress" ) == {("main" , "Middle" )})
292- assert _outbound ("main" , "Middle" ) == set ()
249+ _wait_for_topology (
250+ {
251+ ("main" , "Ingress" ): {("main" , "Middle" )},
252+ ("main" , "Middle" ): set (),
253+ }
254+ )
293255
294256 serve .shutdown ()
295257
@@ -299,26 +261,7 @@ def test_incomplete_topology(self, serve_shutdown_instance):
299261 # The known edge is still respected.
300262 assert order .index ("Ingress" ) < order .index ("Middle" )
301263
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 ):
264+ def test_ingress_into_cycle (self , shutdown_test_cluster ):
322265 """An ingress feeding a cycle drains before the cyclic remainder."""
323266 recorder = Accumulator .remote ()
324267
@@ -330,11 +273,12 @@ def test_ingress_into_cycle(self, serve_shutdown_instance):
330273 route_prefix = "/a" ,
331274 )
332275
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 ,
276+ _wait_for_topology (
277+ {
278+ ("app_a" , "Ingress" ): {("app_a" , "A" )},
279+ ("app_a" , "A" ): {("app_b" , "B" )},
280+ ("app_b" , "B" ): {("app_a" , "A" )},
281+ }
338282 )
339283
340284 serve .shutdown ()
@@ -344,11 +288,11 @@ def test_ingress_into_cycle(self, serve_shutdown_instance):
344288 assert sorted (order [1 :]) == ["A" , "B" ]
345289
346290 @pytest .mark .parametrize (
347- "serve_shutdown_instance " ,
291+ "shutdown_test_cluster " ,
348292 [{"RAY_SERVE_SHUTDOWN_TIER_TIMEOUT_S" : "2" }],
349293 indirect = True ,
350294 )
351- def test_tier_that_never_drains (self , serve_shutdown_instance ):
295+ def test_tier_that_never_drains (self , shutdown_test_cluster ):
352296 """A replica that refuses to stop does not block the tiers behind it."""
353297 recorder = Accumulator .remote ()
354298
@@ -362,29 +306,25 @@ def test_tier_that_never_drains(self, serve_shutdown_instance):
362306 )
363307 assert handle .remote ().result () == "Ingress/wedged"
364308
309+ _wait_for_topology (
310+ {
311+ ("chain" , "Ingress" ): {("chain" , "Middle" )},
312+ ("chain" , "Middle" ): {("chain" , "Leaf" )},
313+ }
314+ )
315+
365316 # Start the shutdown without blocking the driver on the stuck replica.
366317 client = _get_global_client ()
367318 ray .get (client ._controller .graceful_shutdown .remote (False ))
368319
369- wait_for_condition (lambda : "Leaf" in _shutdown_order (recorder ), timeout = 60 )
320+ # Leaf can only be torn down while Middle is still stopping, so its
321+ # teardown proves shutdown advanced past the tier that never drained.
322+ wait_for_condition (lambda : "Leaf" in _shutdown_order (recorder ), timeout = 20 )
370323 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.
374324 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 ()
380325
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- ]
326+ # Cleanup Middle so it doesn't sit in __del__ for its full 1000s grace period
327+ ray .kill (middle_replica )
388328
389329
390330if __name__ == "__main__" :
0 commit comments