chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,856 @@
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from fastapi import FastAPI
|
||||
|
||||
import ray
|
||||
from ray import serve
|
||||
from ray._common.test_utils import SignalActor, wait_for_condition
|
||||
from ray.serve.api import get_deployment_handle
|
||||
from ray.serve.context import _get_global_client
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
from ray.serve.schema import ApplicationStatus
|
||||
|
||||
|
||||
class TestDeploymentTopology:
|
||||
def test_simple_chain_topology(self, serve_instance):
|
||||
"""Test topology with a simple chain: ingress -> downstream."""
|
||||
|
||||
# Define a downstream deployment
|
||||
@serve.deployment
|
||||
class Downstream:
|
||||
def __call__(self):
|
||||
return "downstream response"
|
||||
|
||||
# Define an ingress deployment that calls downstream
|
||||
@serve.deployment
|
||||
class Ingress:
|
||||
def __init__(self, downstream: DeploymentHandle):
|
||||
self.downstream = downstream
|
||||
|
||||
async def __call__(self):
|
||||
result = await self.downstream.remote()
|
||||
return f"ingress -> {result}"
|
||||
|
||||
# Deploy the application
|
||||
downstream = Downstream.bind()
|
||||
ingress = Ingress.bind(downstream)
|
||||
serve.run(ingress, name="test_app", route_prefix="/test")
|
||||
|
||||
# Make a request to ensure deployments are fully initialized
|
||||
response = requests.get("http://localhost:8000/test")
|
||||
assert response.status_code == 200
|
||||
assert "downstream response" in response.text
|
||||
|
||||
# Define expected topology
|
||||
expected_topology = {
|
||||
"app_name": "test_app",
|
||||
"ingress_deployment": "Ingress",
|
||||
"nodes": {
|
||||
"Ingress": {
|
||||
"name": "Ingress",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": True,
|
||||
"outbound_deployments": [
|
||||
{"name": "Downstream", "app_name": "test_app"}
|
||||
],
|
||||
},
|
||||
"Downstream": {
|
||||
"name": "Downstream",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": False,
|
||||
"outbound_deployments": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Final verification - get the actual topology
|
||||
client = _get_global_client()
|
||||
status = client.get_serve_details()
|
||||
actual_topology = status["applications"]["test_app"]["deployment_topology"]
|
||||
|
||||
# Direct comparison with expected topology
|
||||
assert actual_topology == expected_topology
|
||||
|
||||
def test_multi_level_chain_topology(self, serve_instance):
|
||||
"""Test topology with deep dependencies: Ingress -> A -> B -> C."""
|
||||
|
||||
@serve.deployment
|
||||
class ServiceC:
|
||||
def __call__(self):
|
||||
return "C"
|
||||
|
||||
@serve.deployment
|
||||
class ServiceB:
|
||||
def __init__(self, service_c: DeploymentHandle):
|
||||
self.service_c = service_c
|
||||
|
||||
async def __call__(self):
|
||||
result = await self.service_c.remote()
|
||||
return f"B->{result}"
|
||||
|
||||
@serve.deployment
|
||||
class ServiceA:
|
||||
def __init__(self, service_b: DeploymentHandle):
|
||||
self.service_b = service_b
|
||||
|
||||
async def __call__(self):
|
||||
result = await self.service_b.remote()
|
||||
return f"A->{result}"
|
||||
|
||||
@serve.deployment
|
||||
class Ingress:
|
||||
def __init__(self, service_a: DeploymentHandle):
|
||||
self.service_a = service_a
|
||||
|
||||
async def __call__(self):
|
||||
result = await self.service_a.remote()
|
||||
return f"Ingress->{result}"
|
||||
|
||||
# Deploy the application
|
||||
service_c = ServiceC.bind()
|
||||
service_b = ServiceB.bind(service_c)
|
||||
service_a = ServiceA.bind(service_b)
|
||||
ingress = Ingress.bind(service_a)
|
||||
serve.run(ingress, name="test_app", route_prefix="/test")
|
||||
|
||||
# Make a request
|
||||
response = requests.get("http://localhost:8000/test")
|
||||
assert response.status_code == 200
|
||||
assert "C" in response.text
|
||||
|
||||
# Expected topology
|
||||
expected_topology = {
|
||||
"app_name": "test_app",
|
||||
"ingress_deployment": "Ingress",
|
||||
"nodes": {
|
||||
"Ingress": {
|
||||
"name": "Ingress",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": True,
|
||||
"outbound_deployments": [
|
||||
{"name": "ServiceA", "app_name": "test_app"}
|
||||
],
|
||||
},
|
||||
"ServiceA": {
|
||||
"name": "ServiceA",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": False,
|
||||
"outbound_deployments": [
|
||||
{"name": "ServiceB", "app_name": "test_app"}
|
||||
],
|
||||
},
|
||||
"ServiceB": {
|
||||
"name": "ServiceB",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": False,
|
||||
"outbound_deployments": [
|
||||
{"name": "ServiceC", "app_name": "test_app"}
|
||||
],
|
||||
},
|
||||
"ServiceC": {
|
||||
"name": "ServiceC",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": False,
|
||||
"outbound_deployments": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Final verification
|
||||
client = _get_global_client()
|
||||
status = client.get_serve_details()
|
||||
actual_topology = status["applications"]["test_app"]["deployment_topology"]
|
||||
assert actual_topology == expected_topology
|
||||
|
||||
def test_fan_out_topology(self, serve_instance):
|
||||
"""Test topology with fan-out: Ingress -> [Service1, Service2, Service3]."""
|
||||
|
||||
@serve.deployment
|
||||
class Service1:
|
||||
def __call__(self):
|
||||
return "service1"
|
||||
|
||||
@serve.deployment
|
||||
class Service2:
|
||||
def __call__(self):
|
||||
return "service2"
|
||||
|
||||
@serve.deployment
|
||||
class Service3:
|
||||
def __call__(self):
|
||||
return "service3"
|
||||
|
||||
@serve.deployment
|
||||
class Ingress:
|
||||
def __init__(
|
||||
self,
|
||||
service1: DeploymentHandle,
|
||||
service2: DeploymentHandle,
|
||||
service3: DeploymentHandle,
|
||||
):
|
||||
self.service1 = service1
|
||||
self.service2 = service2
|
||||
self.service3 = service3
|
||||
|
||||
async def __call__(self):
|
||||
results = await asyncio.gather(
|
||||
self.service1.remote(),
|
||||
self.service2.remote(),
|
||||
self.service3.remote(),
|
||||
)
|
||||
return f"Results: {','.join(results)}"
|
||||
|
||||
# Deploy the application
|
||||
service1 = Service1.bind()
|
||||
service2 = Service2.bind()
|
||||
service3 = Service3.bind()
|
||||
ingress = Ingress.bind(service1, service2, service3)
|
||||
serve.run(ingress, name="test_app", route_prefix="/test")
|
||||
|
||||
# Make a request
|
||||
response = requests.get("http://localhost:8000/test")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Expected topology - outbound_deployments should contain all three services
|
||||
expected_topology = {
|
||||
"app_name": "test_app",
|
||||
"ingress_deployment": "Ingress",
|
||||
"nodes": {
|
||||
"Ingress": {
|
||||
"name": "Ingress",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": True,
|
||||
"outbound_deployments": [
|
||||
{"name": "Service1", "app_name": "test_app"},
|
||||
{"name": "Service2", "app_name": "test_app"},
|
||||
{"name": "Service3", "app_name": "test_app"},
|
||||
],
|
||||
},
|
||||
"Service1": {
|
||||
"name": "Service1",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": False,
|
||||
"outbound_deployments": [],
|
||||
},
|
||||
"Service2": {
|
||||
"name": "Service2",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": False,
|
||||
"outbound_deployments": [],
|
||||
},
|
||||
"Service3": {
|
||||
"name": "Service3",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": False,
|
||||
"outbound_deployments": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Final verification
|
||||
client = _get_global_client()
|
||||
status = client.get_serve_details()
|
||||
actual_topology = status["applications"]["test_app"]["deployment_topology"]
|
||||
# Sort for comparison
|
||||
actual_topology["nodes"]["Ingress"]["outbound_deployments"] = sorted(
|
||||
actual_topology["nodes"]["Ingress"]["outbound_deployments"],
|
||||
key=lambda x: x["name"],
|
||||
)
|
||||
expected_topology["nodes"]["Ingress"]["outbound_deployments"] = sorted(
|
||||
expected_topology["nodes"]["Ingress"]["outbound_deployments"],
|
||||
key=lambda x: x["name"],
|
||||
)
|
||||
assert actual_topology == expected_topology
|
||||
|
||||
def test_diamond_topology(self, serve_instance):
|
||||
"""Test diamond pattern: Ingress -> [ServiceA, ServiceB] -> Database."""
|
||||
|
||||
@serve.deployment
|
||||
class Database:
|
||||
def __call__(self):
|
||||
return "db_data"
|
||||
|
||||
@serve.deployment
|
||||
class ServiceA:
|
||||
def __init__(self, database: DeploymentHandle):
|
||||
self.database = database
|
||||
|
||||
async def __call__(self):
|
||||
data = await self.database.remote()
|
||||
return f"A:{data}"
|
||||
|
||||
@serve.deployment
|
||||
class ServiceB:
|
||||
def __init__(self, database: DeploymentHandle):
|
||||
self.database = database
|
||||
|
||||
async def __call__(self):
|
||||
data = await self.database.remote()
|
||||
return f"B:{data}"
|
||||
|
||||
@serve.deployment
|
||||
class Ingress:
|
||||
def __init__(
|
||||
self, service_a: DeploymentHandle, service_b: DeploymentHandle
|
||||
):
|
||||
self.service_a = service_a
|
||||
self.service_b = service_b
|
||||
|
||||
async def __call__(self):
|
||||
results = await asyncio.gather(
|
||||
self.service_a.remote(),
|
||||
self.service_b.remote(),
|
||||
)
|
||||
return f"Results: {','.join(results)}"
|
||||
|
||||
# Deploy the application
|
||||
database = Database.bind()
|
||||
service_a = ServiceA.bind(database)
|
||||
service_b = ServiceB.bind(database)
|
||||
ingress = Ingress.bind(service_a, service_b)
|
||||
serve.run(ingress, name="test_app", route_prefix="/test")
|
||||
|
||||
# Make a request
|
||||
response = requests.get("http://localhost:8000/test")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Expected topology
|
||||
expected_topology = {
|
||||
"app_name": "test_app",
|
||||
"ingress_deployment": "Ingress",
|
||||
"nodes": {
|
||||
"Ingress": {
|
||||
"name": "Ingress",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": True,
|
||||
"outbound_deployments": [
|
||||
{"name": "ServiceA", "app_name": "test_app"},
|
||||
{"name": "ServiceB", "app_name": "test_app"},
|
||||
],
|
||||
},
|
||||
"ServiceA": {
|
||||
"name": "ServiceA",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": False,
|
||||
"outbound_deployments": [
|
||||
{"name": "Database", "app_name": "test_app"}
|
||||
],
|
||||
},
|
||||
"ServiceB": {
|
||||
"name": "ServiceB",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": False,
|
||||
"outbound_deployments": [
|
||||
{"name": "Database", "app_name": "test_app"}
|
||||
],
|
||||
},
|
||||
"Database": {
|
||||
"name": "Database",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": False,
|
||||
"outbound_deployments": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Final verification
|
||||
client = _get_global_client()
|
||||
status = client.get_serve_details()
|
||||
actual_topology = status["applications"]["test_app"]["deployment_topology"]
|
||||
# Sort for comparison
|
||||
actual_topology["nodes"]["Ingress"]["outbound_deployments"] = sorted(
|
||||
actual_topology["nodes"]["Ingress"]["outbound_deployments"],
|
||||
key=lambda x: x["name"],
|
||||
)
|
||||
expected_topology["nodes"]["Ingress"]["outbound_deployments"] = sorted(
|
||||
expected_topology["nodes"]["Ingress"]["outbound_deployments"],
|
||||
key=lambda x: x["name"],
|
||||
)
|
||||
assert actual_topology == expected_topology
|
||||
|
||||
def test_cross_app_topology(self, serve_instance):
|
||||
"""Test cross-application dependencies."""
|
||||
|
||||
# Deploy App2 first (the dependency)
|
||||
@serve.deployment
|
||||
class App2Service:
|
||||
def __call__(self):
|
||||
return "app2_response"
|
||||
|
||||
app2_service = App2Service.bind()
|
||||
serve.run(app2_service, name="app2", route_prefix="/app2")
|
||||
|
||||
# Deploy App1 that depends on App2
|
||||
@serve.deployment
|
||||
class App1Service:
|
||||
def __init__(self):
|
||||
self.app2_handle = get_deployment_handle("App2Service", "app2")
|
||||
|
||||
async def __call__(self):
|
||||
result = await self.app2_handle.remote()
|
||||
return f"app1->{result}"
|
||||
|
||||
app1_service = App1Service.bind()
|
||||
serve.run(app1_service, name="app1", route_prefix="/app1")
|
||||
|
||||
# Make requests to both apps to ensure they're initialized
|
||||
response1 = requests.get("http://localhost:8000/app1")
|
||||
assert response1.status_code == 200
|
||||
response2 = requests.get("http://localhost:8000/app2")
|
||||
assert response2.status_code == 200
|
||||
|
||||
# Expected topology for app1
|
||||
expected_topology_app1 = {
|
||||
"app_name": "app1",
|
||||
"ingress_deployment": "App1Service",
|
||||
"nodes": {
|
||||
"App1Service": {
|
||||
"name": "App1Service",
|
||||
"app_name": "app1",
|
||||
"is_ingress": True,
|
||||
"outbound_deployments": [
|
||||
{"name": "App2Service", "app_name": "app2"}
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Expected topology for app2
|
||||
expected_topology_app2 = {
|
||||
"app_name": "app2",
|
||||
"ingress_deployment": "App2Service",
|
||||
"nodes": {
|
||||
"App2Service": {
|
||||
"name": "App2Service",
|
||||
"app_name": "app2",
|
||||
"is_ingress": True,
|
||||
"outbound_deployments": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Wait for topologies to be built
|
||||
def check_topology():
|
||||
client = _get_global_client()
|
||||
status = client.get_serve_details()
|
||||
topology_app1 = (
|
||||
status.get("applications", {})
|
||||
.get("app1", {})
|
||||
.get("deployment_topology")
|
||||
)
|
||||
topology_app2 = (
|
||||
status.get("applications", {})
|
||||
.get("app2", {})
|
||||
.get("deployment_topology")
|
||||
)
|
||||
return (
|
||||
topology_app1 == expected_topology_app1
|
||||
and topology_app2 == expected_topology_app2
|
||||
)
|
||||
|
||||
# NOTE(abrar): using wait_for_condition because outbound deployments are added asynchronously
|
||||
# when get_deployment_handle is used.
|
||||
wait_for_condition(check_topology)
|
||||
|
||||
# Final verification
|
||||
client = _get_global_client()
|
||||
status = client.get_serve_details()
|
||||
actual_topology_app1 = status["applications"]["app1"]["deployment_topology"]
|
||||
actual_topology_app2 = status["applications"]["app2"]["deployment_topology"]
|
||||
assert actual_topology_app1 == expected_topology_app1
|
||||
assert actual_topology_app2 == expected_topology_app2
|
||||
|
||||
def test_single_deployment_no_dependencies(self, serve_instance):
|
||||
"""Test single deployment with no outbound dependencies."""
|
||||
|
||||
@serve.deployment
|
||||
class Standalone:
|
||||
def __call__(self):
|
||||
return "standalone"
|
||||
|
||||
standalone = Standalone.bind()
|
||||
serve.run(standalone, name="test_app", route_prefix="/test")
|
||||
|
||||
# Make a request
|
||||
response = requests.get("http://localhost:8000/test")
|
||||
assert response.status_code == 200
|
||||
assert response.text == "standalone"
|
||||
|
||||
# Expected topology
|
||||
expected_topology = {
|
||||
"app_name": "test_app",
|
||||
"ingress_deployment": "Standalone",
|
||||
"nodes": {
|
||||
"Standalone": {
|
||||
"name": "Standalone",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": True,
|
||||
"outbound_deployments": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Final verification
|
||||
client = _get_global_client()
|
||||
status = client.get_serve_details()
|
||||
actual_topology = status["applications"]["test_app"]["deployment_topology"]
|
||||
assert actual_topology == expected_topology
|
||||
|
||||
def test_topology_update_on_redeploy(self, serve_instance):
|
||||
"""Test that topology updates when application is redeployed with different structure."""
|
||||
|
||||
# Initial deployment: Ingress -> ServiceA
|
||||
@serve.deployment
|
||||
class ServiceA:
|
||||
def __call__(self):
|
||||
return "A"
|
||||
|
||||
@serve.deployment
|
||||
class Ingress:
|
||||
def __init__(self, service_a: DeploymentHandle):
|
||||
self.service_a = service_a
|
||||
|
||||
async def __call__(self):
|
||||
result = await self.service_a.remote()
|
||||
return f"Ingress->{result}"
|
||||
|
||||
service_a = ServiceA.bind()
|
||||
ingress = Ingress.bind(service_a)
|
||||
serve.run(ingress, name="test_app", route_prefix="/test")
|
||||
|
||||
# Make a request
|
||||
response = requests.get("http://localhost:8000/test")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Expected initial topology
|
||||
expected_topology_v1 = {
|
||||
"app_name": "test_app",
|
||||
"ingress_deployment": "Ingress",
|
||||
"nodes": {
|
||||
"Ingress": {
|
||||
"name": "Ingress",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": True,
|
||||
"outbound_deployments": [
|
||||
{"name": "ServiceA", "app_name": "test_app"}
|
||||
],
|
||||
},
|
||||
"ServiceA": {
|
||||
"name": "ServiceA",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": False,
|
||||
"outbound_deployments": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
client = _get_global_client()
|
||||
status = client.get_serve_details()
|
||||
actual_topology = status["applications"]["test_app"]["deployment_topology"]
|
||||
assert expected_topology_v1 == actual_topology
|
||||
|
||||
# Redeploy with different structure: Ingress -> ServiceB
|
||||
@serve.deployment
|
||||
class ServiceB:
|
||||
def __call__(self):
|
||||
return "B"
|
||||
|
||||
@serve.deployment
|
||||
class IngressV2:
|
||||
def __init__(self, service_b: DeploymentHandle):
|
||||
self.service_b = service_b
|
||||
|
||||
async def __call__(self):
|
||||
result = await self.service_b.remote()
|
||||
return f"IngressV2->{result}"
|
||||
|
||||
service_b = ServiceB.bind()
|
||||
ingress_v2 = IngressV2.bind(service_b)
|
||||
serve.run(ingress_v2, name="test_app", route_prefix="/test")
|
||||
|
||||
# Make a request to new deployment
|
||||
response = requests.get("http://localhost:8000/test")
|
||||
assert response.status_code == 200
|
||||
assert "B" in response.text
|
||||
|
||||
# Expected updated topology
|
||||
expected_topology_v2 = {
|
||||
"app_name": "test_app",
|
||||
"ingress_deployment": "IngressV2",
|
||||
"nodes": {
|
||||
"IngressV2": {
|
||||
"name": "IngressV2",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": True,
|
||||
"outbound_deployments": [
|
||||
{"name": "ServiceB", "app_name": "test_app"}
|
||||
],
|
||||
},
|
||||
"ServiceB": {
|
||||
"name": "ServiceB",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": False,
|
||||
"outbound_deployments": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Final verification
|
||||
client = _get_global_client()
|
||||
status = client.get_serve_details()
|
||||
actual_topology = status["applications"]["test_app"]["deployment_topology"]
|
||||
assert actual_topology == expected_topology_v2
|
||||
|
||||
def test_fastapi_factory_with_get_deployment_handle(self, serve_instance):
|
||||
"""Test topology with FastAPI factory pattern using get_deployment_handle.
|
||||
|
||||
This test demonstrates the pattern where get_deployment_handle is used
|
||||
inside a FastAPI request handler (lazy lookup), which is common in
|
||||
factory patterns where dependencies aren't injected at construction time.
|
||||
"""
|
||||
|
||||
# Backend service that will be called via get_deployment_handle
|
||||
@serve.deployment(name="BackendService")
|
||||
class BackendService:
|
||||
def __call__(self):
|
||||
return "backend_data"
|
||||
|
||||
# FastAPI ingress using factory pattern
|
||||
def create_fastapi_app():
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
# Use get_deployment_handle inside the request handler (lazy lookup)
|
||||
# This is the factory pattern - handle is obtained at request time
|
||||
backend_handle = get_deployment_handle("BackendService", "test_app")
|
||||
result = await backend_handle.remote()
|
||||
return {"data": result}
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
return app
|
||||
|
||||
@serve.deployment(name="FastAPIIngress")
|
||||
@serve.ingress(create_fastapi_app())
|
||||
class FastAPIIngress:
|
||||
def __init__(self, backend_service: DeploymentHandle):
|
||||
self.backend_service = backend_service
|
||||
|
||||
# Deploy both deployments
|
||||
ingress_deployment = FastAPIIngress.bind(BackendService.bind())
|
||||
|
||||
serve.run(ingress_deployment, name="test_app", route_prefix="/test")
|
||||
|
||||
# Make requests to ensure deployments are initialized
|
||||
response = requests.get("http://localhost:8000/test/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Make a request that triggers get_deployment_handle
|
||||
response = requests.get("http://localhost:8000/test/")
|
||||
assert response.status_code == 200
|
||||
assert "backend_data" in response.text
|
||||
|
||||
# Expected topology
|
||||
expected_topology = {
|
||||
"app_name": "test_app",
|
||||
"ingress_deployment": "FastAPIIngress",
|
||||
"nodes": {
|
||||
"FastAPIIngress": {
|
||||
"name": "FastAPIIngress",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": True,
|
||||
"outbound_deployments": [
|
||||
{"name": "BackendService", "app_name": "test_app"}
|
||||
],
|
||||
},
|
||||
"BackendService": {
|
||||
"name": "BackendService",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": False,
|
||||
"outbound_deployments": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Wait for topology to be built
|
||||
# NOTE(abrar): using wait_for_condition because outbound deployments are added asynchronously
|
||||
# when get_deployment_handle is used.
|
||||
def check_topology():
|
||||
client = _get_global_client()
|
||||
status = client.get_serve_details()
|
||||
topology = (
|
||||
status.get("applications", {})
|
||||
.get("test_app", {})
|
||||
.get("deployment_topology")
|
||||
)
|
||||
return topology == expected_topology
|
||||
|
||||
wait_for_condition(check_topology)
|
||||
|
||||
# Final verification
|
||||
client = _get_global_client()
|
||||
status = client.get_serve_details()
|
||||
actual_topology = status["applications"]["test_app"]["deployment_topology"]
|
||||
assert actual_topology == expected_topology
|
||||
|
||||
def test_topology_with_updating_deployment(self, serve_instance):
|
||||
"""Test topology when a deployment is updating (not fully rolled out)."""
|
||||
|
||||
# Create signal actor for synchronization
|
||||
signal = SignalActor.remote()
|
||||
|
||||
# Initial deployment
|
||||
@serve.deployment(name="ServiceA")
|
||||
class ServiceA:
|
||||
def __call__(self):
|
||||
return "v1"
|
||||
|
||||
@serve.deployment(name="Ingress")
|
||||
class Ingress:
|
||||
def __init__(self, service_a: DeploymentHandle):
|
||||
# Block until signal is sent to keep deployment in UPDATING state
|
||||
ray.get(signal.wait.remote())
|
||||
self.service_a = service_a
|
||||
|
||||
async def __call__(self):
|
||||
result = await self.service_a.remote()
|
||||
return f"Ingress->{result}"
|
||||
|
||||
# Deploy initial version
|
||||
service_a_v1 = ServiceA.bind()
|
||||
ingress = Ingress.bind(service_a_v1)
|
||||
serve._run(ingress, name="test_app", route_prefix="/test", _blocking=False)
|
||||
|
||||
wait_for_condition(lambda: ray.get(signal.cur_num_waiters.remote()) == 1)
|
||||
|
||||
# Verify initial topology
|
||||
initial_topology = {
|
||||
"app_name": "test_app",
|
||||
"ingress_deployment": "Ingress",
|
||||
"nodes": {
|
||||
"Ingress": {
|
||||
"name": "Ingress",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": True,
|
||||
"outbound_deployments": [],
|
||||
},
|
||||
"ServiceA": {
|
||||
"name": "ServiceA",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": False,
|
||||
"outbound_deployments": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def check_initial_topology():
|
||||
client = _get_global_client()
|
||||
status = client.get_serve_details()
|
||||
if "test_app" not in status["applications"]:
|
||||
return False
|
||||
app_status = status["applications"]["test_app"]["status"]
|
||||
return app_status == ApplicationStatus.DEPLOYING.value
|
||||
|
||||
wait_for_condition(check_initial_topology)
|
||||
|
||||
client = _get_global_client()
|
||||
status = client.get_serve_details()
|
||||
actual_topology = status["applications"]["test_app"]["deployment_topology"]
|
||||
assert actual_topology == initial_topology
|
||||
|
||||
# Clean up: send signal to unblock replicas
|
||||
ray.get(signal.send.remote())
|
||||
|
||||
def test_topology_with_failed_deployment(self, serve_instance):
|
||||
"""Test topology when a deployment fails to start."""
|
||||
|
||||
# A deployment that will fail to start
|
||||
@serve.deployment
|
||||
class ServiceB:
|
||||
def __call__(self):
|
||||
return "b"
|
||||
|
||||
@serve.deployment(name="FailingService")
|
||||
class FailingService:
|
||||
def __init__(self, service_b: DeploymentHandle):
|
||||
self.service_b = service_b
|
||||
raise RuntimeError("Intentional failure for testing")
|
||||
|
||||
def __call__(self):
|
||||
return "should never reach here"
|
||||
|
||||
@serve.deployment(name="Ingress")
|
||||
class Ingress:
|
||||
def __init__(self, failing_service: DeploymentHandle):
|
||||
self.failing_service = failing_service
|
||||
|
||||
async def __call__(self):
|
||||
result = await self.failing_service.remote()
|
||||
return f"Ingress->{result}"
|
||||
|
||||
# Deploy with failing service
|
||||
service_b = ServiceB.bind()
|
||||
failing_service = FailingService.bind(service_b)
|
||||
ingress = Ingress.bind(failing_service)
|
||||
|
||||
# Deploy (this won't fail immediately, but replicas will fail to start)
|
||||
serve._run(ingress, name="test_app", route_prefix="/test", _blocking=False)
|
||||
|
||||
# Wait for the deployment to be processed and replicas to fail
|
||||
def deployment_has_failures():
|
||||
client = _get_global_client()
|
||||
status = client.get_serve_details()
|
||||
if "test_app" not in status["applications"]:
|
||||
return False
|
||||
app_status = status["applications"]["test_app"]["status"]
|
||||
return app_status == ApplicationStatus.DEPLOY_FAILED.value
|
||||
|
||||
wait_for_condition(deployment_has_failures, timeout=10)
|
||||
|
||||
# Get the serve details
|
||||
client = _get_global_client()
|
||||
status = client.get_serve_details()
|
||||
|
||||
# Verify the application exists (even with failed deployment)
|
||||
assert "test_app" in status["applications"]
|
||||
|
||||
# Verify deployment_topology still exists
|
||||
# The topology should be built based on the deployment structure,
|
||||
# regardless of whether the replicas are healthy
|
||||
topology = status["applications"]["test_app"]["deployment_topology"]
|
||||
expected_topology = {
|
||||
"app_name": "test_app",
|
||||
"ingress_deployment": "Ingress",
|
||||
"nodes": {
|
||||
"Ingress": {
|
||||
"name": "Ingress",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": True,
|
||||
"outbound_deployments": [
|
||||
{"name": "FailingService", "app_name": "test_app"}
|
||||
],
|
||||
},
|
||||
"FailingService": {
|
||||
"name": "FailingService",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": False,
|
||||
"outbound_deployments": [],
|
||||
},
|
||||
"ServiceB": {
|
||||
"name": "ServiceB",
|
||||
"app_name": "test_app",
|
||||
"is_ingress": False,
|
||||
"outbound_deployments": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
assert topology == expected_topology
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(["-v", "-s", __file__]))
|
||||
Reference in New Issue
Block a user