9646 lines
358 KiB
Python
9646 lines
358 KiB
Python
import sys
|
|
from copy import deepcopy
|
|
from typing import Any, List, Optional, Tuple
|
|
from unittest.mock import MagicMock, Mock, patch
|
|
|
|
import pytest
|
|
|
|
from ray._common.ray_constants import DEFAULT_MAX_CONCURRENCY_ASYNC
|
|
from ray._raylet import NodeID
|
|
from ray.serve._private.autoscaling_state import AutoscalingStateManager
|
|
from ray.serve._private.common import (
|
|
RUNNING_REQUESTS_KEY,
|
|
DeploymentHandleSource,
|
|
DeploymentID,
|
|
DeploymentStatus,
|
|
DeploymentStatusTrigger,
|
|
GangReservationResult,
|
|
HandleMetricReport,
|
|
ReplicaID,
|
|
ReplicaMetricReport,
|
|
ReplicaState,
|
|
TargetCapacityDirection,
|
|
TimeStampedValue,
|
|
)
|
|
from ray.serve._private.config import DeploymentConfig, ReplicaConfig
|
|
from ray.serve._private.constants import (
|
|
DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_S,
|
|
DEFAULT_GRACEFUL_SHUTDOWN_WAIT_LOOP_S,
|
|
DEFAULT_HEALTH_CHECK_PERIOD_S,
|
|
DEFAULT_HEALTH_CHECK_TIMEOUT_S,
|
|
DEFAULT_MAX_ONGOING_REQUESTS,
|
|
RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE,
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_ACTOR_NAME_ENV_VAR,
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_APP_NAME_ENV_VAR,
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_CODE_VERSION_ENV_VAR,
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_NAME_ENV_VAR,
|
|
RAY_SERVE_STATUS_GAUGE_REPORT_INTERVAL_S,
|
|
)
|
|
from ray.serve._private.deployment_info import DeploymentInfo
|
|
from ray.serve._private.deployment_state import (
|
|
ALL_REPLICA_STATES,
|
|
CHECKPOINT_KEY,
|
|
SLOW_STARTUP_WARNING_S,
|
|
ActorReplicaWrapper,
|
|
DeploymentActorContainer,
|
|
DeploymentActorState,
|
|
DeploymentActorWrapper,
|
|
DeploymentReplica,
|
|
DeploymentState,
|
|
DeploymentStateManager,
|
|
DeploymentVersion,
|
|
ReplicaStartupStatus,
|
|
ReplicaStateContainer,
|
|
)
|
|
from ray.serve._private.exceptions import DeploymentIsBeingDeletedError
|
|
from ray.serve._private.long_poll import LongPollNamespace
|
|
from ray.serve._private.test_utils import (
|
|
MockDeploymentActorWrapper,
|
|
MockPlacementGroup,
|
|
dead_replicas_context,
|
|
replica_rank_context,
|
|
uninitialized_replicas_context,
|
|
)
|
|
from ray.serve._private.utils import (
|
|
get_capacity_adjusted_num_replicas,
|
|
get_random_string,
|
|
)
|
|
from ray.serve.config import DeploymentActorConfig, GangSchedulingConfig
|
|
from ray.serve.schema import ReplicaRank
|
|
from ray.util.placement_group import validate_placement_group
|
|
|
|
TEST_DEPLOYMENT_ID = DeploymentID(name="test_deployment", app_name="test_app")
|
|
TEST_DEPLOYMENT_ID_2 = DeploymentID(name="test_deployment_2", app_name="test_app")
|
|
|
|
|
|
def deployment_info(
|
|
version: Optional[str] = None,
|
|
num_replicas: Optional[int] = 1,
|
|
user_config: Optional[Any] = None,
|
|
replica_config: Optional[ReplicaConfig] = None,
|
|
**config_opts,
|
|
) -> Tuple[DeploymentInfo, DeploymentVersion]:
|
|
info = DeploymentInfo(
|
|
version=version,
|
|
start_time_ms=0,
|
|
actor_name="abc",
|
|
deployment_config=DeploymentConfig(
|
|
num_replicas=num_replicas, user_config=user_config, **config_opts
|
|
),
|
|
replica_config=replica_config or ReplicaConfig.create(lambda x: x),
|
|
deployer_job_id="",
|
|
)
|
|
|
|
if version is not None:
|
|
code_version = version
|
|
else:
|
|
code_version = get_random_string()
|
|
|
|
version = DeploymentVersion(
|
|
code_version, info.deployment_config, info.replica_config.ray_actor_options
|
|
)
|
|
|
|
return info, version
|
|
|
|
|
|
def deployment_version(code_version) -> DeploymentVersion:
|
|
return DeploymentVersion(code_version, DeploymentConfig(), {})
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_max_per_replica_retry_count():
|
|
with patch("ray.serve._private.deployment_state.MAX_PER_REPLICA_RETRY_COUNT", 2):
|
|
yield 2
|
|
|
|
|
|
class FakeDeploymentReplica:
|
|
"""Fakes the DeploymentReplica class."""
|
|
|
|
def __init__(self, version: DeploymentVersion):
|
|
self._version = version
|
|
self._replica_id = ReplicaID(
|
|
get_random_string(), deployment_id=DeploymentID(name="fake")
|
|
)
|
|
|
|
@property
|
|
def replica_id(self):
|
|
return self._replica_id
|
|
|
|
@property
|
|
def version(self):
|
|
return self._version
|
|
|
|
def update_state(self, state):
|
|
pass
|
|
|
|
|
|
def replica(version: Optional[DeploymentVersion] = None) -> FakeDeploymentReplica:
|
|
version = version or DeploymentVersion(get_random_string(), DeploymentConfig(), {})
|
|
return FakeDeploymentReplica(version)
|
|
|
|
|
|
class TestReplicaStateContainer:
|
|
def test_count(self):
|
|
c = ReplicaStateContainer()
|
|
r1, r2, r3 = (
|
|
replica(deployment_version("1")),
|
|
replica(deployment_version("2")),
|
|
replica(deployment_version("2")),
|
|
)
|
|
c.add(ReplicaState.STARTING, r1)
|
|
c.add(ReplicaState.STARTING, r2)
|
|
c.add(ReplicaState.STOPPING, r3)
|
|
assert c.count() == 3
|
|
|
|
# Test filtering by state.
|
|
assert c.count() == c.count(
|
|
states=[ReplicaState.STARTING, ReplicaState.STOPPING]
|
|
)
|
|
assert c.count(states=[ReplicaState.STARTING]) == 2
|
|
assert c.count(states=[ReplicaState.STOPPING]) == 1
|
|
|
|
# Test filtering by version.
|
|
assert c.count(version=deployment_version("1")) == 1
|
|
assert c.count(version=deployment_version("2")) == 2
|
|
assert c.count(version=deployment_version("3")) == 0
|
|
assert c.count(exclude_version=deployment_version("1")) == 2
|
|
assert c.count(exclude_version=deployment_version("2")) == 1
|
|
assert c.count(exclude_version=deployment_version("3")) == 3
|
|
|
|
# Test filtering by state and version.
|
|
assert (
|
|
c.count(version=deployment_version("1"), states=[ReplicaState.STARTING])
|
|
== 1
|
|
)
|
|
assert (
|
|
c.count(version=deployment_version("3"), states=[ReplicaState.STARTING])
|
|
== 0
|
|
)
|
|
assert (
|
|
c.count(
|
|
version=deployment_version("2"),
|
|
states=[ReplicaState.STARTING, ReplicaState.STOPPING],
|
|
)
|
|
== 2
|
|
)
|
|
assert (
|
|
c.count(
|
|
exclude_version=deployment_version("1"), states=[ReplicaState.STARTING]
|
|
)
|
|
== 1
|
|
)
|
|
assert (
|
|
c.count(
|
|
exclude_version=deployment_version("3"), states=[ReplicaState.STARTING]
|
|
)
|
|
== 2
|
|
)
|
|
assert (
|
|
c.count(
|
|
exclude_version=deployment_version("2"),
|
|
states=[ReplicaState.STARTING, ReplicaState.STOPPING],
|
|
)
|
|
== 1
|
|
)
|
|
|
|
def test_get(self):
|
|
c = ReplicaStateContainer()
|
|
r1, r2, r3 = replica(), replica(), replica()
|
|
|
|
c.add(ReplicaState.STARTING, r1)
|
|
c.add(ReplicaState.STARTING, r2)
|
|
c.add(ReplicaState.STOPPING, r3)
|
|
assert c.get() == [r1, r2, r3]
|
|
assert c.get() == c.get([ReplicaState.STARTING, ReplicaState.STOPPING])
|
|
assert c.get([ReplicaState.STARTING]) == [r1, r2]
|
|
assert c.get([ReplicaState.STOPPING]) == [r3]
|
|
|
|
def test_get_by_id(self):
|
|
c = ReplicaStateContainer()
|
|
r1, r2, r3 = replica(), replica(), replica()
|
|
|
|
c.add(ReplicaState.STARTING, r1)
|
|
c.add(ReplicaState.RUNNING, r2)
|
|
c.add(ReplicaState.STOPPING, r3)
|
|
|
|
# Found: each replica is retrievable by its ID regardless of state.
|
|
assert c.get_by_id(r1.replica_id) is r1
|
|
assert c.get_by_id(r2.replica_id) is r2
|
|
assert c.get_by_id(r3.replica_id) is r3
|
|
|
|
# Not found: a replica ID that was never added returns None.
|
|
unknown = replica()
|
|
assert c.get_by_id(unknown.replica_id) is None
|
|
|
|
# After pop: popped replicas are no longer in the index.
|
|
popped = c.pop(states=[ReplicaState.RUNNING])
|
|
assert popped == [r2]
|
|
assert c.get_by_id(r2.replica_id) is None
|
|
|
|
# Remaining replicas are still found.
|
|
assert c.get_by_id(r1.replica_id) is r1
|
|
assert c.get_by_id(r3.replica_id) is r3
|
|
|
|
# Pop everything and verify the index is fully cleared.
|
|
c.pop()
|
|
assert c.get_by_id(r1.replica_id) is None
|
|
assert c.get_by_id(r3.replica_id) is None
|
|
|
|
def test_pop_basic(self):
|
|
c = ReplicaStateContainer()
|
|
r1, r2, r3 = replica(), replica(), replica()
|
|
|
|
c.add(ReplicaState.STARTING, r1)
|
|
c.add(ReplicaState.STARTING, r2)
|
|
c.add(ReplicaState.STOPPING, r3)
|
|
assert c.pop() == [r1, r2, r3]
|
|
assert not c.pop()
|
|
|
|
def test_pop_exclude_version(self):
|
|
c = ReplicaStateContainer()
|
|
r1, r2, r3 = (
|
|
replica(deployment_version("1")),
|
|
replica(deployment_version("1")),
|
|
replica(deployment_version("2")),
|
|
)
|
|
|
|
c.add(ReplicaState.STARTING, r1)
|
|
c.add(ReplicaState.STARTING, r2)
|
|
c.add(ReplicaState.STARTING, r3)
|
|
assert c.pop(exclude_version=deployment_version("1")) == [r3]
|
|
assert not c.pop(exclude_version=deployment_version("1"))
|
|
assert c.pop(exclude_version=deployment_version("2")) == [r1, r2]
|
|
assert not c.pop(exclude_version=deployment_version("2"))
|
|
assert not c.pop()
|
|
|
|
def test_pop_max_replicas(self):
|
|
c = ReplicaStateContainer()
|
|
r1, r2, r3 = replica(), replica(), replica()
|
|
|
|
c.add(ReplicaState.STARTING, r1)
|
|
c.add(ReplicaState.STARTING, r2)
|
|
c.add(ReplicaState.STOPPING, r3)
|
|
assert not c.pop(max_replicas=0)
|
|
assert len(c.pop(max_replicas=1)) == 1
|
|
assert len(c.pop(max_replicas=2)) == 2
|
|
c.add(ReplicaState.STARTING, r1)
|
|
c.add(ReplicaState.STARTING, r2)
|
|
c.add(ReplicaState.STOPPING, r3)
|
|
assert len(c.pop(max_replicas=10)) == 3
|
|
|
|
def test_pop_states(self):
|
|
c = ReplicaStateContainer()
|
|
r1, r2, r3, r4 = replica(), replica(), replica(), replica()
|
|
|
|
# Check popping single state.
|
|
c.add(ReplicaState.STOPPING, r1)
|
|
c.add(ReplicaState.STARTING, r2)
|
|
c.add(ReplicaState.STOPPING, r3)
|
|
assert c.pop(states=[ReplicaState.STARTING]) == [r2]
|
|
assert not c.pop(states=[ReplicaState.STARTING])
|
|
assert c.pop(states=[ReplicaState.STOPPING]) == [r1, r3]
|
|
assert not c.pop(states=[ReplicaState.STOPPING])
|
|
|
|
# Check popping multiple states. Ordering of states should be
|
|
# preserved.
|
|
c.add(ReplicaState.STOPPING, r1)
|
|
c.add(ReplicaState.STARTING, r2)
|
|
c.add(ReplicaState.STOPPING, r3)
|
|
c.add(ReplicaState.STARTING, r4)
|
|
assert c.pop(states=[ReplicaState.STOPPING, ReplicaState.STARTING]) == [
|
|
r1,
|
|
r3,
|
|
r2,
|
|
r4,
|
|
]
|
|
assert not c.pop(states=[ReplicaState.STOPPING, ReplicaState.STARTING])
|
|
assert not c.pop(states=[ReplicaState.STOPPING])
|
|
assert not c.pop(states=[ReplicaState.STARTING])
|
|
assert not c.pop()
|
|
|
|
def test_pop_integration(self):
|
|
c = ReplicaStateContainer()
|
|
r1, r2, r3, r4 = (
|
|
replica(deployment_version("1")),
|
|
replica(deployment_version("2")),
|
|
replica(deployment_version("2")),
|
|
replica(deployment_version("3")),
|
|
)
|
|
|
|
c.add(ReplicaState.STOPPING, r1)
|
|
c.add(ReplicaState.STARTING, r2)
|
|
c.add(ReplicaState.RUNNING, r3)
|
|
c.add(ReplicaState.RUNNING, r4)
|
|
assert not c.pop(
|
|
exclude_version=deployment_version("1"), states=[ReplicaState.STOPPING]
|
|
)
|
|
assert c.pop(
|
|
exclude_version=deployment_version("1"),
|
|
states=[ReplicaState.RUNNING],
|
|
max_replicas=1,
|
|
) == [r3]
|
|
assert c.pop(
|
|
exclude_version=deployment_version("1"),
|
|
states=[ReplicaState.RUNNING],
|
|
max_replicas=1,
|
|
) == [r4]
|
|
c.add(ReplicaState.RUNNING, r3)
|
|
c.add(ReplicaState.RUNNING, r4)
|
|
assert c.pop(
|
|
exclude_version=deployment_version("1"), states=[ReplicaState.RUNNING]
|
|
) == [r3, r4]
|
|
assert c.pop(
|
|
exclude_version=deployment_version("1"), states=[ReplicaState.STARTING]
|
|
) == [r2]
|
|
c.add(ReplicaState.STARTING, r2)
|
|
c.add(ReplicaState.RUNNING, r3)
|
|
c.add(ReplicaState.RUNNING, r4)
|
|
assert c.pop(
|
|
exclude_version=deployment_version("1"),
|
|
states=[ReplicaState.RUNNING, ReplicaState.STARTING],
|
|
) == [r3, r4, r2]
|
|
assert c.pop(
|
|
exclude_version=deployment_version("nonsense"),
|
|
states=[ReplicaState.STOPPING],
|
|
) == [r1]
|
|
|
|
|
|
def _mock_deployment_actor_wrapper(deployment_id, code_version: str, name: str):
|
|
"""Create a MockDeploymentActorWrapper for container tests."""
|
|
config = DeploymentActorConfig(name=name, actor_class="builtins:object")
|
|
return MockDeploymentActorWrapper(deployment_id, config, code_version)
|
|
|
|
|
|
class TestDeploymentActorContainer:
|
|
def test_add_and_count(self):
|
|
dep_id = DeploymentID(name="test", app_name="app")
|
|
c = DeploymentActorContainer(dep_id)
|
|
w1 = _mock_deployment_actor_wrapper(dep_id, "v1", "actor_a")
|
|
w2 = _mock_deployment_actor_wrapper(dep_id, "v1", "actor_b")
|
|
w3 = _mock_deployment_actor_wrapper(dep_id, "v2", "actor_a")
|
|
|
|
c.add(DeploymentActorState.STARTING, w1)
|
|
c.add(DeploymentActorState.STARTING, w2)
|
|
c.add(DeploymentActorState.RECOVERING, w3)
|
|
assert c.count() == 3
|
|
assert c.count(states=[DeploymentActorState.STARTING]) == 2
|
|
assert c.count(states=[DeploymentActorState.RECOVERING]) == 1
|
|
assert c.count(code_version="v1") == 2
|
|
assert c.count(code_version="v2") == 1
|
|
assert c.count(code_version="v3") == 0
|
|
|
|
def test_is_empty(self):
|
|
dep_id = DeploymentID(name="test", app_name="app")
|
|
c = DeploymentActorContainer(dep_id)
|
|
assert c.is_empty()
|
|
w = _mock_deployment_actor_wrapper(dep_id, "v1", "actor_a")
|
|
c.add(DeploymentActorState.STARTING, w)
|
|
assert not c.is_empty()
|
|
c.pop() # remove all tracked actors
|
|
assert c.is_empty()
|
|
|
|
def test_add_moves_existing(self):
|
|
"""Adding same (code_version, name) moves from old state to new."""
|
|
dep_id = DeploymentID(name="test", app_name="app")
|
|
c = DeploymentActorContainer(dep_id)
|
|
w = _mock_deployment_actor_wrapper(dep_id, "v1", "actor_a")
|
|
|
|
c.add(DeploymentActorState.STARTING, w)
|
|
assert c.count() == 1
|
|
assert c.count(states=[DeploymentActorState.STARTING]) == 1
|
|
|
|
c.add(DeploymentActorState.RUNNING, w)
|
|
assert c.count() == 1
|
|
assert c.count(states=[DeploymentActorState.STARTING]) == 0
|
|
assert c.count(states=[DeploymentActorState.RUNNING]) == 1
|
|
|
|
def test_get(self):
|
|
dep_id = DeploymentID(name="test", app_name="app")
|
|
c = DeploymentActorContainer(dep_id)
|
|
w1 = _mock_deployment_actor_wrapper(dep_id, "v1", "actor_a")
|
|
w2 = _mock_deployment_actor_wrapper(dep_id, "v1", "actor_b")
|
|
w3 = _mock_deployment_actor_wrapper(dep_id, "v2", "actor_a")
|
|
|
|
c.add(DeploymentActorState.STARTING, w1)
|
|
c.add(DeploymentActorState.STARTING, w2)
|
|
c.add(DeploymentActorState.RECOVERING, w3)
|
|
|
|
assert set(c.get()) == {w1, w2, w3}
|
|
assert set(c.get(states=[DeploymentActorState.STARTING])) == {w1, w2}
|
|
assert c.get(states=[DeploymentActorState.RECOVERING]) == [w3]
|
|
assert set(c.get(code_version="v1")) == {w1, w2}
|
|
assert c.get(code_version="v2") == [w3]
|
|
assert c.get(code_version="v3") == []
|
|
|
|
def test_pop(self):
|
|
dep_id = DeploymentID(name="test", app_name="app")
|
|
c = DeploymentActorContainer(dep_id)
|
|
w1 = _mock_deployment_actor_wrapper(dep_id, "v1", "actor_a")
|
|
w2 = _mock_deployment_actor_wrapper(dep_id, "v1", "actor_b")
|
|
w3 = _mock_deployment_actor_wrapper(dep_id, "v2", "actor_a")
|
|
|
|
c.add(DeploymentActorState.STARTING, w1)
|
|
c.add(DeploymentActorState.RUNNING, w2)
|
|
c.add(DeploymentActorState.RUNNING, w3)
|
|
|
|
# Pop by code_version
|
|
removed = c.pop(code_version="v1")
|
|
assert len(removed) == 2
|
|
states_removed = {s for s, _ in removed}
|
|
assert states_removed == {
|
|
DeploymentActorState.STARTING,
|
|
DeploymentActorState.RUNNING,
|
|
}
|
|
assert c.count() == 1
|
|
assert c.get() == [w3]
|
|
|
|
# Pop remaining
|
|
removed = c.pop(code_version="v2")
|
|
assert len(removed) == 1
|
|
assert removed[0][1].wrapper is w3
|
|
assert c.count() == 0
|
|
|
|
def test_pop_by_states(self):
|
|
dep_id = DeploymentID(name="test", app_name="app")
|
|
c = DeploymentActorContainer(dep_id)
|
|
w1 = _mock_deployment_actor_wrapper(dep_id, "v1", "actor_a")
|
|
w2 = _mock_deployment_actor_wrapper(dep_id, "v1", "actor_b")
|
|
|
|
c.add(DeploymentActorState.STARTING, w1)
|
|
c.add(DeploymentActorState.RUNNING, w2)
|
|
|
|
removed = c.pop(code_version="v1", states=[DeploymentActorState.STARTING])
|
|
assert len(removed) == 1
|
|
assert removed[0][1].wrapper is w1
|
|
assert c.count() == 1
|
|
assert c.get() == [w2]
|
|
|
|
def test_pop_all_versions(self):
|
|
dep_id = DeploymentID(name="test", app_name="app")
|
|
c = DeploymentActorContainer(dep_id)
|
|
w1 = _mock_deployment_actor_wrapper(dep_id, "v1", "actor_a")
|
|
w2 = _mock_deployment_actor_wrapper(dep_id, "v2", "actor_b")
|
|
|
|
c.add(DeploymentActorState.RUNNING, w1)
|
|
c.add(DeploymentActorState.RUNNING, w2)
|
|
|
|
removed = c.pop()
|
|
assert len(removed) == 2
|
|
assert c.count() == 0
|
|
|
|
def test_get_wrapper(self):
|
|
dep_id = DeploymentID(name="test", app_name="app")
|
|
c = DeploymentActorContainer(dep_id)
|
|
w1 = _mock_deployment_actor_wrapper(dep_id, "v1", "actor_a")
|
|
w2 = _mock_deployment_actor_wrapper(dep_id, "v1", "actor_b")
|
|
w3 = _mock_deployment_actor_wrapper(dep_id, "v2", "actor_a")
|
|
|
|
c.add(DeploymentActorState.RUNNING, w1)
|
|
c.add(DeploymentActorState.RUNNING, w2)
|
|
c.add(DeploymentActorState.RUNNING, w3)
|
|
|
|
assert c.get_wrapper("v1", "actor_a") is w1
|
|
assert c.get_wrapper("v1", "actor_b") is w2
|
|
assert c.get_wrapper("v2", "actor_a") is w3
|
|
assert c.get_wrapper("v1", "actor_c") is None
|
|
assert c.get_wrapper("v3", "actor_a") is None
|
|
|
|
# After pop, get_wrapper returns None
|
|
c.pop(code_version="v1")
|
|
assert c.get_wrapper("v1", "actor_a") is None
|
|
assert c.get_wrapper("v1", "actor_b") is None
|
|
assert c.get_wrapper("v2", "actor_a") is w3
|
|
|
|
def test_get_code_versions(self):
|
|
dep_id = DeploymentID(name="test", app_name="app")
|
|
c = DeploymentActorContainer(dep_id)
|
|
assert c.get_code_versions() == set()
|
|
|
|
w1 = _mock_deployment_actor_wrapper(dep_id, "v1", "actor_a")
|
|
w2 = _mock_deployment_actor_wrapper(dep_id, "v2", "actor_b")
|
|
c.add(DeploymentActorState.RUNNING, w1)
|
|
c.add(DeploymentActorState.RUNNING, w2)
|
|
assert c.get_code_versions() == {"v1", "v2"}
|
|
|
|
c.pop(code_version="v1")
|
|
assert c.get_code_versions() == {"v2"}
|
|
|
|
|
|
class TestDeploymentActorWrapper:
|
|
@pytest.mark.parametrize(
|
|
"deployment_runtime_env,actor_runtime_env,conflicting_keys",
|
|
[
|
|
(
|
|
{
|
|
"env_vars": {
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_APP_NAME_ENV_VAR: "user-app",
|
|
}
|
|
},
|
|
None,
|
|
[RAY_SERVE_INTERNAL_DEPLOYMENT_APP_NAME_ENV_VAR],
|
|
),
|
|
(
|
|
None,
|
|
{
|
|
"env_vars": {
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_NAME_ENV_VAR: "user-deployment",
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_ACTOR_NAME_ENV_VAR: "user-actor",
|
|
}
|
|
},
|
|
[
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_ACTOR_NAME_ENV_VAR,
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_NAME_ENV_VAR,
|
|
],
|
|
),
|
|
(
|
|
{
|
|
"env_vars": {
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_CODE_VERSION_ENV_VAR: "user-v1",
|
|
}
|
|
},
|
|
{
|
|
"env_vars": {
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_NAME_ENV_VAR: "user-deployment",
|
|
}
|
|
},
|
|
[
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_CODE_VERSION_ENV_VAR,
|
|
RAY_SERVE_INTERNAL_DEPLOYMENT_NAME_ENV_VAR,
|
|
],
|
|
),
|
|
],
|
|
)
|
|
def test_start_rejects_reserved_internal_context_env_vars(
|
|
self,
|
|
deployment_runtime_env,
|
|
actor_runtime_env,
|
|
conflicting_keys,
|
|
):
|
|
mock_ready_ref = MagicMock()
|
|
mock_handle = MagicMock()
|
|
mock_handle.__ray_ready__ = MagicMock(
|
|
remote=MagicMock(return_value=mock_ready_ref)
|
|
)
|
|
mock_actor_cls = MagicMock()
|
|
mock_actor_cls.options.return_value.remote.return_value = mock_handle
|
|
|
|
actor_options = {}
|
|
if actor_runtime_env is not None:
|
|
actor_options["runtime_env"] = actor_runtime_env
|
|
|
|
config = DeploymentActorConfig(
|
|
name="counter",
|
|
actor_class="builtins:object",
|
|
actor_options=actor_options,
|
|
)
|
|
wrapper = DeploymentActorWrapper(
|
|
deployment_id=TEST_DEPLOYMENT_ID,
|
|
config=config,
|
|
code_version="v1",
|
|
)
|
|
|
|
with patch(
|
|
"ray.serve._private.deployment_state.DeploymentActorConfig.get_actor_class",
|
|
return_value=mock_actor_cls,
|
|
):
|
|
success, err = wrapper.start(deployment_runtime_env=deployment_runtime_env)
|
|
|
|
assert success is False
|
|
assert err is not None
|
|
for key in conflicting_keys:
|
|
assert key in err
|
|
mock_actor_cls.options.assert_not_called()
|
|
|
|
def test_properties(self):
|
|
"""Test actor_logical_name and code_version properties."""
|
|
config = DeploymentActorConfig(name="counter", actor_class="builtins:object")
|
|
wrapper = DeploymentActorWrapper(
|
|
deployment_id=TEST_DEPLOYMENT_ID,
|
|
config=config,
|
|
code_version="v1",
|
|
)
|
|
assert wrapper.actor_logical_name == "counter"
|
|
assert wrapper.code_version == "v1"
|
|
|
|
def test_start_success(self):
|
|
"""Test start() returns (True, None) when actor creation succeeds."""
|
|
mock_ready_ref = MagicMock()
|
|
mock_handle = MagicMock()
|
|
mock_handle.__ray_ready__ = MagicMock(
|
|
remote=MagicMock(return_value=mock_ready_ref)
|
|
)
|
|
|
|
mock_actor_cls = MagicMock()
|
|
mock_actor_cls.options.return_value.remote.return_value = mock_handle
|
|
|
|
config = DeploymentActorConfig(name="counter", actor_class="builtins:object")
|
|
wrapper = DeploymentActorWrapper(
|
|
deployment_id=TEST_DEPLOYMENT_ID,
|
|
config=config,
|
|
code_version="v1",
|
|
)
|
|
with patch(
|
|
"ray.serve._private.deployment_state.DeploymentActorConfig.get_actor_class",
|
|
return_value=mock_actor_cls,
|
|
):
|
|
success, err = wrapper.start()
|
|
assert success is True
|
|
assert err is None
|
|
assert wrapper._handle is mock_handle
|
|
assert wrapper._ready_ref is mock_ready_ref
|
|
|
|
def test_start_injects_internal_deployment_context_env_vars(self):
|
|
"""Test start() injects deployment metadata into actor runtime_env."""
|
|
mock_ready_ref = MagicMock()
|
|
mock_handle = MagicMock()
|
|
mock_handle.__ray_ready__ = MagicMock(
|
|
remote=MagicMock(return_value=mock_ready_ref)
|
|
)
|
|
mock_actor_cls = MagicMock()
|
|
mock_actor_cls.options.return_value.remote.return_value = mock_handle
|
|
|
|
config = DeploymentActorConfig(name="counter", actor_class="builtins:object")
|
|
wrapper = DeploymentActorWrapper(
|
|
deployment_id=TEST_DEPLOYMENT_ID,
|
|
config=config,
|
|
code_version="v1",
|
|
)
|
|
with patch(
|
|
"ray.serve._private.deployment_state.DeploymentActorConfig.get_actor_class",
|
|
return_value=mock_actor_cls,
|
|
):
|
|
success, err = wrapper.start(
|
|
deployment_runtime_env={"env_vars": {"PARENT_ENV": "parent"}}
|
|
)
|
|
|
|
assert success is True
|
|
assert err is None
|
|
|
|
actor_options = mock_actor_cls.options.call_args.kwargs
|
|
assert "runtime_env" in actor_options
|
|
assert actor_options["runtime_env"]["env_vars"] == {
|
|
"PARENT_ENV": "parent",
|
|
"RAY_SERVE_INTERNAL_DEPLOYMENT_APP_NAME": TEST_DEPLOYMENT_ID.app_name,
|
|
"RAY_SERVE_INTERNAL_DEPLOYMENT_NAME": TEST_DEPLOYMENT_ID.name,
|
|
"RAY_SERVE_INTERNAL_DEPLOYMENT_ACTOR_NAME": "counter",
|
|
"RAY_SERVE_INTERNAL_DEPLOYMENT_CODE_VERSION": "v1",
|
|
}
|
|
|
|
def test_start_preserves_user_env_vars_when_injecting_internal_context(self):
|
|
"""Test internal context env injection preserves unrelated user env vars."""
|
|
mock_ready_ref = MagicMock()
|
|
mock_handle = MagicMock()
|
|
mock_handle.__ray_ready__ = MagicMock(
|
|
remote=MagicMock(return_value=mock_ready_ref)
|
|
)
|
|
mock_actor_cls = MagicMock()
|
|
mock_actor_cls.options.return_value.remote.return_value = mock_handle
|
|
|
|
config = DeploymentActorConfig(
|
|
name="counter",
|
|
actor_class="builtins:object",
|
|
actor_options={"runtime_env": {"env_vars": {"CHILD_ENV": "child"}}},
|
|
)
|
|
wrapper = DeploymentActorWrapper(
|
|
deployment_id=TEST_DEPLOYMENT_ID,
|
|
config=config,
|
|
code_version="v2",
|
|
)
|
|
with patch(
|
|
"ray.serve._private.deployment_state.DeploymentActorConfig.get_actor_class",
|
|
return_value=mock_actor_cls,
|
|
):
|
|
wrapper.start(deployment_runtime_env={"env_vars": {"PARENT_ENV": "parent"}})
|
|
|
|
actor_options = mock_actor_cls.options.call_args.kwargs
|
|
assert actor_options["runtime_env"]["env_vars"]["PARENT_ENV"] == "parent"
|
|
assert actor_options["runtime_env"]["env_vars"]["CHILD_ENV"] == "child"
|
|
assert (
|
|
actor_options["runtime_env"]["env_vars"][
|
|
"RAY_SERVE_INTERNAL_DEPLOYMENT_ACTOR_NAME"
|
|
]
|
|
== "counter"
|
|
)
|
|
|
|
def test_start_failure(self):
|
|
"""Test start() returns (False, error_msg) when actor creation fails."""
|
|
mock_actor_cls = MagicMock()
|
|
mock_actor_cls.options.return_value.remote.side_effect = RuntimeError(
|
|
"out of resources"
|
|
)
|
|
config = DeploymentActorConfig(name="counter", actor_class="builtins:object")
|
|
wrapper = DeploymentActorWrapper(
|
|
deployment_id=TEST_DEPLOYMENT_ID,
|
|
config=config,
|
|
code_version="v1",
|
|
)
|
|
with patch(
|
|
"ray.serve._private.deployment_state.DeploymentActorConfig.get_actor_class",
|
|
return_value=mock_actor_cls,
|
|
):
|
|
success, err = wrapper.start()
|
|
assert success is False
|
|
assert "out of resources" in err
|
|
|
|
def test_check_ready_already_ready(self):
|
|
"""Test check_ready() when _ready_ref is None and _handle is set."""
|
|
config = DeploymentActorConfig(name="counter", actor_class="builtins:object")
|
|
wrapper = DeploymentActorWrapper(
|
|
deployment_id=TEST_DEPLOYMENT_ID,
|
|
config=config,
|
|
code_version="v1",
|
|
)
|
|
wrapper._handle = object() # Any truthy value - actor is ready
|
|
wrapper._ready_ref = None
|
|
ready, err = wrapper.check_ready()
|
|
assert ready is True
|
|
assert err is None
|
|
|
|
def test_check_ready_not_started(self):
|
|
"""Test check_ready() when _ready_ref and _handle are None."""
|
|
config = DeploymentActorConfig(name="counter", actor_class="builtins:object")
|
|
wrapper = DeploymentActorWrapper(
|
|
deployment_id=TEST_DEPLOYMENT_ID,
|
|
config=config,
|
|
code_version="v1",
|
|
)
|
|
ready, err = wrapper.check_ready()
|
|
assert ready is False
|
|
assert err is None
|
|
|
|
def test_check_ready_ref_not_ready(self):
|
|
"""Test check_ready() when _ready_ref exists but is not ready."""
|
|
with patch(
|
|
"ray.serve._private.deployment_state.check_obj_ref_ready_nowait",
|
|
return_value=False,
|
|
):
|
|
config = DeploymentActorConfig(
|
|
name="counter", actor_class="builtins:object"
|
|
)
|
|
wrapper = DeploymentActorWrapper(
|
|
deployment_id=TEST_DEPLOYMENT_ID,
|
|
config=config,
|
|
code_version="v1",
|
|
)
|
|
wrapper._ready_ref = object() # Pending ref, not ready yet
|
|
ready, err = wrapper.check_ready()
|
|
assert ready is False
|
|
assert err is None
|
|
|
|
def test_check_ready_ref_ready_then_success(self):
|
|
"""Test check_ready() when _ready_ref is ready and ray.get succeeds."""
|
|
with (
|
|
patch(
|
|
"ray.serve._private.deployment_state.check_obj_ref_ready_nowait",
|
|
return_value=True,
|
|
),
|
|
patch("ray.serve._private.deployment_state.ray.get"),
|
|
):
|
|
config = DeploymentActorConfig(
|
|
name="counter", actor_class="builtins:object"
|
|
)
|
|
wrapper = DeploymentActorWrapper(
|
|
deployment_id=TEST_DEPLOYMENT_ID,
|
|
config=config,
|
|
code_version="v1",
|
|
)
|
|
wrapper._ready_ref = object() # Ref that will pass check
|
|
ready, err = wrapper.check_ready()
|
|
assert ready is True
|
|
assert err is None
|
|
assert wrapper._ready_ref is None
|
|
|
|
def test_check_ready_ref_ready_then_fails(self):
|
|
"""Test check_ready() when ray.get on _ready_ref raises."""
|
|
with (
|
|
patch(
|
|
"ray.serve._private.deployment_state.check_obj_ref_ready_nowait",
|
|
return_value=True,
|
|
),
|
|
patch(
|
|
"ray.serve._private.deployment_state.ray.get",
|
|
side_effect=RuntimeError("actor crashed"),
|
|
),
|
|
):
|
|
config = DeploymentActorConfig(
|
|
name="counter", actor_class="builtins:object"
|
|
)
|
|
wrapper = DeploymentActorWrapper(
|
|
deployment_id=TEST_DEPLOYMENT_ID,
|
|
config=config,
|
|
code_version="v1",
|
|
)
|
|
wrapper._ready_ref = object() # Ref that passes check, ray.get will fail
|
|
ready, err = wrapper.check_ready()
|
|
assert ready is False
|
|
assert "actor crashed" in err
|
|
|
|
def test_kill_with_handle(self):
|
|
"""Test kill() when _handle is already set."""
|
|
with patch("ray.serve._private.deployment_state.ray.kill") as mock_kill:
|
|
config = DeploymentActorConfig(
|
|
name="counter", actor_class="builtins:object"
|
|
)
|
|
fake_handle = object() # Simulates existing actor handle
|
|
wrapper = DeploymentActorWrapper(
|
|
deployment_id=TEST_DEPLOYMENT_ID,
|
|
config=config,
|
|
code_version="v1",
|
|
recovered_handle=fake_handle,
|
|
)
|
|
wrapper.kill()
|
|
mock_kill.assert_called_once_with(fake_handle, no_restart=True)
|
|
|
|
def test_kill_without_handle(self):
|
|
"""Test kill() when _handle is None - fetches via get_actor then kills."""
|
|
fake_handle = object()
|
|
with (
|
|
patch(
|
|
"ray.serve._private.deployment_state.ray.get_actor",
|
|
return_value=fake_handle,
|
|
),
|
|
patch("ray.serve._private.deployment_state.ray.kill") as mock_kill,
|
|
):
|
|
config = DeploymentActorConfig(
|
|
name="counter", actor_class="builtins:object"
|
|
)
|
|
wrapper = DeploymentActorWrapper(
|
|
deployment_id=TEST_DEPLOYMENT_ID,
|
|
config=config,
|
|
code_version="v1",
|
|
)
|
|
wrapper.kill()
|
|
mock_kill.assert_called_once_with(fake_handle, no_restart=True)
|
|
|
|
def test_kill_actor_already_stopped(self):
|
|
"""Test kill() when actor is already stopped - should not raise."""
|
|
with (
|
|
patch(
|
|
"ray.serve._private.deployment_state.ray.get_actor",
|
|
side_effect=ValueError("actor not found"),
|
|
),
|
|
patch("ray.serve._private.deployment_state.ray.kill"),
|
|
):
|
|
config = DeploymentActorConfig(
|
|
name="counter", actor_class="builtins:object"
|
|
)
|
|
wrapper = DeploymentActorWrapper(
|
|
deployment_id=TEST_DEPLOYMENT_ID,
|
|
config=config,
|
|
code_version="v1",
|
|
)
|
|
wrapper.kill() # should not raise
|
|
|
|
|
|
def check_counts(
|
|
deployment_state: DeploymentState,
|
|
total: Optional[int] = None,
|
|
by_state: Optional[List[Tuple[ReplicaState, int]]] = None,
|
|
):
|
|
replicas = {
|
|
state: deployment_state._replicas.count(states=[state])
|
|
for state in ALL_REPLICA_STATES
|
|
}
|
|
if total is not None:
|
|
assert deployment_state._replicas.count() == total, f"Replicas: {replicas}"
|
|
|
|
if by_state is not None:
|
|
for state, count, version in by_state:
|
|
assert isinstance(state, ReplicaState)
|
|
assert isinstance(count, int) and count >= 0
|
|
curr_count = deployment_state._replicas.count(
|
|
version=version, states=[state]
|
|
)
|
|
msg = (
|
|
f"Expected {count} for state {state} but got {curr_count}. Current "
|
|
f"replicas: {replicas}"
|
|
)
|
|
assert curr_count == count, msg
|
|
|
|
|
|
def test_create_delete_single_replica(mock_deployment_state_manager):
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
info_1, v1 = deployment_info()
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Single replica should be created.
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, None)])
|
|
|
|
# update() should not transition the state if the replica isn't ready.
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, None)])
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
# Now the replica should be marked running.
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
# Removing the replica should transition it to stopping.
|
|
ds.delete()
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STOPPING, 1, None)])
|
|
assert ds._replicas.get()[0]._actor.stopped
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert ds.curr_status_info.status_trigger == DeploymentStatusTrigger.DELETING
|
|
|
|
# Once it's done stopping, replica should be removed.
|
|
replica = ds._replicas.get()[0]
|
|
replica._actor.set_done_stopping()
|
|
dsm.update()
|
|
check_counts(ds, total=0)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"allocate_logs, expected_dead",
|
|
[(True, 1), (False, 0)],
|
|
ids=["with_logs", "no_logs"],
|
|
)
|
|
def test_recent_dead_replicas_retention(
|
|
mock_deployment_state_manager, allocate_logs, expected_dead
|
|
):
|
|
"""A stopped replica is retained for the dashboard iff it allocated a log file."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, deployment_info()[0])
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
if allocate_logs:
|
|
# set_ready() allocates the replica's log file path.
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
replica_id = ds._replicas.get()[0].replica_id.unique_id
|
|
|
|
ds.delete()
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_done_stopping()
|
|
dsm.update()
|
|
check_counts(ds, total=0)
|
|
|
|
# Dead replicas are tracked separately, so the live list is unaffected.
|
|
assert ds.list_replica_details() == []
|
|
dead = ds.list_recent_dead_replicas()
|
|
assert len(dead) == expected_dead
|
|
if expected_dead:
|
|
assert dead[0].replica_id == replica_id
|
|
assert dead[0].state == ReplicaState.STOPPED
|
|
assert dead[0].log_file_path is not None
|
|
|
|
|
|
@patch("ray.serve._private.deployment_state.RAY_SERVE_RETAINED_DEAD_REPLICAS", 2)
|
|
def test_recent_dead_replicas_bounded(mock_deployment_state_manager):
|
|
"""Only the most recent RAY_SERVE_RETAINED_DEAD_REPLICAS are retained."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, deployment_info(num_replicas=3)[0])
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
assert ds._recent_dead_replicas.maxlen == 2
|
|
|
|
# Bring up 3 replicas, then stop all of them at once.
|
|
dsm.update()
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.RUNNING, 3, None)])
|
|
|
|
ds.delete()
|
|
dsm.update()
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_done_stopping()
|
|
dsm.update()
|
|
check_counts(ds, total=0)
|
|
|
|
# Buffer is capped at 2 even though three replicas have stopped.
|
|
assert len(ds._recent_dead_replicas) == 2
|
|
|
|
|
|
def test_force_kill(mock_deployment_state_manager):
|
|
create_dsm, timer, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
grace_period_s = 10
|
|
info_1, _ = deployment_info(graceful_shutdown_timeout_s=grace_period_s)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
dsm.update()
|
|
|
|
# Create deployment.
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
|
|
# Delete deployment.
|
|
ds.delete()
|
|
|
|
# Replica should remain in STOPPING until it finishes.
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STOPPING, 1, None)])
|
|
assert ds._replicas.get()[0]._actor.stopped
|
|
|
|
for _ in range(10):
|
|
dsm.update()
|
|
|
|
# force_stop shouldn't be called until after the timer.
|
|
assert not ds._replicas.get()[0]._actor.force_stopped_counter
|
|
print(ds._replicas)
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STOPPING, 1, None)])
|
|
|
|
# Advance the timer, now the replica should be force stopped.
|
|
timer.advance(grace_period_s + 0.1)
|
|
dsm.update()
|
|
assert ds._replicas.get()[0]._actor.force_stopped_counter == 1
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STOPPING, 1, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert ds.curr_status_info.status_trigger == DeploymentStatusTrigger.DELETING
|
|
|
|
# Force stop should be called repeatedly until the replica stops.
|
|
dsm.update()
|
|
assert ds._replicas.get()[0]._actor.force_stopped_counter == 2
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STOPPING, 1, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert ds.curr_status_info.status_trigger == DeploymentStatusTrigger.DELETING
|
|
|
|
# Once the replica is done stopping, it should be removed.
|
|
replica = ds._replicas.get()[0]
|
|
replica._actor.set_done_stopping()
|
|
dsm.update()
|
|
check_counts(ds, total=0)
|
|
|
|
|
|
def test_redeploy_same_version(mock_deployment_state_manager):
|
|
# Redeploying with the same version and code should do nothing.
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
info_1, v1 = deployment_info(version="1")
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
dsm.update()
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
# Test redeploying while the initial deployment is still pending.
|
|
updating = dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
assert not updating
|
|
# Redeploying the exact same info shouldn't cause any change in status
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, v1)])
|
|
|
|
# Mark the replica ready. After this, the initial goal should be complete.
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
# Test redeploying after the initial deployment has finished.
|
|
updating = dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
assert not updating
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
|
|
def test_redeploy_no_version(mock_deployment_state_manager):
|
|
"""Redeploying with no version specified (`None`) should always
|
|
redeploy the replicas.
|
|
"""
|
|
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
b_info_1, v1 = deployment_info(version=None)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
# Test redeploying while the initial deployment is still pending.
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
dsm.update()
|
|
# The initial replica should be stopping. The new replica should
|
|
# start without waiting for the old one to stop completely.
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[
|
|
(ReplicaState.STOPPING, 1, None),
|
|
(ReplicaState.STARTING, 1, None),
|
|
],
|
|
)
|
|
|
|
# Mark old replica as completely stopped.
|
|
ds._replicas.get(states=[ReplicaState.STOPPING])[0]._actor.set_done_stopping()
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
# Check that the new replica has started.
|
|
ds._replicas.get(states=[ReplicaState.STARTING])[0]._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
# Now deploy a third version after the transition has finished.
|
|
b_info_3, v3 = deployment_info(version="3")
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_3)
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
dsm.update()
|
|
# The initial replica should be stopping. The new replica should
|
|
# start without waiting for the old one to stop completely.
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[
|
|
(ReplicaState.STOPPING, 1, None),
|
|
(ReplicaState.STARTING, 1, v3),
|
|
],
|
|
)
|
|
ds._replicas.get(states=[ReplicaState.STOPPING])[0]._actor.set_done_stopping()
|
|
|
|
dsm.update()
|
|
ds._replicas.get(states=[ReplicaState.STARTING])[0]._actor.set_ready()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
|
|
def test_redeploy_new_version(mock_deployment_state_manager):
|
|
"""Redeploying with a new version should start a new replica."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
b_info_1, v1 = deployment_info(version="1")
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
# Test redeploying while the initial deployment is still pending.
|
|
b_info_2, v2 = deployment_info(version="2")
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_2)
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
dsm.update()
|
|
# The new replica should start without waiting for the old one
|
|
# to stop.
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[(ReplicaState.STOPPING, 1, v1), (ReplicaState.STARTING, 1, v2)],
|
|
)
|
|
|
|
# Mark old replica as stopped.
|
|
ds._replicas.get(states=[ReplicaState.STOPPING])[0]._actor.set_done_stopping()
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, v2)])
|
|
|
|
# Mark new replica as ready
|
|
ds._replicas.get(states=[ReplicaState.STARTING])[0]._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, v2)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
# Now deploy a third version after the transition has finished.
|
|
b_info_3, v3 = deployment_info(version="3")
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, b_info_3)
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
dsm.update()
|
|
# New replica should start without waiting for old one to stop
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[(ReplicaState.STOPPING, 1, v2), (ReplicaState.STARTING, 1, v3)],
|
|
)
|
|
|
|
# Mark old replica as stopped and mark new replica as ready
|
|
ds._replicas.get(states=[ReplicaState.STOPPING])[0]._actor.set_done_stopping()
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, v3)])
|
|
|
|
ds._replicas.get(states=[ReplicaState.STARTING])[0]._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, v3)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
|
|
def test_redeploy_different_num_replicas(mock_deployment_state_manager):
|
|
"""Tests status changes when redeploying with different num_replicas.
|
|
|
|
1. Deploys a deployment -> checks if it's UPDATING.
|
|
2. Redeploys deployment -> checks that it's still UPDATING.
|
|
3. Makes deployment HEALTHY, and then redeploys with more replicas ->
|
|
check that is becomes UPSCALING.
|
|
4. Makes deployment HEALTHY, and then redeploys with more replicas ->
|
|
check that is becomes DOWNSCALING.
|
|
"""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
version = "1"
|
|
b_info_1, v1 = deployment_info(version=version, num_replicas=5)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, by_state=[(ReplicaState.STARTING, 5, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
# Test redeploying with a higher num_replicas while the deployment is UPDATING.
|
|
b_info_2, v1 = deployment_info(version=version, num_replicas=10)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_2)
|
|
# Redeploying while the deployment is UPDATING shouldn't change status.
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(ds, by_state=[(ReplicaState.STARTING, 10, v1)])
|
|
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
dsm.update()
|
|
check_counts(ds, by_state=[(ReplicaState.RUNNING, 10, v1)])
|
|
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
# Redeploy with a higher number of replicas. The status should be UPSCALING.
|
|
b_info_3, v1 = deployment_info(version=version, num_replicas=20)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_3)
|
|
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPSCALING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(ds, by_state=[(ReplicaState.STARTING, 10, v1)])
|
|
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
dsm.update()
|
|
check_counts(ds, by_state=[(ReplicaState.RUNNING, 20, v1)])
|
|
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger == DeploymentStatusTrigger.UPSCALE_COMPLETED
|
|
)
|
|
|
|
# Redeploy with lower number of replicas. The status should be DOWNSCALING.
|
|
b_info_4, v1 = deployment_info(version=version, num_replicas=5)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_4)
|
|
|
|
assert ds.curr_status_info.status == DeploymentStatus.DOWNSCALING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(
|
|
ds, by_state=[(ReplicaState.STOPPING, 15, v1), (ReplicaState.RUNNING, 5, v1)]
|
|
)
|
|
|
|
for replica in ds._replicas.get(states=[ReplicaState.STOPPING]):
|
|
replica._actor.set_done_stopping()
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=5, by_state=[(ReplicaState.RUNNING, 5, v1)])
|
|
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.DOWNSCALE_COMPLETED
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"option,value",
|
|
[
|
|
("user_config", {"hello": "world"}),
|
|
("max_ongoing_requests", 10),
|
|
("graceful_shutdown_timeout_s", DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_S + 1),
|
|
("graceful_shutdown_wait_loop_s", DEFAULT_GRACEFUL_SHUTDOWN_WAIT_LOOP_S + 1),
|
|
("health_check_period_s", DEFAULT_HEALTH_CHECK_PERIOD_S + 1),
|
|
("health_check_timeout_s", DEFAULT_HEALTH_CHECK_TIMEOUT_S + 1),
|
|
],
|
|
)
|
|
def test_deploy_new_config_same_code_version(
|
|
mock_deployment_state_manager, option, value
|
|
):
|
|
"""Deploying a new config with the same version should not deploy a new replica."""
|
|
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
b_info_1, v1 = deployment_info(version="1")
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
# Create the replica initially.
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
# Update to a new config without changing the code version.
|
|
b_info_2, v2 = deployment_info(version="1", **{option: value})
|
|
updated = dsm.deploy(TEST_DEPLOYMENT_ID, b_info_2)
|
|
assert updated
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, v1)])
|
|
|
|
if option in [
|
|
"user_config",
|
|
"graceful_shutdown_wait_loop_s",
|
|
"max_ongoing_requests",
|
|
]:
|
|
dsm.update()
|
|
check_counts(ds, total=1)
|
|
check_counts(
|
|
ds,
|
|
total=1,
|
|
by_state=[(ReplicaState.UPDATING, 1, v2)],
|
|
)
|
|
# Mark the replica as ready.
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, v2)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
|
|
def test_deploy_new_config_same_code_version_2(mock_deployment_state_manager):
|
|
"""Make sure we don't transition from STARTING to UPDATING directly."""
|
|
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
b_info_1, v1 = deployment_info(version="1")
|
|
updated = dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
assert updated
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
# Create the replica initially.
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, v1)])
|
|
|
|
# Update to a new config without changing the code version.
|
|
b_info_2, v2 = deployment_info(version="1", user_config={"hello": "world"})
|
|
updated = dsm.deploy(TEST_DEPLOYMENT_ID, b_info_2)
|
|
assert updated
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
dsm.update()
|
|
# Since it's STARTING, we cannot transition to UPDATING
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, v1)])
|
|
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.UPDATING, 1, v2)])
|
|
|
|
# Mark the replica as ready.
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=1)
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, v2)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
|
|
def test_deploy_new_config_new_version(mock_deployment_state_manager):
|
|
# Deploying a new config with a new version should deploy a new replica.
|
|
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
b_info_1, v1 = deployment_info(version="1")
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Create the replica initially.
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
# Update to a new config and a new version.
|
|
b_info_2, v2 = deployment_info(version="2", user_config={"hello": "world"})
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_2)
|
|
|
|
dsm.update()
|
|
# New version should start immediately without waiting for
|
|
# replicas of old version to completely stop
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[
|
|
(ReplicaState.STOPPING, 1, v1),
|
|
(ReplicaState.STARTING, 1, v2),
|
|
],
|
|
)
|
|
|
|
# Mark replica of old version as stopped
|
|
ds._replicas.get(states=[ReplicaState.STOPPING])[0]._actor.set_done_stopping()
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, v2)])
|
|
|
|
# Mark new replica as ready
|
|
ds._replicas.get(states=[ReplicaState.STARTING])[0]._actor.set_ready()
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
# Check that the new version is now running.
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, v2)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
|
|
def test_initial_deploy_no_throttling(mock_deployment_state_manager):
|
|
# All replicas should be started at once for a new deployment.
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
b_info_1, v1 = deployment_info(num_replicas=10, version="1")
|
|
updated = dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
assert updated
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=10, by_state=[(ReplicaState.STARTING, 10, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
# Check that the new replicas have started.
|
|
dsm.update()
|
|
check_counts(ds, total=10, by_state=[(ReplicaState.RUNNING, 10, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
|
|
def test_new_version_deploy_throttling_new(mock_deployment_state_manager):
|
|
"""All replicas should be started at once for a new deployment.
|
|
|
|
When the version is updated, it should be throttled. The throttling
|
|
should apply to both code version and user config updates.
|
|
"""
|
|
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
b_info_1, v1 = deployment_info(num_replicas=10, version="1", user_config="1")
|
|
updated = dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
assert updated
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=10, by_state=[(ReplicaState.STARTING, 10, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
# Check that the new replicas have started.
|
|
dsm.update()
|
|
check_counts(ds, total=10, by_state=[(ReplicaState.RUNNING, 10, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
# Now deploy a new version. Two old replicas should be stopped.
|
|
b_info_2, v2 = deployment_info(num_replicas=10, version="2", user_config="2")
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_2)
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=12,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 8, v1),
|
|
(ReplicaState.STOPPING, 2, v1),
|
|
(ReplicaState.STARTING, 2, v2),
|
|
],
|
|
)
|
|
|
|
# Mark only one of the replicas as done stopping.
|
|
ds._replicas.get(states=[ReplicaState.STOPPING])[0]._actor.set_done_stopping()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=11,
|
|
by_state=[
|
|
# Old version running
|
|
(ReplicaState.RUNNING, 8, v1),
|
|
# Replicas being "rolled out"
|
|
(ReplicaState.STARTING, 2, v2),
|
|
# Out of the picture
|
|
(ReplicaState.STOPPING, 1, v1),
|
|
],
|
|
)
|
|
|
|
# Mark one new replica as ready. Then the rollout should continue,
|
|
# stopping another old-version-replica and starting another
|
|
# new-version-replica
|
|
ds._replicas.get(states=[ReplicaState.STARTING])[0]._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=12,
|
|
by_state=[
|
|
# Old version running
|
|
(ReplicaState.RUNNING, 7, v1),
|
|
# New version running
|
|
(ReplicaState.RUNNING, 1, v2),
|
|
# Replicas being "rolled out"
|
|
(ReplicaState.STARTING, 2, v2),
|
|
# Out of the picture
|
|
(ReplicaState.STOPPING, 2, v1),
|
|
],
|
|
)
|
|
|
|
# Mark the old replicas as done stopping.
|
|
ds._replicas.get(states=[ReplicaState.STOPPING])[0]._actor.set_done_stopping()
|
|
ds._replicas.get(states=[ReplicaState.STARTING])[0]._actor.set_ready()
|
|
|
|
# Old replicas should be stopped and new versions started in batches of 2.
|
|
new_replicas = 2
|
|
old_replicas = 8
|
|
while old_replicas:
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
# 2 replicas should be stopping, and simultaneously 2 replicas
|
|
# should start to fill the gap.
|
|
old_replicas -= 2
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=12,
|
|
by_state=[
|
|
# Old version running
|
|
(ReplicaState.RUNNING, old_replicas, v1),
|
|
# New version running
|
|
(ReplicaState.RUNNING, new_replicas, v2),
|
|
# New replicas being "rolled out"
|
|
(ReplicaState.STARTING, 2, v2),
|
|
# Out of the picture
|
|
(ReplicaState.STOPPING, 2, v1),
|
|
],
|
|
)
|
|
|
|
ds._replicas.get(states=[ReplicaState.STOPPING])[0]._actor.set_done_stopping()
|
|
ds._replicas.get(states=[ReplicaState.STOPPING])[1]._actor.set_done_stopping()
|
|
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=10,
|
|
by_state=[
|
|
# Old version running
|
|
(ReplicaState.RUNNING, old_replicas, v1),
|
|
# New version running
|
|
(ReplicaState.RUNNING, new_replicas, v2),
|
|
# Replicas being "rolled out"
|
|
(ReplicaState.STARTING, 2, v2),
|
|
],
|
|
)
|
|
|
|
# Set both ready.
|
|
ds._replicas.get(states=[ReplicaState.STARTING])[0]._actor.set_ready()
|
|
ds._replicas.get(states=[ReplicaState.STARTING])[1]._actor.set_ready()
|
|
new_replicas += 2
|
|
|
|
# All new replicas should be up and running.
|
|
dsm.update()
|
|
check_counts(ds, total=10, by_state=[(ReplicaState.RUNNING, 10, v2)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
|
|
def test_reconfigure_throttling(mock_deployment_state_manager):
|
|
"""All replicas should be started at once for a new deployment.
|
|
|
|
When the version is updated, it should be throttled.
|
|
"""
|
|
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
b_info_1, v1 = deployment_info(num_replicas=2, version="1", user_config="1")
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
# Check that the new replicas have started.
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
# Now deploy a new user_config. One replica should be updated.
|
|
b_info_2, v2 = deployment_info(num_replicas=2, version="1", user_config="2")
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_2)
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[(ReplicaState.RUNNING, 1, v1), (ReplicaState.UPDATING, 1, v2)],
|
|
)
|
|
|
|
# Mark the updating replica as ready.
|
|
ds._replicas.get(states=[ReplicaState.UPDATING])[0]._actor.set_ready()
|
|
|
|
# The updated replica should now be RUNNING.
|
|
# The second replica should now be updated.
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[(ReplicaState.RUNNING, 1, v2), (ReplicaState.UPDATING, 1, v2)],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
# Mark the updating replica as ready.
|
|
ds._replicas.get(states=[ReplicaState.UPDATING])[0]._actor.set_ready()
|
|
|
|
# Both replicas should now be RUNNING.
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, v2)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("num_replicas", "percentage", "expected_stopping"),
|
|
[
|
|
# The existing case: 50% of 10 replicas is 5.
|
|
(10, 0.5, 5),
|
|
# Test default percentage (20%) of 10 replicas is 2.
|
|
(10, None, 2),
|
|
# Test rounding down: 50% of 3 replicas is 1.5 -> 1.
|
|
(3, 0.5, 1),
|
|
# Test minimum of 1: 20% of 4 replicas is 0.8 -> 0, but minimum is 1.
|
|
(4, 0.2, 1),
|
|
# Test percentage that isn't a clean divisor.
|
|
(10, 0.21, 2),
|
|
# Test 100% update. All old replicas should be stopping.
|
|
(5, 1.0, 5),
|
|
],
|
|
)
|
|
def test_rolling_update_percentage_configurable(
|
|
mock_deployment_state_manager, num_replicas, percentage, expected_stopping
|
|
):
|
|
"""Test that rolling_update_percentage controls how many replicas update per wave."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
deploy_kwargs = {"num_replicas": num_replicas, "version": "1"}
|
|
if percentage is not None:
|
|
deploy_kwargs["rolling_update_percentage"] = percentage
|
|
|
|
b_info_1, v1 = deployment_info(**deploy_kwargs)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(
|
|
ds, total=num_replicas, by_state=[(ReplicaState.RUNNING, num_replicas, v1)]
|
|
)
|
|
|
|
# Deploy new version and check that the correct number of replicas are
|
|
# transitioning.
|
|
deploy_kwargs["version"] = "2"
|
|
b_info_2, v2 = deployment_info(**deploy_kwargs)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_2)
|
|
dsm.update()
|
|
|
|
expected_running_v1 = num_replicas - expected_stopping
|
|
# When there are 0 running v1 replicas, the check_counts `by_state`
|
|
# entry should be omitted.
|
|
expected_by_state = [
|
|
(ReplicaState.STOPPING, expected_stopping, v1),
|
|
(ReplicaState.STARTING, expected_stopping, v2),
|
|
]
|
|
if expected_running_v1 > 0:
|
|
expected_by_state.insert(0, (ReplicaState.RUNNING, expected_running_v1, v1))
|
|
|
|
check_counts(
|
|
ds,
|
|
total=num_replicas + expected_stopping,
|
|
by_state=expected_by_state,
|
|
)
|
|
|
|
|
|
def test_new_version_and_scale_down(mock_deployment_state_manager):
|
|
# Test the case when we reduce the number of replicas and change the
|
|
# version at the same time. First the number of replicas should be
|
|
# turned down, then the rolling update should happen.
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
b_info_1, v1 = deployment_info(num_replicas=10, version="1")
|
|
updated = dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
assert updated
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=10, by_state=[(ReplicaState.STARTING, 10, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
# Check that the new replicas have started.
|
|
dsm.update()
|
|
check_counts(ds, total=10, by_state=[(ReplicaState.RUNNING, 10, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
# Now deploy a new version and scale down the number of replicas to 2.
|
|
# First, 8 old replicas should be stopped to bring it down to the target.
|
|
b_info_2, v2 = deployment_info(num_replicas=2, version="2")
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_2)
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=10,
|
|
by_state=[(ReplicaState.RUNNING, 2, v1), (ReplicaState.STOPPING, 8, v1)],
|
|
)
|
|
|
|
# Mark only one of the replicas as done stopping.
|
|
# This should not yet trigger the rolling update because there are still
|
|
# stopping replicas.
|
|
ds._replicas.get(states=[ReplicaState.STOPPING])[0]._actor.set_done_stopping()
|
|
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=9,
|
|
by_state=[(ReplicaState.RUNNING, 2, v1), (ReplicaState.STOPPING, 7, v1)],
|
|
)
|
|
|
|
# Stop the remaining replicas.
|
|
for replica in ds._replicas.get(states=[ReplicaState.STOPPING]):
|
|
replica._actor.set_done_stopping()
|
|
|
|
# Now the rolling update should trigger, stopping one of the old
|
|
# replicas, simultaneously starting replica of new version.
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=3,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 1, v1),
|
|
(ReplicaState.STOPPING, 1, v1),
|
|
(ReplicaState.STARTING, 1, v2),
|
|
],
|
|
)
|
|
|
|
# Mark old replica as stopped
|
|
ds._replicas.get(states=[ReplicaState.STOPPING])[0]._actor.set_done_stopping()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[(ReplicaState.RUNNING, 1, v1), (ReplicaState.STARTING, 1, v2)],
|
|
)
|
|
|
|
# New version is started, final old version replica should be stopped.
|
|
ds._replicas.get(states=[ReplicaState.STARTING])[0]._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=3,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 1, v2),
|
|
(ReplicaState.STOPPING, 1, v1),
|
|
(ReplicaState.STARTING, 1, v2),
|
|
],
|
|
)
|
|
|
|
# Old replica finishes stopping and new replica is ready
|
|
ds._replicas.get(states=[ReplicaState.STOPPING])[0]._actor.set_done_stopping()
|
|
dsm.update()
|
|
ds._replicas.get(states=[ReplicaState.STARTING])[0]._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, v2)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
|
|
def test_new_version_and_scale_up(mock_deployment_state_manager):
|
|
# Test the case when we increase the number of replicas and change the
|
|
# version at the same time. The new replicas should all immediately be
|
|
# turned up. When they're up, rolling update should trigger.
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
b_info_1, v1 = deployment_info(num_replicas=2, version="1")
|
|
updated = dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
assert updated
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
# Check that the new replicas have started.
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
# Now deploy a new version and scale up the number of replicas to 10.
|
|
# 8 new replicas should be started.
|
|
b_info_2, v2 = deployment_info(num_replicas=10, version="2")
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_2)
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=10,
|
|
by_state=[(ReplicaState.RUNNING, 2, v1), (ReplicaState.STARTING, 8, v2)],
|
|
)
|
|
|
|
# Mark the new replicas as ready.
|
|
for replica in ds._replicas.get(states=[ReplicaState.STARTING]):
|
|
replica._actor.set_ready()
|
|
|
|
# Now that the new version replicas are up, rolling update should start.
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=12,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 0, v1),
|
|
(ReplicaState.STOPPING, 2, v1),
|
|
(ReplicaState.STARTING, 2, v2),
|
|
(ReplicaState.RUNNING, 8, v2),
|
|
],
|
|
)
|
|
|
|
# Mark the replicas as done stopping.
|
|
for replica in ds._replicas.get(states=[ReplicaState.STOPPING]):
|
|
replica._actor.set_done_stopping()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=10,
|
|
by_state=[(ReplicaState.RUNNING, 8, v2), (ReplicaState.STARTING, 2, v2)],
|
|
)
|
|
|
|
# Mark the remaining replicas as ready.
|
|
for replica in ds._replicas.get(states=[ReplicaState.STARTING]):
|
|
replica._actor.set_ready()
|
|
|
|
# All new replicas should be up and running.
|
|
dsm.update()
|
|
check_counts(ds, total=10, by_state=[(ReplicaState.RUNNING, 10, v2)])
|
|
|
|
dsm.update()
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("target_capacity_direction", ["up", "down"])
|
|
def test_scale_num_replicas(mock_deployment_state_manager, target_capacity_direction):
|
|
"""Test upscaling and downscaling the number of replicas manually.
|
|
|
|
Upscaling version:
|
|
1. Deploy deployment with num_replicas=3.
|
|
2. 3 replicas starting, status=UPDATING, trigger=DEPLOY.
|
|
3. It becomes healthy with 3 running replicas.
|
|
4. Update deployment to num_replicas=5.
|
|
5. 2 replicas starting, status=UPSCALING, trigger=CONFIG_UPDATE.
|
|
6. It becomes healthy with 5 running replicas, status=HEALTHY, trigger=CONFIG_UPDATE
|
|
"""
|
|
|
|
# State
|
|
version = get_random_string()
|
|
|
|
# Create deployment state manager
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
# Deploy deployment with 3 replicas
|
|
info_1, v1 = deployment_info(num_replicas=3, version=version)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
ds: DeploymentState = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# status=UPDATING, status_trigger=DEPLOY
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.STARTING, 3, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
# Set replicas ready and check statuses
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
# status=HEALTHY, status_trigger=DEPLOY
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.RUNNING, 3, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
# upscale or downscale the number of replicas manually
|
|
new_num_replicas = 5 if target_capacity_direction == "up" else 1
|
|
info_2, _ = deployment_info(num_replicas=new_num_replicas, version=version)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_2)
|
|
dsm.update()
|
|
|
|
# status=UPSCALING/DOWNSCALING, status_trigger=CONFIG_UPDATE
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
if target_capacity_direction == "up":
|
|
check_counts(
|
|
ds,
|
|
total=5,
|
|
by_state=[(ReplicaState.RUNNING, 3, v1), (ReplicaState.STARTING, 2, v1)],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPSCALING
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
else:
|
|
check_counts(
|
|
ds,
|
|
total=3,
|
|
by_state=[(ReplicaState.RUNNING, 1, v1), (ReplicaState.STOPPING, 2, v1)],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.DOWNSCALING
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_done_stopping()
|
|
|
|
# After the upscaling/downscaling finishes
|
|
# status=HEALTHY, status_trigger=UPSCALING_COMPLETED/DOWNSCALE_COMPLETED
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=new_num_replicas,
|
|
by_state=[(ReplicaState.RUNNING, new_num_replicas, v1)],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert ds.curr_status_info.status_trigger == (
|
|
DeploymentStatusTrigger.UPSCALE_COMPLETED
|
|
if target_capacity_direction == "up"
|
|
else DeploymentStatusTrigger.DOWNSCALE_COMPLETED
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("force_stop_unhealthy_replicas", [False, True])
|
|
def test_health_check(
|
|
mock_deployment_state_manager, force_stop_unhealthy_replicas: bool
|
|
):
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
b_info_1, v1 = deployment_info(num_replicas=2, version="1")
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
ds.FORCE_STOP_UNHEALTHY_REPLICAS = force_stop_unhealthy_replicas
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
# Health check shouldn't be called until it's ready.
|
|
assert not replica._actor.health_check_called
|
|
|
|
# Check that the new replicas have started.
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
dsm.update()
|
|
for replica in ds._replicas.get():
|
|
# Health check shouldn't be called until it's ready.
|
|
assert replica._actor.health_check_called
|
|
|
|
# Mark one replica unhealthy; it should be stopped.
|
|
ds._replicas.get()[0]._actor.set_unhealthy()
|
|
dsm.update()
|
|
# SIMULTANEOUSLY a new replica should be started to try to reach
|
|
# the target number of healthy replicas.
|
|
check_counts(
|
|
ds,
|
|
total=3,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 1, v1),
|
|
(ReplicaState.STOPPING, 1, v1),
|
|
(ReplicaState.STARTING, 1, v1),
|
|
],
|
|
)
|
|
|
|
stopping_replicas = ds._replicas.get(states=[ReplicaState.STOPPING])
|
|
assert len(stopping_replicas) == 1
|
|
stopping_replica = stopping_replicas[0]
|
|
if force_stop_unhealthy_replicas:
|
|
assert stopping_replica._actor.force_stopped_counter == 1
|
|
else:
|
|
assert stopping_replica._actor.force_stopped_counter == 0
|
|
|
|
assert ds.curr_status_info.status == DeploymentStatus.UNHEALTHY
|
|
# If state transitioned from healthy -> unhealthy, status driver should be none
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.HEALTH_CHECK_FAILED
|
|
)
|
|
|
|
stopping_replica._actor.set_done_stopping()
|
|
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[(ReplicaState.RUNNING, 1, v1), (ReplicaState.STARTING, 1, v1)],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.UNHEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.HEALTH_CHECK_FAILED
|
|
)
|
|
|
|
replica = ds._replicas.get(states=[ReplicaState.STARTING])[0]
|
|
replica._actor.set_ready()
|
|
assert ds.curr_status_info.status == DeploymentStatus.UNHEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.HEALTH_CHECK_FAILED
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert ds.curr_status_info.status_trigger == DeploymentStatusTrigger.UNSPECIFIED
|
|
|
|
|
|
def test_health_gauge_caching(mock_deployment_state_manager):
|
|
"""Test that the health gauge is only set when the value changes.
|
|
|
|
The _health_gauge_cache avoids redundant Gauge.set() calls on every
|
|
control-loop iteration, which are expensive at scale.
|
|
"""
|
|
create_dsm, timer, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
b_info_1, v1 = deployment_info(num_replicas=2, version="1")
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, v1)])
|
|
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
# First update: _check_startup_replicas transitions STARTING -> RUNNING.
|
|
# check_and_update_replicas hasn't seen them as RUNNING yet.
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, v1)])
|
|
|
|
# Second update: check_and_update_replicas processes the RUNNING replicas
|
|
# for the first time, calling check_health() and setting the gauge.
|
|
dsm.update()
|
|
|
|
replica_ids = [r.replica_id.unique_id for r in ds._replicas.get()]
|
|
# After the second update the cache should have (value=1, timestamp) for both.
|
|
for rid in replica_ids:
|
|
cached_value, cached_time = ds._health_gauge_cache[rid]
|
|
assert cached_value == 1
|
|
|
|
# Track how many times Gauge.set is called using a wrapper.
|
|
original_set = ds.health_check_gauge.set
|
|
call_count = 0
|
|
|
|
def counting_set(*args, **kwargs):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return original_set(*args, **kwargs)
|
|
|
|
ds.health_check_gauge.set = counting_set
|
|
|
|
# Subsequent updates with all-healthy replicas should NOT call Gauge.set
|
|
# because the cache already has value 1 for each replica (within TTL).
|
|
dsm.update()
|
|
dsm.update()
|
|
dsm.update()
|
|
assert call_count == 0, (
|
|
f"Gauge.set was called {call_count} times for already-healthy replicas; "
|
|
"expected 0 (should be cached)"
|
|
)
|
|
|
|
# After the TTL expires, the gauge should be re-reported even though
|
|
# the value hasn't changed.
|
|
timer.advance(RAY_SERVE_STATUS_GAUGE_REPORT_INTERVAL_S + 1)
|
|
dsm.update()
|
|
assert call_count == len(replica_ids), (
|
|
f"Gauge.set was called {call_count} times after TTL expired; "
|
|
f"expected {len(replica_ids)} (one per replica)"
|
|
)
|
|
|
|
# Mark one replica unhealthy — gauge should transition to 0.
|
|
call_count = 0
|
|
ds._replicas.get()[0]._actor.set_unhealthy()
|
|
dsm.update()
|
|
# Gauge.set should have been called at least once (for the now-unhealthy replica).
|
|
assert call_count >= 1
|
|
# The stopping replica should have cache value 0.
|
|
stopping = ds._replicas.get(states=[ReplicaState.STOPPING])
|
|
assert len(stopping) == 1
|
|
cached_value, _ = ds._health_gauge_cache[stopping[0].replica_id.unique_id]
|
|
assert cached_value == 0
|
|
|
|
# After the stopped replica is fully removed, its cache entry should be cleaned up.
|
|
stopped_id = stopping[0].replica_id.unique_id
|
|
stopping[0]._actor.set_done_stopping()
|
|
call_count = 0
|
|
dsm.update()
|
|
assert stopped_id not in ds._health_gauge_cache
|
|
|
|
|
|
def test_update_while_unhealthy(mock_deployment_state_manager):
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
b_info_1, v1 = deployment_info(num_replicas=2, version="1")
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
# Health check shouldn't be called until it's ready.
|
|
assert not replica._actor.health_check_called
|
|
|
|
# Check that the new replicas have started.
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
dsm.update()
|
|
for replica in ds._replicas.get():
|
|
# Health check shouldn't be called until it's ready.
|
|
assert replica._actor.health_check_called
|
|
|
|
# Mark one replica unhealthy. It should be stopped.
|
|
ds._replicas.get()[0]._actor.set_unhealthy()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=3,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 1, v1),
|
|
(ReplicaState.STOPPING, 1, v1),
|
|
(ReplicaState.STARTING, 1, v1),
|
|
],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.UNHEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.HEALTH_CHECK_FAILED
|
|
)
|
|
|
|
replica = ds._replicas.get(states=[ReplicaState.STOPPING])[0]
|
|
replica._actor.set_done_stopping()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 1, v1),
|
|
(ReplicaState.STARTING, 1, v1),
|
|
],
|
|
)
|
|
|
|
# Now deploy a new version (e.g., a rollback). This should update the status
|
|
# to UPDATING and then it should eventually become healthy.
|
|
b_info_2, v2 = deployment_info(num_replicas=2, version="2")
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_2)
|
|
|
|
# The replica that was still starting should be stopped (over the
|
|
# running replica).
|
|
dsm.update()
|
|
# Simultaneously, a replica with the new version should be started
|
|
check_counts(
|
|
ds,
|
|
total=3,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 1, v1),
|
|
(ReplicaState.STOPPING, 1, v1),
|
|
(ReplicaState.STARTING, 1, v2),
|
|
],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
# Mark the remaining running replica of the old version as unhealthy
|
|
ds._replicas.get(states=[ReplicaState.RUNNING])[0]._actor.set_unhealthy()
|
|
dsm.update()
|
|
# A replica of the new version should get started to try to reach
|
|
# the target number of healthy replicas
|
|
check_counts(
|
|
ds,
|
|
total=4,
|
|
by_state=[(ReplicaState.STOPPING, 2, v1), (ReplicaState.STARTING, 2, v2)],
|
|
)
|
|
# Check that a failure in the old version replica does not mark the
|
|
# deployment as UNHEALTHY.
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
ds._replicas.get(states=[ReplicaState.STOPPING])[0]._actor.set_done_stopping()
|
|
ds._replicas.get(states=[ReplicaState.STOPPING])[1]._actor.set_done_stopping()
|
|
|
|
# Another replica of the new version should get started.
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, v2)])
|
|
|
|
# Mark new version replicas as ready.
|
|
for replica in ds._replicas.get(states=[ReplicaState.STARTING]):
|
|
replica._actor.set_ready()
|
|
|
|
# Both replicas should be RUNNING, deployment should be HEALTHY.
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, v2)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
|
|
def _constructor_failure_loop_two_replica(
|
|
dsm, ds, num_loops, replica_retry_multiplier=3
|
|
):
|
|
"""Helper function to exact constructor failure loops."""
|
|
|
|
for i in range(num_loops):
|
|
# Two replicas should be created.
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, None)])
|
|
|
|
assert ds._replica_constructor_retry_counter == i * 2
|
|
|
|
replica_1 = ds._replicas.get()[0]
|
|
replica_2 = ds._replicas.get()[1]
|
|
|
|
replica_1._actor.set_failed_to_start()
|
|
replica_2._actor.set_failed_to_start()
|
|
# Now the replica should be marked STOPPING after failure.
|
|
dsm.update()
|
|
if ds._replica_constructor_retry_counter >= replica_retry_multiplier * 2:
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[(ReplicaState.STOPPING, 2, None)],
|
|
)
|
|
else:
|
|
check_counts(
|
|
ds,
|
|
total=4,
|
|
by_state=[
|
|
(ReplicaState.STOPPING, 2, None),
|
|
(ReplicaState.STARTING, 2, None),
|
|
],
|
|
)
|
|
|
|
# Once it's done stopping, replica should be removed.
|
|
replica_1._actor.set_done_stopping()
|
|
replica_2._actor.set_done_stopping()
|
|
|
|
|
|
def test_deploy_with_consistent_constructor_failure(
|
|
mock_deployment_state_manager, mock_max_per_replica_retry_count
|
|
):
|
|
"""
|
|
Test deploy() multiple replicas with consistent constructor failure.
|
|
|
|
The deployment should get marked FAILED.
|
|
"""
|
|
create_dsm, timer, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
b_info_1, _ = deployment_info(num_replicas=2)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
loop_count = mock_max_per_replica_retry_count
|
|
_constructor_failure_loop_two_replica(
|
|
dsm, ds, loop_count, mock_max_per_replica_retry_count
|
|
)
|
|
|
|
assert ds._replica_constructor_retry_counter == 2 * mock_max_per_replica_retry_count
|
|
assert ds.curr_status_info.status == DeploymentStatus.DEPLOY_FAILED
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.REPLICA_STARTUP_FAILED
|
|
)
|
|
check_counts(ds, total=2)
|
|
assert ds.curr_status_info.message != ""
|
|
|
|
# No more replicas should be retried.
|
|
for _ in range(20):
|
|
dsm.update()
|
|
assert ds._replica_constructor_retry_counter == 4
|
|
check_counts(ds, total=0)
|
|
timer.advance(10) # simulate time passing between each call to update
|
|
|
|
|
|
def test_deploy_with_partial_constructor_failure(
|
|
mock_deployment_state_manager, mock_max_per_replica_retry_count
|
|
):
|
|
"""
|
|
Test deploy() multiple replicas with constructor failure exceedining
|
|
pre-set limit but achieved partial success with at least 1 running replica.
|
|
|
|
Ensures:
|
|
1) Deployment status doesn't get marked FAILED.
|
|
2) There should be expected # of RUNNING replicas eventually that
|
|
matches user intent
|
|
3) Replica counter set as -1 to stop tracking current goal as it's
|
|
already completed
|
|
|
|
Same testing for same test case in test_deploy.py.
|
|
"""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
b_info_1, _ = deployment_info(num_replicas=2)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
_constructor_failure_loop_two_replica(dsm, ds, 1, mock_max_per_replica_retry_count)
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, None)])
|
|
assert ds._replica_constructor_retry_counter == 2
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
# Let one replica reach RUNNING state while the other still fails
|
|
replica_1 = ds._replicas.get()[0]
|
|
replica_2 = ds._replicas.get()[1]
|
|
replica_1._actor.set_ready()
|
|
replica_2._actor.set_failed_to_start()
|
|
|
|
# Failed to start replica should be removed
|
|
dsm.update()
|
|
# A new replica should be brought up to take its place
|
|
check_counts(
|
|
ds,
|
|
total=3,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 1, None),
|
|
(ReplicaState.STOPPING, 1, None),
|
|
(ReplicaState.STARTING, 1, None),
|
|
],
|
|
)
|
|
|
|
# Mark old replica as done stopping
|
|
replica_2._actor.set_done_stopping()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[(ReplicaState.RUNNING, 1, None), (ReplicaState.STARTING, 1, None)],
|
|
)
|
|
|
|
# Set the starting one to fail again and trigger retry limit
|
|
starting_replica = ds._replicas.get(states=[ReplicaState.STARTING])[0]
|
|
starting_replica._actor.set_failed_to_start()
|
|
|
|
dsm.update()
|
|
# Ensure our goal returned with replica_has_started flag set
|
|
assert ds._replica_has_started
|
|
# Deployment should NOT be considered complete yet
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
# A new replica should be brought up to take its place
|
|
check_counts(
|
|
ds,
|
|
total=3,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 1, None),
|
|
(ReplicaState.STOPPING, 1, None),
|
|
(ReplicaState.STARTING, 1, None),
|
|
],
|
|
)
|
|
starting_replica = ds._replicas.get(states=[ReplicaState.STOPPING])[0]
|
|
starting_replica._actor.set_done_stopping()
|
|
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[(ReplicaState.RUNNING, 1, None), (ReplicaState.STARTING, 1, None)],
|
|
)
|
|
starting_replica = ds._replicas.get(states=[ReplicaState.STARTING])[0]
|
|
starting_replica._actor.set_ready()
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, None)])
|
|
|
|
# Deployment should be considered complete
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
|
|
def test_deploy_with_placement_group_failure(mock_deployment_state_manager):
|
|
"""
|
|
Test deploy with a placement group failure.
|
|
"""
|
|
|
|
def fake_create_placement_group_fn(placement_group_bundles, *args, **kwargs):
|
|
"""Fakes the placement_group_fn used by the scheduler.
|
|
|
|
Lets the test to run without starting Ray. Raises an exception if the
|
|
bundles are invalid.
|
|
"""
|
|
|
|
validate_placement_group(bundles=placement_group_bundles)
|
|
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm(
|
|
create_placement_group_fn_override=fake_create_placement_group_fn,
|
|
)
|
|
|
|
def create_deployment_state(
|
|
deployment_id: DeploymentID, pg_bundles=None
|
|
) -> List[DeploymentState]:
|
|
b_info, _ = deployment_info(num_replicas=3)
|
|
b_info.replica_config.placement_group_bundles = pg_bundles
|
|
assert dsm.deploy(deployment_id, b_info)
|
|
ds = dsm._deployment_states[deployment_id]
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
return ds
|
|
|
|
# Make all of ds1's replica's placement groups invalid.
|
|
invalid_bundle = [{"GPU": 0}]
|
|
with pytest.raises(ValueError):
|
|
validate_placement_group(invalid_bundle)
|
|
|
|
ds1 = create_deployment_state(TEST_DEPLOYMENT_ID, pg_bundles=invalid_bundle)
|
|
ds2 = create_deployment_state(TEST_DEPLOYMENT_ID_2)
|
|
|
|
# Now ds1's replicas should all fail, while ds2's replicas should run.
|
|
dsm.update()
|
|
|
|
check_counts(ds1, total=3, by_state=[(ReplicaState.STOPPING, 3, None)])
|
|
assert ds1._replica_constructor_retry_counter == 3
|
|
assert "Retrying 6 more time(s)" in ds1.curr_status_info.message
|
|
|
|
# Set all of ds1's replicas to stopped.
|
|
for replica in ds1._replicas.get():
|
|
replica._actor.set_done_stopping()
|
|
|
|
check_counts(ds2, total=3, by_state=[(ReplicaState.STARTING, 3, None)])
|
|
assert ds2._replica_constructor_retry_counter == 0
|
|
|
|
# Set all of ds2's replicas to ready.
|
|
for replica in ds2._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
dsm.update()
|
|
|
|
assert ds1.curr_status_info.status == DeploymentStatus.UPDATING
|
|
check_counts(ds1, total=3, by_state=[(ReplicaState.STOPPING, 3, None)])
|
|
assert ds1._replica_constructor_retry_counter == 6
|
|
assert "Retrying 3 more time(s)" in ds1.curr_status_info.message
|
|
|
|
# Set all of ds1's replicas to stopped.
|
|
for replica in ds1._replicas.get():
|
|
replica._actor.set_done_stopping()
|
|
|
|
assert ds2.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
check_counts(ds2, total=3, by_state=[(ReplicaState.RUNNING, 3, None)])
|
|
assert ds2._replica_constructor_retry_counter == 0
|
|
|
|
dsm.update()
|
|
|
|
assert ds1.curr_status_info.status == DeploymentStatus.UPDATING
|
|
check_counts(ds1, total=3, by_state=[(ReplicaState.STOPPING, 3, None)])
|
|
assert ds1._replica_constructor_retry_counter == 9
|
|
assert "Retrying 0 more time(s)" in ds1.curr_status_info.message
|
|
|
|
# Set all of ds1's replicas to stopped.
|
|
for replica in ds1._replicas.get():
|
|
replica._actor.set_done_stopping()
|
|
|
|
dsm.update()
|
|
|
|
# All replicas have failed to initialize 3 times. The deployment should
|
|
# stop trying to initialize replicas.
|
|
assert ds1.curr_status_info.status == DeploymentStatus.DEPLOY_FAILED
|
|
check_counts(ds1, total=0)
|
|
assert ds1._replica_constructor_retry_counter == 9
|
|
assert "The deployment failed to start" in ds1.curr_status_info.message
|
|
|
|
|
|
def test_deploy_with_gang_placement_group_failure(mock_deployment_state_manager):
|
|
"""
|
|
Test deploy with a gang placement group creation failure.
|
|
"""
|
|
|
|
def failing_create_placement_group_fn(request, *args, **kwargs):
|
|
raise RuntimeError("Simulated gang PG creation failure")
|
|
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm(
|
|
create_placement_group_fn_override=failing_create_placement_group_fn,
|
|
)
|
|
|
|
b_info, _ = deployment_info(
|
|
num_replicas=4,
|
|
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
|
|
# Each dsm.update() call attempts to create gang PGs, fails, and
|
|
# increments the retry counter by 1. The threshold is
|
|
# min(max_constructor_retry_count, target_num_replicas * MAX_PER_REPLICA_RETRY_COUNT)
|
|
# = min(inf, 4 * 2) = 8.
|
|
threshold = ds._failed_to_start_threshold
|
|
for i in range(1, threshold + 1):
|
|
dsm.update()
|
|
assert "Gang scheduling failed" in ds.curr_status_info.message
|
|
if i < threshold:
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert f"Retrying {threshold - i} more time(s)" in (
|
|
ds.curr_status_info.message
|
|
)
|
|
|
|
# After reaching the threshold, the next update should fail the deployment.
|
|
dsm.update()
|
|
assert ds.curr_status_info.status == DeploymentStatus.DEPLOY_FAILED
|
|
assert "The deployment failed to start" in ds.curr_status_info.message
|
|
|
|
|
|
def _deployment_actors_config():
|
|
"""DeploymentActorConfig for tests. Uses import path to avoid Ray init."""
|
|
return [
|
|
DeploymentActorConfig(
|
|
name="counter",
|
|
actor_class="ray.serve.tests.test_deployment_actors:SharedCounter",
|
|
init_kwargs={"start": 0},
|
|
),
|
|
]
|
|
|
|
|
|
def _deployment_actors_config_two():
|
|
"""Two deployment actors for partial-failure tests."""
|
|
return [
|
|
DeploymentActorConfig(
|
|
name="counter",
|
|
actor_class="ray.serve.tests.test_deployment_actors:SharedCounter",
|
|
init_kwargs={"start": 0},
|
|
),
|
|
DeploymentActorConfig(
|
|
name="cache",
|
|
actor_class="ray.serve.tests.test_deployment_actors:SharedCounter",
|
|
init_kwargs={"start": 0},
|
|
),
|
|
]
|
|
|
|
|
|
def _get_deployment_actor_wrapper(
|
|
ds: DeploymentState,
|
|
code_version: str,
|
|
actor_name: str = "counter",
|
|
):
|
|
wrapper = ds._deployment_actors.get_wrapper(code_version, actor_name)
|
|
if wrapper is None:
|
|
raise KeyError(
|
|
f"No deployment actor wrapper for version={code_version!r} "
|
|
f"name={actor_name!r}"
|
|
)
|
|
return wrapper
|
|
|
|
|
|
class TestDeploymentActors:
|
|
"""Deployment actor tests using setter methods on wrapper instances."""
|
|
|
|
def test_deploy_with_deployment_actors_deferred_replica_creation(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Replicas are not created until deployment actors are ready."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info, _ = deployment_info(
|
|
version="1",
|
|
num_replicas=2,
|
|
deployment_actors=_deployment_actors_config(),
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
for _ in range(5):
|
|
dsm.update()
|
|
check_counts(ds, total=0)
|
|
|
|
_get_deployment_actor_wrapper(ds, "1").set_ready()
|
|
dsm.update()
|
|
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, None)])
|
|
for r in ds._replicas.get():
|
|
r._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_deploy_with_deployment_actor_failure(self, mock_deployment_state_manager):
|
|
"""Deployment actor constructor failure transitions to DEPLOY_FAILED."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
error_msg = "Deployment actor 'counter' failed: constructor error"
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info, _ = deployment_info(
|
|
version="1",
|
|
num_replicas=2,
|
|
deployment_actors=_deployment_actors_config(),
|
|
max_constructor_retry_count=2,
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
for i in range(2):
|
|
dsm.update()
|
|
_get_deployment_actor_wrapper(ds, "1").set_failed_to_start(error_msg)
|
|
dsm.update()
|
|
if i < 1:
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
else:
|
|
assert ds.curr_status_info.status == DeploymentStatus.DEPLOY_FAILED
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.DEPLOYMENT_ACTOR_FAILED
|
|
)
|
|
assert error_msg in ds.curr_status_info.message
|
|
|
|
def test_delete_deployment_calls_stop_deployment_actors(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Deleting a deployment calls force_stop on deployment actors."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info, _ = deployment_info(
|
|
version="1",
|
|
num_replicas=1,
|
|
deployment_actors=_deployment_actors_config(),
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
dsm.update()
|
|
wrapper_v1 = _get_deployment_actor_wrapper(ds, "1")
|
|
wrapper_v1.set_ready()
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
ds.delete()
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_done_stopping()
|
|
dsm.update()
|
|
assert wrapper_v1.killed
|
|
|
|
def test_cleanup_orphaned_deployment_actors_on_version_change(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Orphaned version's deployment actor is killed after rollout."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info1, _ = deployment_info(
|
|
version="1",
|
|
num_replicas=1,
|
|
deployment_actors=_deployment_actors_config(),
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
dsm.update()
|
|
wrapper_v1 = _get_deployment_actor_wrapper(ds, "1")
|
|
wrapper_v1.set_ready()
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
info2, _ = deployment_info(
|
|
version="2",
|
|
num_replicas=1,
|
|
deployment_actors=_deployment_actors_config(),
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info2)
|
|
dsm.update()
|
|
wrapper_v2 = _get_deployment_actor_wrapper(ds, "2")
|
|
wrapper_v2.set_ready()
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_done_stopping()
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
|
|
assert wrapper_v1.killed
|
|
assert not wrapper_v2.killed
|
|
|
|
def test_deploy_without_deployment_actors_creates_replicas_immediately(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Deployments without deployment_actors create replicas immediately."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info, _ = deployment_info(version="1", num_replicas=2)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, None)])
|
|
for r in ds._replicas.get():
|
|
r._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_deployment_actor_start_retry_then_succeed(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Deployment actor fails N times then succeeds; replicas created."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info, _ = deployment_info(
|
|
version="1",
|
|
num_replicas=2,
|
|
deployment_actors=_deployment_actors_config(),
|
|
max_constructor_retry_count=5,
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
_get_deployment_actor_wrapper(ds, "1").set_failed_to_start("fail")
|
|
dsm.update()
|
|
assert ds._deployment_actor_retry_counter == 1
|
|
|
|
dsm.update()
|
|
_get_deployment_actor_wrapper(ds, "1").set_failed_to_start("fail")
|
|
dsm.update()
|
|
assert ds._deployment_actor_retry_counter == 2
|
|
|
|
dsm.update()
|
|
_get_deployment_actor_wrapper(ds, "1").set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, None)])
|
|
for r in ds._replicas.get():
|
|
r._actor.set_ready()
|
|
dsm.update()
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_deployment_actor_retry_counter_reset_on_redeploy(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Redeploy resets _deployment_actor_retry_counter."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info1, _ = deployment_info(
|
|
version="1",
|
|
num_replicas=1,
|
|
deployment_actors=_deployment_actors_config(),
|
|
max_constructor_retry_count=5,
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
_get_deployment_actor_wrapper(ds, "1").set_failed_to_start("fail")
|
|
dsm.update()
|
|
assert ds._deployment_actor_retry_counter == 1
|
|
|
|
info2, _ = deployment_info(
|
|
version="2",
|
|
num_replicas=1,
|
|
deployment_actors=_deployment_actors_config(),
|
|
max_constructor_retry_count=5,
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info2)
|
|
assert ds._deployment_actor_retry_counter == 0
|
|
|
|
def test_deployment_actor_terminal_failure(self, mock_deployment_state_manager):
|
|
"""After threshold failures, deployment stops retrying."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info, _ = deployment_info(
|
|
version="1",
|
|
num_replicas=1,
|
|
deployment_actors=_deployment_actors_config(),
|
|
max_constructor_retry_count=3,
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
threshold = ds._deployment_actor_failed_to_start_threshold
|
|
for _ in range(threshold):
|
|
dsm.update()
|
|
_get_deployment_actor_wrapper(ds, "1").set_failed_to_start(
|
|
"persistent error"
|
|
)
|
|
dsm.update()
|
|
|
|
assert ds.deployment_actor_terminally_failed()
|
|
dsm.update()
|
|
assert ds.curr_status_info.status == DeploymentStatus.DEPLOY_FAILED
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.DEPLOYMENT_ACTOR_FAILED
|
|
)
|
|
|
|
def test_deployment_actor_failed_handled_when_already_deploy_failed(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Repeated ticks with DEPLOYMENT_ACTOR_FAILED when already DEPLOY_FAILED.
|
|
|
|
When deployment is already in DEPLOY_FAILED (due to deployment actor
|
|
failure), check_curr_status hits deployment_actor_terminally_failed()
|
|
again on each tick. handle_transition must handle DEPLOYMENT_ACTOR_FAILED
|
|
in the DEPLOY_FAILED block to avoid returning None and crashing.
|
|
"""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info, _ = deployment_info(
|
|
version="1",
|
|
num_replicas=1,
|
|
deployment_actors=_deployment_actors_config(),
|
|
max_constructor_retry_count=2,
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Reach DEPLOY_FAILED via deployment actor terminal failure
|
|
for _ in range(2):
|
|
dsm.update()
|
|
_get_deployment_actor_wrapper(ds, "1").set_failed_to_start(
|
|
"persistent error"
|
|
)
|
|
dsm.update()
|
|
|
|
assert ds.curr_status_info.status == DeploymentStatus.DEPLOY_FAILED
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.DEPLOYMENT_ACTOR_FAILED
|
|
)
|
|
|
|
# Repeated ticks: deployment_actor_terminally_failed() stays True, so
|
|
# check_curr_status calls handle_transition(DEPLOYMENT_ACTOR_FAILED)
|
|
# while status is already DEPLOY_FAILED. Must not crash.
|
|
for _ in range(5):
|
|
dsm.update()
|
|
assert ds.curr_status_info is not None
|
|
assert ds.curr_status_info.status == DeploymentStatus.DEPLOY_FAILED
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.DEPLOYMENT_ACTOR_FAILED
|
|
)
|
|
|
|
def test_no_deployment_actors_not_terminally_failed(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Deployment without deployment_actors must not be terminally failed."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info, _ = deployment_info(version="1", num_replicas=1)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
assert not ds.deployment_actor_terminally_failed()
|
|
assert not ds._terminally_failed()
|
|
|
|
ds._deployment_actor_retry_counter = (
|
|
ds._deployment_actor_failed_to_start_threshold + 1
|
|
)
|
|
assert not ds.deployment_actor_terminally_failed()
|
|
|
|
dsm.update()
|
|
assert ds.curr_status_info.status != DeploymentStatus.DEPLOY_FAILED
|
|
|
|
def test_deployment_actor_partial_failure(self, mock_deployment_state_manager):
|
|
"""One of two deployment actors fails; DEPLOY_FAILED after threshold."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info, _ = deployment_info(
|
|
version="1",
|
|
num_replicas=2,
|
|
deployment_actors=_deployment_actors_config_two(),
|
|
max_constructor_retry_count=2,
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
for _ in range(2):
|
|
dsm.update()
|
|
_get_deployment_actor_wrapper(ds, "1", "counter").set_ready()
|
|
_get_deployment_actor_wrapper(ds, "1", "cache").set_failed_to_start(
|
|
"cache actor failed"
|
|
)
|
|
dsm.update()
|
|
|
|
assert ds.curr_status_info.status == DeploymentStatus.DEPLOY_FAILED
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.DEPLOYMENT_ACTOR_FAILED
|
|
)
|
|
assert "cache actor failed" in ds.curr_status_info.message
|
|
|
|
def test_deployment_actor_partial_failure_preserves_running(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""When one actor fails, already-RUNNING actors must stay tracked."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info, _ = deployment_info(
|
|
version="1",
|
|
num_replicas=2,
|
|
deployment_actors=_deployment_actors_config_two(),
|
|
max_constructor_retry_count=3,
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
assert (
|
|
ds._deployment_actors.count("1", states=[DeploymentActorState.STARTING])
|
|
== 2
|
|
)
|
|
|
|
_get_deployment_actor_wrapper(ds, "1", "counter").set_ready()
|
|
_get_deployment_actor_wrapper(ds, "1", "cache").set_failed_to_start(
|
|
"cache actor failed"
|
|
)
|
|
dsm.update()
|
|
|
|
assert (
|
|
ds._deployment_actors.count("1", states=[DeploymentActorState.RUNNING]) == 1
|
|
)
|
|
counter_wrapper = ds._deployment_actors.get_wrapper("1", "counter")
|
|
assert counter_wrapper is not None
|
|
|
|
def test_deployment_actor_recovery_from_checkpoint(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""recover_target_state_from_checkpoint restores deployment actors."""
|
|
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info, v1 = deployment_info(
|
|
version="1",
|
|
num_replicas=1,
|
|
deployment_actors=_deployment_actors_config(),
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
dsm.update()
|
|
_get_deployment_actor_wrapper(ds, "1").set_ready()
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
dsm.save_checkpoint()
|
|
checkpoint = dsm._kv_store.get(CHECKPOINT_KEY)
|
|
assert checkpoint is not None
|
|
|
|
mock_handle = MagicMock()
|
|
with patch("ray.get_actor") as mock_get_actor:
|
|
mock_get_actor.return_value = mock_handle
|
|
new_dsm = create_dsm([ds._replicas.get()[0].replica_id.to_full_id_str()])
|
|
new_ds = new_dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
assert (
|
|
new_ds._deployment_actors.count(
|
|
"1", states=[DeploymentActorState.RECOVERING]
|
|
)
|
|
== 1
|
|
)
|
|
recovered_wrapper = _get_deployment_actor_wrapper(new_ds, "1")
|
|
assert recovered_wrapper._handle is mock_handle
|
|
recovered_wrapper.set_ready()
|
|
new_dsm.update()
|
|
assert (
|
|
new_ds._deployment_actors.count("1", states=[DeploymentActorState.RUNNING])
|
|
== 1
|
|
)
|
|
check_counts(new_ds, total=1, by_state=[(ReplicaState.RECOVERING, 1, v1)])
|
|
|
|
def test_deployment_actor_recovery_get_actor_raises_value_error(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""When ray.get_actor raises ValueError during recovery, actor is recreated."""
|
|
from ray.serve._private.deployment_state import CHECKPOINT_KEY
|
|
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info, v1 = deployment_info(
|
|
version="1",
|
|
num_replicas=1,
|
|
deployment_actors=_deployment_actors_config(),
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
dsm.update()
|
|
_get_deployment_actor_wrapper(ds, "1").set_ready()
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
dsm.save_checkpoint()
|
|
assert dsm._kv_store.get(CHECKPOINT_KEY) is not None
|
|
|
|
# ray.get_actor raises ValueError when actor not found during recovery.
|
|
with patch("ray.get_actor") as mock_get_actor:
|
|
mock_get_actor.side_effect = ValueError("Actor not found")
|
|
new_dsm = create_dsm([ds._replicas.get()[0].replica_id.to_full_id_str()])
|
|
new_ds = new_dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# No deployment actors recovered (ValueError path skips add).
|
|
assert (
|
|
new_ds._deployment_actors.count(
|
|
"1",
|
|
states=[
|
|
DeploymentActorState.RECOVERING,
|
|
DeploymentActorState.RUNNING,
|
|
],
|
|
)
|
|
== 0
|
|
)
|
|
|
|
# Next update: start_deployment_actors recreates the missing actor.
|
|
new_dsm.update()
|
|
assert (
|
|
new_ds._deployment_actors.count("1", states=[DeploymentActorState.STARTING])
|
|
== 1
|
|
)
|
|
_get_deployment_actor_wrapper(new_ds, "1").set_ready()
|
|
new_dsm.update()
|
|
new_ds._replicas.get()[0]._actor.set_ready()
|
|
new_dsm.update()
|
|
assert new_ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_deployment_actor_multiple_not_ready_until_all_ready(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Replicas not created until ready_count == len(configs)."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info, _ = deployment_info(
|
|
version="1",
|
|
num_replicas=2,
|
|
deployment_actors=_deployment_actors_config_two(),
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=0)
|
|
|
|
_get_deployment_actor_wrapper(ds, "1", "counter").set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=0)
|
|
|
|
_get_deployment_actor_wrapper(ds, "1", "cache").set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, None)])
|
|
|
|
def test_deployment_actor_deletion_gate_with_starting(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Deletion does not complete until deployment actor is stopped."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info, _ = deployment_info(
|
|
version="1",
|
|
num_replicas=1,
|
|
deployment_actors=_deployment_actors_config(),
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
dsm.update()
|
|
wrapper = _get_deployment_actor_wrapper(ds, "1")
|
|
wrapper.set_ready()
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
ds.delete()
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_done_stopping()
|
|
dsm.update()
|
|
assert wrapper.killed
|
|
dsm.update()
|
|
assert TEST_DEPLOYMENT_ID not in dsm._deployment_states
|
|
|
|
def test_deletion_after_version_update_before_new_actors_ready(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Deletion completes when delete is called after version update but before
|
|
new version's deployment actors became ready.
|
|
|
|
Bug: With 2+ replicas, version update stops old replicas one-by-one
|
|
(rollout). After one update we have 1 RUNNING v1 replica. Delete before v2
|
|
actors become ready. stop_deployment_actors_if_needed removes v2 actors
|
|
(versions_to_keep only has v1 from replicas). Without the fix,
|
|
check_deployment_actors_ready would return False and block
|
|
_get_target_replica_delta, so downscale never runs for the remaining
|
|
RUNNING replica and the deployment gets stuck in deletion.
|
|
"""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info1, _ = deployment_info(
|
|
version="1",
|
|
num_replicas=2,
|
|
deployment_actors=_deployment_actors_config(),
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
dsm.update()
|
|
_get_deployment_actor_wrapper(ds, "1").set_ready()
|
|
dsm.update()
|
|
for r in ds._replicas.get():
|
|
r._actor.set_ready()
|
|
dsm.update()
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Version update: deploy v2. v2 deployment actors start but don't become ready.
|
|
info2, _ = deployment_info(
|
|
version="2",
|
|
num_replicas=2,
|
|
deployment_actors=_deployment_actors_config(),
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info2)
|
|
dsm.update()
|
|
# rollout stops 1 v1 replica; we have 1 STOPPING, 1 RUNNING. v2 actor STARTING.
|
|
assert _get_deployment_actor_wrapper(ds, "2") is not None
|
|
running = ds._replicas.get(states=[ReplicaState.RUNNING])
|
|
assert (
|
|
len(running) >= 1
|
|
), "Need at least 1 RUNNING v1 replica to trigger the bug"
|
|
|
|
# Delete before v2 actors become ready. stop_deployment_actors_if_needed
|
|
# removes v2 actors. Without the fix, deployment actors block blocks
|
|
# downscaling of the remaining RUNNING replica(s).
|
|
ds.delete()
|
|
for _ in range(30):
|
|
dsm.update()
|
|
if TEST_DEPLOYMENT_ID not in dsm._deployment_states:
|
|
break
|
|
stopping = ds._replicas.get(states=[ReplicaState.STOPPING])
|
|
for s in stopping:
|
|
s._actor.set_done_stopping()
|
|
|
|
assert TEST_DEPLOYMENT_ID not in dsm._deployment_states, (
|
|
"Deployment should complete deletion; without the fix it gets stuck "
|
|
"because check_deployment_actors_ready blocks downscaling."
|
|
)
|
|
|
|
def test_deployment_actor_retry_counter_reset_on_success(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""On successful readiness, retry counter is reset."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info, _ = deployment_info(
|
|
version="1",
|
|
num_replicas=1,
|
|
deployment_actors=_deployment_actors_config(),
|
|
max_constructor_retry_count=5,
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
_get_deployment_actor_wrapper(ds, "1").set_ready()
|
|
dsm.update()
|
|
assert ds._deployment_actor_retry_counter == 0
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_deployment_actor_start_failure_increments_counter(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Failure via set_failed_to_start increments retry counter."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info, _ = deployment_info(
|
|
version="1",
|
|
num_replicas=1,
|
|
deployment_actors=_deployment_actors_config(),
|
|
max_constructor_retry_count=3,
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
for i in range(3):
|
|
dsm.update()
|
|
_get_deployment_actor_wrapper(ds, "1").set_failed_to_start(
|
|
"constructor failed"
|
|
)
|
|
dsm.update()
|
|
if i < 2:
|
|
assert ds._deployment_actor_retry_counter == i + 1
|
|
assert ds.curr_status_info.status == DeploymentStatus.DEPLOY_FAILED
|
|
|
|
def test_deployment_actor_check_ready_failure_increments_counter(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""check_ready failure increments counter; DEPLOY_FAILED after threshold."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info, _ = deployment_info(
|
|
version="1",
|
|
num_replicas=1,
|
|
deployment_actors=_deployment_actors_config(),
|
|
max_constructor_retry_count=3,
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
for i in range(3):
|
|
dsm.update()
|
|
_get_deployment_actor_wrapper(ds, "1").set_failed_to_start("check failed")
|
|
dsm.update()
|
|
assert ds._deployment_actor_retry_counter == i + 1
|
|
assert ds.curr_status_info.status == DeploymentStatus.DEPLOY_FAILED
|
|
|
|
def test_deployment_actor_check_health_healthy_stays_running(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""``check_and_update_deployment_actors`` re-adds healthy RUNNING actors."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info, _ = deployment_info(
|
|
version="1",
|
|
num_replicas=1,
|
|
deployment_actors=_deployment_actors_config(),
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
dsm.update()
|
|
w = _get_deployment_actor_wrapper(ds, "1")
|
|
w.set_ready()
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert not w.killed
|
|
dsm.update()
|
|
assert not w.killed
|
|
assert ds._deployment_actors.get_wrapper("1", "counter") is w
|
|
|
|
def test_deployment_actor_check_health_unhealthy_kills_and_recreates(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Failed ``check_health`` kills the actor without startup retry counter; recovers."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info, _ = deployment_info(
|
|
version="1",
|
|
num_replicas=1,
|
|
deployment_actors=_deployment_actors_config(),
|
|
max_constructor_retry_count=5,
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
dsm.update()
|
|
w = _get_deployment_actor_wrapper(ds, "1")
|
|
w.set_ready()
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
w.set_health_ok(False)
|
|
# Isolate health reconciliation; a full ``dsm.update()`` would also scale
|
|
# and start a replacement actor in the same tick.
|
|
ds.check_and_update_deployment_actors()
|
|
assert w.killed
|
|
assert ds._deployment_actors.get_wrapper("1", "counter") is None
|
|
assert ds._deployment_actor_retry_counter == 0
|
|
assert ds._in_transition is True
|
|
assert ds.curr_status_info.status == DeploymentStatus.UNHEALTHY
|
|
|
|
dsm.update()
|
|
w2 = _get_deployment_actor_wrapper(ds, "1")
|
|
assert w2 is not w
|
|
assert not w2.killed
|
|
w2.set_ready()
|
|
dsm.update()
|
|
assert ds._deployment_actor_retry_counter == 0
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_deployment_actor_reset_health_state_after_running_on_ready(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""``check_deployment_actors_ready`` resets health bookkeeping when RUNNING."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info, _ = deployment_info(
|
|
version="1",
|
|
num_replicas=1,
|
|
deployment_actors=_deployment_actors_config(),
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
dsm.update()
|
|
w = _get_deployment_actor_wrapper(ds, "1")
|
|
assert w.reset_health_state_after_running_count == 0
|
|
w.set_ready()
|
|
dsm.update()
|
|
assert w.reset_health_state_after_running_count == 1
|
|
|
|
def test_deployment_actor_check_health_mixed_two_actors(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Only unhealthy deployment actors are killed; others stay RUNNING."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info, _ = deployment_info(
|
|
version="1",
|
|
num_replicas=1,
|
|
deployment_actors=_deployment_actors_config_two(),
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
dsm.update()
|
|
w_counter = _get_deployment_actor_wrapper(ds, "1", "counter")
|
|
w_cache = _get_deployment_actor_wrapper(ds, "1", "cache")
|
|
w_counter.set_ready()
|
|
w_cache.set_ready()
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
w_cache.set_health_ok(False)
|
|
ds.check_and_update_deployment_actors()
|
|
assert w_cache.killed
|
|
assert not w_counter.killed
|
|
assert ds._deployment_actors.get_wrapper("1", "cache") is None
|
|
assert ds._deployment_actors.get_wrapper("1", "counter") is w_counter
|
|
|
|
def test_deployment_actors_satisfied_for_target_requires_all_slots_filled(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Missing deployment actor slots are unsatisfied until all are tracked again."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info, _ = deployment_info(
|
|
version="1",
|
|
num_replicas=1,
|
|
deployment_actors=_deployment_actors_config_two(),
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
dsm.update()
|
|
w_counter = _get_deployment_actor_wrapper(ds, "1", "counter")
|
|
w_cache = _get_deployment_actor_wrapper(ds, "1", "cache")
|
|
w_counter.set_ready()
|
|
w_cache.set_ready()
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert ds._deployment_actors_satisfied_for_target()
|
|
|
|
w_cache.set_health_ok(False)
|
|
ds.check_and_update_deployment_actors()
|
|
assert not ds._deployment_actors_satisfied_for_target()
|
|
assert ds._replicas.count(states=[ReplicaState.RUNNING]) == 1
|
|
|
|
def test_deployment_actor_health_check_non_target_kills_without_startup_counter(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Like old replicas: non-target unhealthy actors are stopped, no startup counter."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
info1, _ = deployment_info(
|
|
version="1",
|
|
num_replicas=1,
|
|
deployment_actors=_deployment_actors_config(),
|
|
max_constructor_retry_count=3,
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
dsm.update()
|
|
_get_deployment_actor_wrapper(ds, "1").set_ready()
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
info2, _ = deployment_info(
|
|
version="2",
|
|
num_replicas=1,
|
|
deployment_actors=_deployment_actors_config(),
|
|
max_constructor_retry_count=3,
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info2)
|
|
dsm.update()
|
|
w_v2 = _get_deployment_actor_wrapper(ds, "2")
|
|
w_v2.set_ready()
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_done_stopping()
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert ds._deployment_actor_retry_counter == 0
|
|
|
|
w_orphan = _mock_deployment_actor_wrapper(
|
|
TEST_DEPLOYMENT_ID, "1", "orphan_leftover"
|
|
)
|
|
w_orphan.set_health_ok(False)
|
|
ds._deployment_actors.add(DeploymentActorState.RUNNING, w_orphan)
|
|
|
|
ds.check_and_update_deployment_actors()
|
|
|
|
assert w_orphan.killed
|
|
assert ds._deployment_actor_retry_counter == 0
|
|
assert ds._deployment_actors.get_wrapper("1", "orphan_leftover") is None
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
|
|
def test_deploy_with_transient_constructor_failure(mock_deployment_state_manager):
|
|
"""
|
|
Test deploy() multiple replicas with transient constructor failure.
|
|
Ensures:
|
|
1) Deployment status gets marked as RUNNING.
|
|
2) There should be expected # of RUNNING replicas eventually that
|
|
matches user intent.
|
|
3) Replica counter set as -1 to stop tracking current goal as it's
|
|
already completed.
|
|
|
|
Same testing for same test case in test_deploy.py.
|
|
"""
|
|
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
b_info_1, _ = deployment_info(num_replicas=2)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
# Burn 4 retries from both replicas.
|
|
_constructor_failure_loop_two_replica(dsm, ds, 2)
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
assert ds._replica_constructor_retry_counter == 4
|
|
|
|
# Let both replicas succeed in last try.
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
ds._replicas.get()[1]._actor.set_ready()
|
|
|
|
# Everything should be running and healthy now
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, None)])
|
|
|
|
assert ds._replica_constructor_retry_counter == 0
|
|
assert ds._replica_has_started
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
|
|
def test_recover_state_from_replica_names(mock_deployment_state_manager):
|
|
"""Test recover deployment state."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
# Deploy deployment with version "1" and one replica
|
|
info1, v1 = deployment_info(version="1")
|
|
target_state_changed = dsm.deploy(TEST_DEPLOYMENT_ID, info1)
|
|
assert target_state_changed
|
|
dsm.save_checkpoint()
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Single replica of version `version1` should be created and in STARTING state
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, v1)])
|
|
mocked_replica = ds._replicas.get()[0]
|
|
|
|
# The same replica should transition to RUNNING
|
|
mocked_replica._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, v1)])
|
|
|
|
# (simulate controller crashed!) Create a new deployment state
|
|
# manager, and it should call _recover_from_checkpoint
|
|
new_dsm: DeploymentStateManager = create_dsm(
|
|
[mocked_replica.replica_id.to_full_id_str()]
|
|
)
|
|
|
|
# New deployment state should be created and one replica should
|
|
# be RECOVERING with last-checkpointed target version `version1`
|
|
new_ds = new_dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
check_counts(new_ds, total=1, by_state=[(ReplicaState.RECOVERING, 1, v1)])
|
|
|
|
# Get the new mocked replica. Note that this represents a newly
|
|
# instantiated class keeping track of the state of the replica,
|
|
# but pointing to the same replica actor
|
|
new_mocked_replica = new_ds._replicas.get()[0]
|
|
new_mocked_replica._actor.set_ready(v1)
|
|
any_recovering = new_dsm.update()
|
|
check_counts(new_ds, total=1, by_state=[(ReplicaState.RUNNING, 1, v1)])
|
|
assert not any_recovering
|
|
# Make sure replica ID is the same, meaning the actor is the same
|
|
assert mocked_replica.replica_id == new_mocked_replica.replica_id
|
|
|
|
|
|
def test_recover_during_rolling_update(mock_deployment_state_manager):
|
|
"""Test controller crashes before a replica is updated to new version.
|
|
|
|
During recovery, the controller should wait for the version to be fetched from
|
|
the replica actor. Once it is fetched and the controller realizes the replica
|
|
has an outdated version, it should be stopped and a new replica should be started
|
|
with the target version.
|
|
"""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm = create_dsm()
|
|
|
|
# Step 1: Create some deployment info with actors in running state
|
|
info1, v1 = deployment_info(version="1")
|
|
target_state_changed = dsm.deploy(TEST_DEPLOYMENT_ID, info1)
|
|
assert target_state_changed
|
|
dsm.save_checkpoint()
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Single replica of version `version1` should be created and in STARTING state
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, v1)])
|
|
mocked_replica = ds._replicas.get()[0]
|
|
|
|
# The same replica should transition to RUNNING
|
|
mocked_replica._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, v1)])
|
|
|
|
# Now execute a rollout: upgrade the version to "2".
|
|
info2, v2 = deployment_info(version="2")
|
|
target_state_changed = dsm.deploy(TEST_DEPLOYMENT_ID, info2)
|
|
assert target_state_changed
|
|
# In real code this checkpoint would be done by the caller of .deploy()
|
|
dsm.save_checkpoint()
|
|
|
|
# Before the replica could be stopped and restarted, simulate
|
|
# controller crashed! A new deployment state manager should be
|
|
# created, and it should call _recover_from_checkpoint
|
|
new_dsm = create_dsm([mocked_replica.replica_id.to_full_id_str()])
|
|
|
|
# New deployment state should be created and one replica should
|
|
# be RECOVERING with last-checkpointed target version "2"
|
|
new_ds = new_dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
check_counts(new_ds, total=1, by_state=[(ReplicaState.RECOVERING, 1, v2)])
|
|
|
|
for _ in range(3):
|
|
new_dsm.update()
|
|
check_counts(new_ds, total=1, by_state=[(ReplicaState.RECOVERING, 1, v2)])
|
|
|
|
# Get the new mocked replica. Note that this represents a newly
|
|
# instantiated class keeping track of the state of the replica,
|
|
# but pointing to the same replica actor
|
|
new_mocked_replica = new_ds._replicas.get()[0]
|
|
# Recover real version "1" (simulate previous actor not yet stopped)
|
|
new_mocked_replica._actor.set_ready(v1)
|
|
# At this point the replica is running
|
|
new_dsm.update()
|
|
# Then deployment state manager notices the replica has outdated version -> stops it
|
|
new_dsm.update()
|
|
# Also, a replica of version "2" should be started
|
|
check_counts(
|
|
new_ds,
|
|
total=2,
|
|
by_state=[(ReplicaState.STOPPING, 1, v1), (ReplicaState.STARTING, 1, v2)],
|
|
)
|
|
new_mocked_replica._actor.set_done_stopping()
|
|
|
|
# Mark old replica as stopped.
|
|
new_dsm.update()
|
|
check_counts(new_ds, total=1, by_state=[(ReplicaState.STARTING, 1, v2)])
|
|
new_mocked_replica_version2 = new_ds._replicas.get()[0]
|
|
new_mocked_replica_version2._actor.set_ready()
|
|
new_dsm.update()
|
|
check_counts(new_ds, total=1, by_state=[(ReplicaState.RUNNING, 1, v2)])
|
|
# Make sure replica name is different, meaning a different "actor" was started
|
|
assert mocked_replica.replica_id != new_mocked_replica_version2.replica_id
|
|
|
|
|
|
def test_actor_uninitialized_before_recover(mock_deployment_state_manager):
|
|
"""Test replica actor was found alive but never finished initialization.
|
|
|
|
Mirrors the production scenario where the previous controller crashed
|
|
between actor creation and the first
|
|
`initialize_and_get_metadata(rank=...)` call. `recover()` is non-
|
|
blocking: the new controller fires `was_initialized` asynchronously,
|
|
`check_ready()` observes the False response in the reconcile loop,
|
|
kills the actor, and the reconciler replaces it with a fresh replica.
|
|
The controller-side deploy-failure counter must NOT be bumped, since
|
|
the underlying cause is a previous controller crash, not user code.
|
|
"""
|
|
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm = create_dsm()
|
|
|
|
info1, v1 = deployment_info(version="1")
|
|
target_state_changed = dsm.deploy(TEST_DEPLOYMENT_ID, info1)
|
|
assert target_state_changed
|
|
dsm.save_checkpoint()
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, v1)])
|
|
mocked_replica = ds._replicas.get()[0]
|
|
replica_id = mocked_replica.replica_id
|
|
|
|
mocked_replica._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, v1)])
|
|
|
|
# Mark this replica as not-yet-initialized so that the new controller's
|
|
# async `was_initialized` probe will return False.
|
|
uninitialized_replicas_context.add(replica_id)
|
|
|
|
new_dsm = create_dsm([replica_id.to_full_id_str()])
|
|
new_ds = new_dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# `recover()` is non-blocking: the replica enters RECOVERING and the
|
|
# probe is observed in the reconcile loop.
|
|
check_counts(new_ds, total=1, by_state=[(ReplicaState.RECOVERING, 1, v1)])
|
|
starting_failures_before = new_ds._replica_constructor_retry_counter
|
|
|
|
# Next update cycle: probe says False -> the replica is force-stopped
|
|
# (STOPPING) and a fresh replica is started in its place (STARTING) in
|
|
# the same reconcile pass.
|
|
new_dsm.update()
|
|
check_counts(
|
|
new_ds,
|
|
total=2,
|
|
by_state=[(ReplicaState.STOPPING, 1, v1), (ReplicaState.STARTING, 1, v1)],
|
|
)
|
|
# The drop must not bump the deploy-failure counter -- the underlying
|
|
# cause is a previous controller crash, not user code.
|
|
assert new_ds._replica_constructor_retry_counter == starting_failures_before
|
|
# The fresh replica should have a new replica id.
|
|
starting_replicas = new_ds._replicas.get(states=[ReplicaState.STARTING])
|
|
assert len(starting_replicas) == 1
|
|
assert starting_replicas[0].replica_id != replica_id
|
|
|
|
# Drain the STOPPING replica and confirm we're left with just the
|
|
# replacement.
|
|
stopping_replica = new_ds._replicas.get(states=[ReplicaState.STOPPING])[0]
|
|
stopping_replica._actor.set_done_stopping()
|
|
new_dsm.update()
|
|
check_counts(new_ds, total=1, by_state=[(ReplicaState.STARTING, 1, v1)])
|
|
|
|
uninitialized_replicas_context.remove(replica_id)
|
|
|
|
|
|
def test_actor_died_before_recover(mock_deployment_state_manager):
|
|
"""Test replica actor died before controller could recover it.
|
|
|
|
* Deploy app / 1 deployment / 1 replica
|
|
* (Simulated) Controller crashes.
|
|
* Controller recovers, and tries to recover replicas from actor names.
|
|
* (Simulated) The single replica from before has died before
|
|
controller could recover it.
|
|
* There should be 0 replicas in the deployment.
|
|
* In the following control loop update cycle, the controller adds a
|
|
new replica to match target state.
|
|
"""
|
|
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm = create_dsm()
|
|
|
|
# Create some deployment info with actors in running state
|
|
info1, v1 = deployment_info(version="1")
|
|
target_state_changed = dsm.deploy(TEST_DEPLOYMENT_ID, info1)
|
|
assert target_state_changed
|
|
dsm.save_checkpoint()
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Single replica of version `version1` should be created and in STARTING state
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, v1)])
|
|
mocked_replica = ds._replicas.get()[0]
|
|
replica_id = mocked_replica.replica_id
|
|
|
|
# The same replica should transition to RUNNING
|
|
mocked_replica._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, v1)])
|
|
|
|
# Set dead replicas context. When the controller recovers and tries
|
|
# to recover replicas from actor names, the replica actor wrapper
|
|
# will fail to recover.
|
|
dead_replicas_context.add(replica_id)
|
|
|
|
# Simulate controller crashed! A new deployment state manager should
|
|
# be created, and it should call _recover_from_checkpoint
|
|
new_dsm = create_dsm([replica_id.to_full_id_str()])
|
|
|
|
# Replica should fail to recover (simulate failed to get handle to
|
|
# actor), meaning replica has died.
|
|
new_ds = new_dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
check_counts(new_ds, total=0)
|
|
|
|
# Since the previous replica is now marked dead (because controller
|
|
# failed to recover it), a new replica should be added to meet
|
|
# target state.
|
|
new_dsm.update()
|
|
check_counts(new_ds, total=1, by_state=[(ReplicaState.STARTING, 1, v1)])
|
|
dead_replicas_context.remove(replica_id)
|
|
|
|
|
|
def test_shutdown(mock_deployment_state_manager):
|
|
"""
|
|
Test that shutdown waits for all deployments to be deleted and they
|
|
are force-killed without a grace period.
|
|
"""
|
|
create_dsm, timer, _, _ = mock_deployment_state_manager
|
|
dsm = create_dsm()
|
|
|
|
grace_period_s = 10
|
|
b_info_1, _ = deployment_info(
|
|
graceful_shutdown_timeout_s=grace_period_s,
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Single replica should be created.
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, None)])
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
|
|
# Now the replica should be marked running.
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, None)])
|
|
|
|
# Test shutdown flow
|
|
assert not ds._replicas.get()[0]._actor.stopped
|
|
|
|
# Before shutdown, `is_ready_for_shutdown()` should return False
|
|
assert not dsm.is_ready_for_shutdown()
|
|
|
|
dsm.shutdown()
|
|
|
|
timer.advance(grace_period_s + 0.1)
|
|
dsm.update()
|
|
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STOPPING, 1, None)])
|
|
assert ds._replicas.get()[0]._actor.stopped
|
|
assert len(dsm.get_deployment_statuses()) > 0
|
|
|
|
# Once it's done stopping, replica should be removed.
|
|
replica = ds._replicas.get()[0]
|
|
replica._actor.set_done_stopping()
|
|
dsm.update()
|
|
check_counts(ds, total=0)
|
|
assert len(dsm.get_deployment_statuses()) == 0
|
|
|
|
# After all deployments shutdown, `is_ready_for_shutdown()` should return True
|
|
assert dsm.is_ready_for_shutdown()
|
|
|
|
|
|
def test_shutdown_blocks_deploy(mock_deployment_state_manager):
|
|
"""After shutdown, deploy() should be a no-op and not create new deployments."""
|
|
create_dsm, timer, _, _ = mock_deployment_state_manager
|
|
dsm = create_dsm()
|
|
|
|
dsm.shutdown()
|
|
|
|
b_info_1, _ = deployment_info(num_replicas=3)
|
|
assert not dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
assert TEST_DEPLOYMENT_ID not in dsm._deployment_states
|
|
|
|
dsm.update()
|
|
assert TEST_DEPLOYMENT_ID not in dsm._deployment_states
|
|
assert dsm.is_ready_for_shutdown()
|
|
|
|
|
|
def test_shutdown_blocks_autoscale(mock_deployment_state_manager):
|
|
"""After shutdown, autoscale() should be a no-op."""
|
|
create_dsm, timer, _, _ = mock_deployment_state_manager
|
|
dsm = create_dsm()
|
|
|
|
b_info_1, _ = deployment_info()
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
dsm.update()
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
|
|
dsm.shutdown()
|
|
|
|
assert not dsm.autoscale(TEST_DEPLOYMENT_ID, 5)
|
|
assert ds._target_state.target_num_replicas == 0
|
|
|
|
|
|
def test_shutdown_blocks_set_target_num_replicas(mock_deployment_state_manager):
|
|
"""After shutdown, set_target_num_replicas() should be a no-op."""
|
|
create_dsm, timer, _, _ = mock_deployment_state_manager
|
|
dsm = create_dsm()
|
|
|
|
b_info_1, _ = deployment_info()
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
dsm.update()
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
|
|
dsm.shutdown()
|
|
|
|
dsm.set_target_num_replicas(TEST_DEPLOYMENT_ID, 10)
|
|
assert ds._target_state.target_num_replicas == 0
|
|
|
|
|
|
def test_shutdown_does_not_delete_checkpoint(mock_deployment_state_manager):
|
|
"""Tests checkpoint must survive `shutdown() and `is_ready_for_shutdown().
|
|
Only an explicit `delete_checkpoint() call should remove it.
|
|
"""
|
|
create_dsm, timer, _, _ = mock_deployment_state_manager
|
|
dsm = create_dsm()
|
|
|
|
grace_period_s = 10
|
|
b_info_1, _ = deployment_info(graceful_shutdown_timeout_s=grace_period_s)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
dsm.save_checkpoint()
|
|
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Single replica should be created and become running.
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, None)])
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, None)])
|
|
|
|
# Checkpoint should exist after save.
|
|
assert dsm._kv_store.get(CHECKPOINT_KEY) is not None
|
|
|
|
# shutdown() must NOT delete the checkpoint.
|
|
dsm.shutdown()
|
|
assert dsm._kv_store.get(CHECKPOINT_KEY) is not None
|
|
|
|
# save_checkpoint() after shutdown should be a no-op.
|
|
pre_shutdown_checkpoint = dsm._kv_store.get(CHECKPOINT_KEY)
|
|
dsm.save_checkpoint()
|
|
assert dsm._kv_store.get(CHECKPOINT_KEY) is pre_shutdown_checkpoint
|
|
|
|
timer.advance(grace_period_s + 0.1)
|
|
dsm.update()
|
|
replica = ds._replicas.get()[0]
|
|
replica._actor.set_done_stopping()
|
|
dsm.update()
|
|
assert dsm.is_ready_for_shutdown()
|
|
|
|
# is_ready_for_shutdown() must NOT delete the checkpoint.
|
|
assert dsm._kv_store.get(CHECKPOINT_KEY) is not None
|
|
|
|
# Only delete_checkpoint() should remove it.
|
|
dsm.delete_checkpoint()
|
|
assert dsm._kv_store.get(CHECKPOINT_KEY) is None
|
|
|
|
|
|
def test_resource_requirements_none():
|
|
"""Ensure resource_requirements doesn't break if a requirement is None"""
|
|
|
|
class FakeActor:
|
|
actor_resources = {"num_cpus": 2, "fake": None}
|
|
placement_group_bundles = None
|
|
available_resources = {}
|
|
|
|
# Make a DeploymentReplica just to accesss its resource_requirement function
|
|
replica_id = ReplicaID("asdf123", DeploymentID(name="test"))
|
|
replica = DeploymentReplica(replica_id, None)
|
|
replica._actor = FakeActor()
|
|
|
|
# resource_requirements() should not error
|
|
replica.resource_requirements()
|
|
|
|
|
|
class TestActorReplicaWrapper:
|
|
def test_default_value(self):
|
|
actor_replica = ActorReplicaWrapper(
|
|
version=deployment_version("1"),
|
|
replica_id=ReplicaID(
|
|
"abc123",
|
|
deployment_id=DeploymentID(name="test_deployment", app_name="test_app"),
|
|
),
|
|
)
|
|
assert (
|
|
actor_replica.graceful_shutdown_timeout_s
|
|
== DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_S
|
|
)
|
|
assert actor_replica.max_ongoing_requests == DEFAULT_MAX_ONGOING_REQUESTS
|
|
assert actor_replica.health_check_period_s == DEFAULT_HEALTH_CHECK_PERIOD_S
|
|
assert actor_replica.health_check_timeout_s == DEFAULT_HEALTH_CHECK_TIMEOUT_S
|
|
|
|
def test_max_concurrency_override(self):
|
|
actor_replica = ActorReplicaWrapper(
|
|
version=deployment_version("1"),
|
|
replica_id=ReplicaID(
|
|
"abc123",
|
|
deployment_id=DeploymentID(name="test_deployment", app_name="test_app"),
|
|
),
|
|
)
|
|
max_ongoing_requests = DEFAULT_MAX_CONCURRENCY_ASYNC + 1
|
|
d_info, _ = deployment_info(max_ongoing_requests=max_ongoing_requests)
|
|
replica_scheduling_request = actor_replica.start(
|
|
d_info, assign_rank_callback=lambda x: 0
|
|
)
|
|
assert (
|
|
"max_concurrency" in replica_scheduling_request.actor_options
|
|
and replica_scheduling_request.actor_options["max_concurrency"]
|
|
== max_ongoing_requests
|
|
)
|
|
|
|
|
|
def test_get_active_node_ids(mock_deployment_state_manager):
|
|
"""Test get_active_node_ids() are collecting the correct node ids
|
|
|
|
When there are no running replicas, both methods should return empty results. When
|
|
the replicas are in the RUNNING state, get_running_replica_node_ids() should return
|
|
a list of all node ids. `get_active_node_ids()` should return a set
|
|
of all node ids.
|
|
"""
|
|
node1 = NodeID.from_random().hex()
|
|
node2 = NodeID.from_random().hex()
|
|
node_ids = (node1, node2, node2)
|
|
|
|
create_dsm, _, cluster_node_info_cache, _ = mock_deployment_state_manager
|
|
dsm = create_dsm()
|
|
cluster_node_info_cache.add_node(node1)
|
|
cluster_node_info_cache.add_node(node2)
|
|
|
|
# Deploy deployment with version "1" and 3 replicas
|
|
info1, v1 = deployment_info(version="1", num_replicas=3)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# When the replicas are in the STARTING state, `get_active_node_ids()` should
|
|
# return a set of node ids.
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.STARTING, 3, v1)])
|
|
mocked_replicas = ds._replicas.get()
|
|
for idx, mocked_replica in enumerate(mocked_replicas):
|
|
mocked_replica._actor.set_node_id(node_ids[idx])
|
|
assert ds.get_active_node_ids() == set(node_ids)
|
|
assert dsm.get_active_node_ids() == set(node_ids)
|
|
|
|
# When the replicas are in RUNNING state, `get_active_node_ids()` should
|
|
# return a set of `node_ids`.
|
|
for mocked_replica in mocked_replicas:
|
|
mocked_replica._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.RUNNING, 3, v1)])
|
|
assert ds.get_active_node_ids() == set(node_ids)
|
|
assert dsm.get_active_node_ids() == set(node_ids)
|
|
|
|
for _ in mocked_replicas:
|
|
ds._stop_one_running_replica_for_testing()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=6,
|
|
by_state=[(ReplicaState.STOPPING, 3, v1), (ReplicaState.STARTING, 3, v1)],
|
|
)
|
|
|
|
|
|
def test_get_active_node_ids_none(mock_deployment_state_manager):
|
|
"""Test get_active_node_ids() are not collecting none node ids.
|
|
|
|
When the running replicas has None as the node id, `get_active_node_ids()` should
|
|
not include it in the set.
|
|
"""
|
|
node1 = NodeID.from_random().hex()
|
|
node2 = NodeID.from_random().hex()
|
|
node_ids = (node1, node2, node2)
|
|
|
|
create_dsm, _, cluster_node_info_cache, _ = mock_deployment_state_manager
|
|
dsm = create_dsm()
|
|
cluster_node_info_cache.add_node(node1)
|
|
cluster_node_info_cache.add_node(node2)
|
|
|
|
# Deploy deployment with version "1" and 3 replicas
|
|
info1, v1 = deployment_info(version="1", num_replicas=3)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# When the replicas are in the STARTING state, `get_active_node_ids()` should
|
|
# return a set of node ids.
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.STARTING, 3, v1)])
|
|
mocked_replicas = ds._replicas.get()
|
|
for idx, mocked_replica in enumerate(mocked_replicas):
|
|
mocked_replica._actor.set_node_id(node_ids[idx])
|
|
assert ds.get_active_node_ids() == set(node_ids)
|
|
assert dsm.get_active_node_ids() == set(node_ids)
|
|
|
|
# When the replicas are in the RUNNING state and are having None node id,
|
|
# `get_active_node_ids()` should return empty set.
|
|
for mocked_replica in mocked_replicas:
|
|
mocked_replica._actor.set_node_id(None)
|
|
mocked_replica._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.RUNNING, 3, v1)])
|
|
assert None not in ds.get_active_node_ids()
|
|
assert None not in dsm.get_active_node_ids()
|
|
|
|
|
|
def test_get_deployment_ids(mock_deployment_state_manager):
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm = create_dsm()
|
|
|
|
assert dsm.get_deployment_ids() == []
|
|
|
|
info1, _ = deployment_info(version="1", num_replicas=1)
|
|
info2, _ = deployment_info(version="2", num_replicas=1)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info1)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID_2, info2)
|
|
|
|
assert dsm.get_deployment_ids() == [TEST_DEPLOYMENT_ID, TEST_DEPLOYMENT_ID_2]
|
|
|
|
|
|
def test_get_node_id_to_alive_replica_ids(mock_deployment_state_manager):
|
|
node1 = NodeID.from_random().hex()
|
|
node2 = NodeID.from_random().hex()
|
|
|
|
create_dsm, _, cluster_node_info_cache, _ = mock_deployment_state_manager
|
|
dsm = create_dsm()
|
|
cluster_node_info_cache.add_node(node1)
|
|
cluster_node_info_cache.add_node(node2)
|
|
|
|
info1, v1 = deployment_info(version="1", num_replicas=2)
|
|
info2, v2 = deployment_info(version="2", num_replicas=1)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info1)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID_2, info2)
|
|
|
|
ds1 = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
ds2 = dsm._deployment_states[TEST_DEPLOYMENT_ID_2]
|
|
|
|
dsm.update()
|
|
check_counts(ds1, total=2, by_state=[(ReplicaState.STARTING, 2, v1)])
|
|
check_counts(ds2, total=1, by_state=[(ReplicaState.STARTING, 1, v2)])
|
|
|
|
replicas1 = ds1._replicas.get()
|
|
replicas2 = ds2._replicas.get()
|
|
replicas1[0]._actor.set_node_id(node1)
|
|
replicas1[1]._actor.set_node_id(node2)
|
|
replicas2[0]._actor.set_node_id(node1)
|
|
|
|
node_id_to_alive_replica_ids = dsm.get_node_id_to_alive_replica_ids()
|
|
assert type(node_id_to_alive_replica_ids) is dict
|
|
assert node_id_to_alive_replica_ids == {
|
|
node1: {
|
|
replicas1[0].replica_id.unique_id,
|
|
replicas2[0].replica_id.unique_id,
|
|
},
|
|
node2: {replicas1[1].replica_id.unique_id},
|
|
}
|
|
|
|
replicas1[0]._actor.set_ready()
|
|
replicas1[1]._actor.set_ready()
|
|
replicas2[0]._actor.set_node_id(None)
|
|
replicas2[0]._actor.set_ready()
|
|
dsm.update()
|
|
|
|
node_id_to_alive_replica_ids = dsm.get_node_id_to_alive_replica_ids()
|
|
assert type(node_id_to_alive_replica_ids) is dict
|
|
assert node_id_to_alive_replica_ids == {
|
|
node1: {replicas1[0].replica_id.unique_id},
|
|
node2: {replicas1[1].replica_id.unique_id},
|
|
}
|
|
|
|
|
|
def test_dump_replica_states_for_testing(mock_deployment_state_manager):
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm = create_dsm()
|
|
|
|
info1, _ = deployment_info(version="1", num_replicas=1)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info1)
|
|
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
assert dsm._dump_replica_states_for_testing(TEST_DEPLOYMENT_ID) is ds._replicas
|
|
|
|
with pytest.raises(KeyError):
|
|
dsm._dump_replica_states_for_testing(TEST_DEPLOYMENT_ID_2)
|
|
|
|
|
|
def test_stop_one_running_replica_for_testing(mock_deployment_state_manager):
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm = create_dsm()
|
|
|
|
info1, _ = deployment_info(version="1", num_replicas=1)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info1)
|
|
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
dsm.update()
|
|
replica = ds._replicas.get()[0]
|
|
replica._actor.set_ready()
|
|
dsm.update()
|
|
assert len(ds._replicas.get([ReplicaState.RUNNING])) == 1
|
|
|
|
dsm._stop_one_running_replica_for_testing(TEST_DEPLOYMENT_ID)
|
|
|
|
assert len(ds._replicas.get([ReplicaState.RUNNING])) == 0
|
|
assert len(ds._replicas.get([ReplicaState.STOPPING])) == 1
|
|
|
|
|
|
class TestAutoscaling:
|
|
def scale(
|
|
self,
|
|
dsm: DeploymentStateManager,
|
|
asm: AutoscalingStateManager,
|
|
deployment_ids: List[DeploymentID],
|
|
):
|
|
if not deployment_ids:
|
|
return
|
|
|
|
app_name = deployment_ids[0].app_name
|
|
assert all(dep_id.app_name == app_name for dep_id in deployment_ids)
|
|
|
|
deployment_to_target_num_replicas = {
|
|
dep_id: dsm.get_deployment_details(dep_id).target_num_replicas
|
|
for dep_id in deployment_ids
|
|
}
|
|
decisions = asm.get_decision_num_replicas(
|
|
app_name, deployment_to_target_num_replicas
|
|
)
|
|
for deployment_id, decision_num_replicas in decisions.items():
|
|
dsm.autoscale(deployment_id, decision_num_replicas)
|
|
|
|
@pytest.mark.parametrize("target_capacity_direction", ["up", "down"])
|
|
def test_basic_autoscaling(
|
|
self, mock_deployment_state_manager, target_capacity_direction
|
|
):
|
|
"""Test autoscaling up and down.
|
|
|
|
Upscaling version:
|
|
1. Deploy deployment with autoscaling limits [0,6],
|
|
initial_replicas=3, target=1.
|
|
2. It becomes healthy with 3 running replicas.
|
|
3. Set average request metrics to 2 (compare to target=1).
|
|
4. Deployment autoscales, 3 replicas starting, status=UPSCALING,
|
|
trigger=AUTOSCALE.
|
|
5. It becomes healthy with 6 running replicas, status=HEALTHY,
|
|
trigger=UPSCALE.
|
|
"""
|
|
|
|
# Create deployment state manager
|
|
create_dsm, timer, _, asm = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
asm: AutoscalingStateManager = asm
|
|
|
|
# Deploy deployment with 3 replicas
|
|
info, _ = deployment_info(
|
|
autoscaling_config={
|
|
"target_ongoing_requests": 1,
|
|
"min_replicas": 0,
|
|
"max_replicas": 6,
|
|
"initial_replicas": 3,
|
|
"upscale_delay_s": 0,
|
|
"downscale_delay_s": 0,
|
|
"metrics_interval_s": 100,
|
|
"look_back_period_s": 200,
|
|
}
|
|
)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds: DeploymentState = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# status=UPDATING, status_trigger=DEPLOY
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.STARTING, 3, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
# Set replicas ready and check statuses
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
# status=HEALTHY, status_trigger=DEPLOY
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.RUNNING, 3, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
req_per_replica = 2 if target_capacity_direction == "up" else 0
|
|
replicas = ds._replicas.get()
|
|
if RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE:
|
|
handle_metric_report = HandleMetricReport(
|
|
deployment_id=TEST_DEPLOYMENT_ID,
|
|
handle_id="random",
|
|
actor_id="actor_id",
|
|
handle_source=DeploymentHandleSource.UNKNOWN,
|
|
queued_requests=[TimeStampedValue(timer.time() - 0.1, 0)],
|
|
aggregated_queued_requests=0,
|
|
aggregated_metrics={
|
|
RUNNING_REQUESTS_KEY: {
|
|
replica._actor.replica_id.to_full_id_str(): req_per_replica
|
|
for replica in replicas
|
|
}
|
|
},
|
|
metrics={
|
|
RUNNING_REQUESTS_KEY: {
|
|
replica._actor.replica_id.to_full_id_str(): [
|
|
TimeStampedValue(timer.time() - 0.1, req_per_replica)
|
|
]
|
|
for replica in replicas
|
|
}
|
|
},
|
|
timestamp=timer.time(),
|
|
)
|
|
asm.record_request_metrics_for_handle(handle_metric_report)
|
|
else:
|
|
for replica in replicas:
|
|
replica_metric_report = ReplicaMetricReport(
|
|
replica_id=replica._actor.replica_id,
|
|
aggregated_metrics={RUNNING_REQUESTS_KEY: req_per_replica},
|
|
metrics={
|
|
RUNNING_REQUESTS_KEY: [
|
|
TimeStampedValue(timer.time() - 0.1, req_per_replica)
|
|
]
|
|
},
|
|
timestamp=timer.time(),
|
|
)
|
|
asm.record_request_metrics_for_replica(replica_metric_report)
|
|
|
|
self.scale(dsm, asm, [TEST_DEPLOYMENT_ID])
|
|
|
|
# status=UPSCALING/DOWNSCALING, status_trigger=AUTOSCALE
|
|
dsm.update()
|
|
if target_capacity_direction == "up":
|
|
check_counts(
|
|
ds,
|
|
total=6,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 3, None),
|
|
(ReplicaState.STARTING, 3, None),
|
|
],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPSCALING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.AUTOSCALING
|
|
)
|
|
|
|
# Advance timer by 60 seconds; this should exceed the slow startup
|
|
# warning threshold. The message should be updated, but the status
|
|
# should remain upscaling/autoscaling
|
|
timer.advance(60)
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=6,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 3, None),
|
|
(ReplicaState.STARTING, 3, None),
|
|
],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPSCALING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.AUTOSCALING
|
|
)
|
|
assert "have taken more than" in ds.curr_status_info.message
|
|
|
|
# Set replicas ready
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
else:
|
|
# Due to two-stage downscaling one of the replicas will still be running
|
|
check_counts(
|
|
ds,
|
|
total=3,
|
|
by_state=[
|
|
(ReplicaState.STOPPING, 2, None),
|
|
(ReplicaState.RUNNING, 1, None),
|
|
],
|
|
)
|
|
# Trigger the second stage of downscaling
|
|
self.scale(dsm, asm, [TEST_DEPLOYMENT_ID])
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.STOPPING, 3, None)])
|
|
|
|
assert ds.curr_status_info.status == DeploymentStatus.DOWNSCALING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.AUTOSCALING
|
|
)
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_done_stopping()
|
|
|
|
dsm.update()
|
|
astate = asm._app_autoscaling_states[
|
|
TEST_DEPLOYMENT_ID.app_name
|
|
]._deployment_autoscaling_states[TEST_DEPLOYMENT_ID]
|
|
assert len(astate._replica_metrics) == 0
|
|
|
|
# status=HEALTHY, status_trigger=UPSCALE/DOWNSCALE
|
|
dsm.update()
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert ds.curr_status_info.status_trigger == (
|
|
DeploymentStatusTrigger.UPSCALE_COMPLETED
|
|
if target_capacity_direction == "up"
|
|
else DeploymentStatusTrigger.DOWNSCALE_COMPLETED
|
|
)
|
|
|
|
# Make sure autoscaling state is removed when deployment is deleted
|
|
dsm.delete_deployment(TEST_DEPLOYMENT_ID)
|
|
dsm.update()
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_done_stopping()
|
|
dsm.update()
|
|
assert TEST_DEPLOYMENT_ID not in dsm._deployment_states
|
|
|
|
@pytest.mark.parametrize(
|
|
"target_startup_status",
|
|
[
|
|
ReplicaStartupStatus.PENDING_ALLOCATION,
|
|
ReplicaStartupStatus.PENDING_INITIALIZATION,
|
|
],
|
|
)
|
|
def test_downscaling_reclaiming_starting_replicas_first(
|
|
self,
|
|
target_startup_status,
|
|
mock_deployment_state_manager,
|
|
):
|
|
"""This test asserts that when downscaling first any non-running replicas are
|
|
scavenged, before stopping fully running replicas
|
|
|
|
More context on the issue could be found in:
|
|
https://github.com/ray-project/ray/issues/43034
|
|
"""
|
|
|
|
# Create deployment state manager
|
|
create_dsm, timer, _, asm = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
asm: AutoscalingStateManager = asm
|
|
|
|
# Deploy deployment with 3 replicas
|
|
info, _ = deployment_info(
|
|
autoscaling_config={
|
|
"target_ongoing_requests": 1,
|
|
"min_replicas": 0,
|
|
"max_replicas": 6,
|
|
"initial_replicas": 3,
|
|
"upscale_delay_s": 0,
|
|
"downscale_delay_s": 0,
|
|
"metrics_interval_s": 100,
|
|
"look_back_period_s": 200,
|
|
}
|
|
)
|
|
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
|
|
ds: DeploymentState = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# status=UPDATING, status_trigger=DEPLOY
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.STARTING, 3, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
# Set replicas as SUCCESSFUL and check statuses
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
# status=HEALTHY, status_trigger=DEPLOY
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.RUNNING, 3, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
# Fetch all currently running replicas
|
|
running_replicas = ds._replicas.get(states=[ReplicaState.RUNNING])
|
|
replicas = ds._replicas.get()
|
|
if RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE:
|
|
handle_metric_report = HandleMetricReport(
|
|
deployment_id=TEST_DEPLOYMENT_ID,
|
|
handle_id="random",
|
|
actor_id="actor_id",
|
|
handle_source=DeploymentHandleSource.UNKNOWN,
|
|
queued_requests=[TimeStampedValue(timer.time() - 0.1, 0)],
|
|
aggregated_queued_requests=0,
|
|
aggregated_metrics={
|
|
RUNNING_REQUESTS_KEY: {
|
|
replica._actor.replica_id.to_full_id_str(): 2
|
|
for replica in replicas
|
|
}
|
|
},
|
|
metrics={
|
|
RUNNING_REQUESTS_KEY: {
|
|
replica._actor.replica_id.to_full_id_str(): [
|
|
TimeStampedValue(timer.time() - 0.1, 2)
|
|
]
|
|
for replica in replicas
|
|
}
|
|
},
|
|
timestamp=timer.time(),
|
|
)
|
|
asm.record_request_metrics_for_handle(handle_metric_report)
|
|
else:
|
|
for replica in replicas:
|
|
replica_metric_report = ReplicaMetricReport(
|
|
replica_id=replica._actor.replica_id,
|
|
aggregated_metrics={RUNNING_REQUESTS_KEY: 2},
|
|
metrics={
|
|
RUNNING_REQUESTS_KEY: [TimeStampedValue(timer.time() - 0.1, 2)]
|
|
},
|
|
timestamp=timer.time(),
|
|
)
|
|
asm.record_request_metrics_for_replica(replica_metric_report)
|
|
|
|
# status=UPSCALING, status_trigger=AUTOSCALE
|
|
self.scale(dsm, asm, [TEST_DEPLOYMENT_ID])
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=6,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 3, None),
|
|
(ReplicaState.STARTING, 3, None),
|
|
],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPSCALING
|
|
assert ds.curr_status_info.status_trigger == DeploymentStatusTrigger.AUTOSCALING
|
|
|
|
# Set replicas as PENDING_INITIALIZATION: actors have been
|
|
# successfully allocated, but replicas are still pending
|
|
# successful initialization
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_status(target_startup_status)
|
|
|
|
# Advance timer by 60 seconds; this should exceed the slow startup
|
|
# warning threshold. The message should be updated, but the status
|
|
# should remain upscaling/autoscaling
|
|
timer.advance(60)
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=6,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 3, None),
|
|
(ReplicaState.STARTING, 3, None),
|
|
],
|
|
)
|
|
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPSCALING
|
|
assert ds.curr_status_info.status_trigger == DeploymentStatusTrigger.AUTOSCALING
|
|
|
|
if target_startup_status == ReplicaStartupStatus.PENDING_INITIALIZATION:
|
|
expected_message = (
|
|
"Deployment 'test_deployment' in application 'test_app' has 3 replicas "
|
|
f"that have taken more than {SLOW_STARTUP_WARNING_S}s to initialize.\n"
|
|
"This may be caused by a slow __init__ or reconfigure method."
|
|
)
|
|
elif target_startup_status == ReplicaStartupStatus.PENDING_ALLOCATION:
|
|
expected_message = (
|
|
"Deployment 'test_deployment' in application 'test_app' "
|
|
"has 3 replicas that have taken more than 30s to be scheduled. "
|
|
"This may be due to waiting for the cluster to auto-scale or for "
|
|
"a runtime environment to be installed. "
|
|
"Resources required for each replica: "
|
|
'{"CPU": 0.1}, '
|
|
"total resources available: "
|
|
"{}. "
|
|
"Use `ray status` for more details."
|
|
)
|
|
else:
|
|
raise RuntimeError(f"Got unexpected status: {target_startup_status}")
|
|
|
|
assert expected_message == ds.curr_status_info.message
|
|
|
|
# Now, trigger downscaling attempting to reclaim half (3) of the replicas
|
|
replicas = ds._replicas.get(states=[ReplicaState.RUNNING])
|
|
if RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE:
|
|
handle_metric_report = HandleMetricReport(
|
|
deployment_id=TEST_DEPLOYMENT_ID,
|
|
handle_id="random",
|
|
actor_id="actor_id",
|
|
handle_source=DeploymentHandleSource.UNKNOWN,
|
|
queued_requests=[TimeStampedValue(timer.time() - 0.1, 0)],
|
|
aggregated_queued_requests=0,
|
|
aggregated_metrics={
|
|
RUNNING_REQUESTS_KEY: {
|
|
replica._actor.replica_id.to_full_id_str(): 1
|
|
for replica in replicas
|
|
}
|
|
},
|
|
metrics={
|
|
RUNNING_REQUESTS_KEY: {
|
|
replica._actor.replica_id.to_full_id_str(): [
|
|
TimeStampedValue(timer.time() - 0.1, 1)
|
|
]
|
|
for replica in replicas
|
|
}
|
|
},
|
|
timestamp=timer.time(),
|
|
)
|
|
asm.record_request_metrics_for_handle(handle_metric_report)
|
|
else:
|
|
for replica in replicas:
|
|
replica_metric_report = ReplicaMetricReport(
|
|
replica_id=replica._actor.replica_id,
|
|
aggregated_metrics={RUNNING_REQUESTS_KEY: 1},
|
|
metrics={
|
|
RUNNING_REQUESTS_KEY: [TimeStampedValue(timer.time() - 0.1, 1)]
|
|
},
|
|
timestamp=timer.time(),
|
|
)
|
|
asm.record_request_metrics_for_replica(replica_metric_report)
|
|
|
|
# status=DOWNSCALING, status_trigger=AUTOSCALE
|
|
self.scale(dsm, asm, [TEST_DEPLOYMENT_ID])
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=6,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 3, None),
|
|
(ReplicaState.STOPPING, 3, None),
|
|
],
|
|
)
|
|
|
|
# Assert that no RUNNING replicas are being stopped
|
|
assert running_replicas == ds._replicas.get(states=[ReplicaState.RUNNING])
|
|
|
|
assert ds.curr_status_info.status == DeploymentStatus.DOWNSCALING
|
|
assert ds.curr_status_info.status_trigger == DeploymentStatusTrigger.AUTOSCALING
|
|
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_done_stopping()
|
|
|
|
# status=HEALTHY, status_trigger=UPSCALE/DOWNSCALE
|
|
dsm.update()
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.DOWNSCALE_COMPLETED
|
|
)
|
|
|
|
def test_update_autoscaling_config(self, mock_deployment_state_manager):
|
|
"""Test updating the autoscaling config.
|
|
|
|
1. Deploy deployment with autoscaling limits [0,6] and initial replicas = 3.
|
|
2. It becomes healthy with 3 running replicas.
|
|
3. Update autoscaling config to limits [6,10].
|
|
4. 3 new replicas should be STARTING, and deployment status should be UPDATING.
|
|
5. It becomes healthy with 6 running replicas.
|
|
"""
|
|
|
|
# Create deployment state manager
|
|
create_dsm, timer, _, asm = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
asm: AutoscalingStateManager = asm
|
|
|
|
# Deploy deployment with 3 replicas
|
|
info1, _ = deployment_info(
|
|
autoscaling_config={
|
|
"target_ongoing_requests": 1,
|
|
"min_replicas": 0,
|
|
"max_replicas": 6,
|
|
"initial_replicas": 3,
|
|
"upscale_delay_s": 0,
|
|
"downscale_delay_s": 0,
|
|
},
|
|
version="1",
|
|
)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info1)
|
|
ds: DeploymentState = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Set replicas ready
|
|
dsm.update()
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
# status=HEALTHY, status_trigger=DEPLOY
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.RUNNING, 3, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
# Num ongoing requests = 1, status should remain HEALTHY
|
|
replicas = ds._replicas.get()
|
|
if RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE:
|
|
handle_metric_report = HandleMetricReport(
|
|
deployment_id=TEST_DEPLOYMENT_ID,
|
|
handle_id="random",
|
|
actor_id="actor_id",
|
|
handle_source=DeploymentHandleSource.UNKNOWN,
|
|
queued_requests=[TimeStampedValue(timer.time() - 0.1, 0)],
|
|
aggregated_queued_requests=0,
|
|
aggregated_metrics={
|
|
RUNNING_REQUESTS_KEY: {
|
|
replica._actor.replica_id.to_full_id_str(): 1
|
|
for replica in replicas
|
|
}
|
|
},
|
|
metrics={
|
|
RUNNING_REQUESTS_KEY: {
|
|
replica._actor.replica_id.to_full_id_str(): [
|
|
TimeStampedValue(timer.time() - 0.1, 1)
|
|
]
|
|
for replica in replicas
|
|
}
|
|
},
|
|
timestamp=timer.time(),
|
|
)
|
|
asm.record_request_metrics_for_handle(handle_metric_report)
|
|
else:
|
|
for replica in replicas:
|
|
replica_metric_report = ReplicaMetricReport(
|
|
replica_id=replica._actor.replica_id,
|
|
aggregated_metrics={RUNNING_REQUESTS_KEY: 1},
|
|
metrics={
|
|
RUNNING_REQUESTS_KEY: [TimeStampedValue(timer.time() - 0.1, 1)]
|
|
},
|
|
timestamp=timer.time(),
|
|
)
|
|
asm.record_request_metrics_for_replica(replica_metric_report)
|
|
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.RUNNING, 3, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
# Update autoscaling config
|
|
info2, _ = deployment_info(
|
|
autoscaling_config={
|
|
"target_ongoing_requests": 1,
|
|
"min_replicas": 6,
|
|
"max_replicas": 10,
|
|
"upscale_delay_s": 0,
|
|
"downscale_delay_s": 0,
|
|
},
|
|
version="1",
|
|
)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info2)
|
|
|
|
# 3 new replicas should be starting, status should be UPDATING (not upscaling)
|
|
self.scale(dsm, asm, [TEST_DEPLOYMENT_ID])
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=6,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 3, None),
|
|
(ReplicaState.STARTING, 3, None),
|
|
],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
# Set replicas ready
|
|
dsm.update()
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=6, by_state=[(ReplicaState.RUNNING, 6, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
def test_replicas_fail_during_initial_scale_from_zero(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Test the following case:
|
|
|
|
- An "erroneous" deployment (w/ autoscaling enabled) is deployed
|
|
with initial replicas set to 0. Since no replicas are started,
|
|
no errors have occurred from trying to start the replicas yet.
|
|
- A request is sent, triggering an upscale.
|
|
- The controller tries to start new replicas, but fails because
|
|
of a constructor error.
|
|
|
|
In this case, the deployment should transition to UNHEALTHY and
|
|
stop retrying after a threshold.
|
|
"""
|
|
create_dsm, timer, _, asm = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
asm: AutoscalingStateManager = asm
|
|
|
|
# Deploy deployment with 1 initial replica
|
|
info, _ = deployment_info(
|
|
autoscaling_config={
|
|
"target_ongoing_requests": 1,
|
|
"min_replicas": 0,
|
|
"max_replicas": 2,
|
|
"initial_replicas": 0,
|
|
"upscale_delay_s": 0,
|
|
"downscale_delay_s": 0,
|
|
"metrics_interval_s": 100,
|
|
"look_back_period_s": 200,
|
|
}
|
|
)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds: DeploymentState = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Send request metrics to controller to make the deployment upscale
|
|
handle_metric_report = HandleMetricReport(
|
|
deployment_id=TEST_DEPLOYMENT_ID,
|
|
handle_id="random",
|
|
actor_id="actor_id",
|
|
handle_source=DeploymentHandleSource.UNKNOWN,
|
|
queued_requests=[TimeStampedValue(timer.time() - 0.1, 1)],
|
|
aggregated_queued_requests=1,
|
|
aggregated_metrics={},
|
|
metrics={},
|
|
timestamp=timer.time(),
|
|
)
|
|
asm.record_request_metrics_for_handle(handle_metric_report)
|
|
self.scale(dsm, asm, [TEST_DEPLOYMENT_ID])
|
|
|
|
# The controller should try to start a new replica. If that replica repeatedly
|
|
# fails to start, the deployment should transition to UNHEALTHY and NOT retry
|
|
# replicas anymore
|
|
for i in range(10):
|
|
dsm.update()
|
|
if i < 3:
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPSCALING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.AUTOSCALING
|
|
)
|
|
# Set replica failed to start
|
|
replica = ds._replicas.get()[0]
|
|
replica._actor.set_failed_to_start()
|
|
dsm.update()
|
|
if i < 2:
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[
|
|
(ReplicaState.STOPPING, 1, None),
|
|
(ReplicaState.STARTING, 1, None),
|
|
],
|
|
)
|
|
else:
|
|
check_counts(
|
|
ds, total=1, by_state=[(ReplicaState.STOPPING, 1, None)]
|
|
)
|
|
|
|
# Set replica finished stopping
|
|
replica._actor.set_done_stopping()
|
|
else:
|
|
check_counts(ds, total=0)
|
|
assert ds.curr_status_info.status == DeploymentStatus.UNHEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.REPLICA_STARTUP_FAILED
|
|
)
|
|
|
|
def test_replicas_fail_during_subsequent_scale_from_zero(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Test the following case:
|
|
|
|
- An autoscaling deployment is deployed and it reaches HEALTHY
|
|
with a non-zero number of replicas.
|
|
- After a period of no traffic, the deployment scales down to 0.
|
|
- New traffic is sent, triggering an upscale.
|
|
- The controller tries to start new replicas, but for some
|
|
reason some replicas fail to start because of transient errors
|
|
|
|
In this case, the deployment should transition to UNHEALTHY and
|
|
keep retrying, since at least one replica of this version has
|
|
successfully started in the past, meaning we don't know if it is
|
|
an unrecoverable user code error.
|
|
"""
|
|
create_dsm, timer, _, asm = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
asm: AutoscalingStateManager = asm
|
|
|
|
# Deploy deployment with 1 initial replica
|
|
info, _ = deployment_info(
|
|
autoscaling_config={
|
|
"target_ongoing_requests": 1,
|
|
"min_replicas": 0,
|
|
"max_replicas": 2,
|
|
"initial_replicas": 1,
|
|
"upscale_delay_s": 0,
|
|
"downscale_delay_s": 0,
|
|
"metrics_interval_s": 100,
|
|
"look_back_period_s": 200,
|
|
}
|
|
)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds: DeploymentState = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Expected: status=UPDATING, status_trigger=CONFIG_UPDATED_STARTED
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
# Set replicas ready and check statuses
|
|
# Expected: status=HEALTHY, status_trigger=CONFIG_UPDATED_COMPLETED
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED
|
|
)
|
|
|
|
# There are no requests, so the deployment should be downscaled to zero.
|
|
self.scale(dsm, asm, [TEST_DEPLOYMENT_ID])
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STOPPING, 1, None)])
|
|
ds._replicas.get()[0]._actor.set_done_stopping()
|
|
dsm.update()
|
|
check_counts(ds, total=0)
|
|
|
|
# Send request metrics to controller to make the deployment upscale
|
|
handle_metric_report = HandleMetricReport(
|
|
deployment_id=TEST_DEPLOYMENT_ID,
|
|
handle_id="random",
|
|
actor_id="actor_id",
|
|
handle_source=DeploymentHandleSource.UNKNOWN,
|
|
queued_requests=[TimeStampedValue(timer.time() - 0.1, 1)],
|
|
aggregated_queued_requests=1,
|
|
aggregated_metrics={},
|
|
metrics={},
|
|
timestamp=timer.time(),
|
|
)
|
|
asm.record_request_metrics_for_handle(handle_metric_report)
|
|
self.scale(dsm, asm, [TEST_DEPLOYMENT_ID])
|
|
# The controller should try to start a new replica. If that replica repeatedly
|
|
# fails to start, the deployment should transition to UNHEALTHY. Meanwhile
|
|
# the controller should continue retrying after 3 times.
|
|
for i in range(10):
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, None)])
|
|
if i < 3:
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPSCALING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.AUTOSCALING
|
|
)
|
|
else:
|
|
assert ds.curr_status_info.status == DeploymentStatus.UNHEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.REPLICA_STARTUP_FAILED
|
|
)
|
|
|
|
# Set replica failed to start
|
|
replica = ds._replicas.get()[0]
|
|
replica._actor.set_failed_to_start()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[
|
|
(ReplicaState.STOPPING, 1, None),
|
|
(ReplicaState.STARTING, 1, None),
|
|
],
|
|
)
|
|
|
|
# Set replica finished stopping
|
|
replica._actor.set_done_stopping()
|
|
|
|
@pytest.mark.skipif(
|
|
not RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE,
|
|
reason="Testing handle metrics behavior.",
|
|
)
|
|
def test_handle_metrics_timeout(self, mock_deployment_state_manager):
|
|
create_dsm, timer, _, asm = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
asm: AutoscalingStateManager = asm
|
|
|
|
# Deploy, start with 1 replica
|
|
info, _ = deployment_info(
|
|
autoscaling_config={
|
|
"target_ongoing_requests": 1,
|
|
"min_replicas": 0,
|
|
"max_replicas": 6,
|
|
"initial_replicas": 1,
|
|
"upscale_delay_s": 0,
|
|
"downscale_delay_s": 0,
|
|
}
|
|
)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds: DeploymentState = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
asm.drop_stale_handle_metrics(dsm.get_alive_replica_actor_ids())
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, None)])
|
|
|
|
# Record 2 requests/replica -> trigger upscale
|
|
handle_metric_report = HandleMetricReport(
|
|
deployment_id=TEST_DEPLOYMENT_ID,
|
|
handle_id="random",
|
|
actor_id="actor_id",
|
|
handle_source=DeploymentHandleSource.UNKNOWN,
|
|
queued_requests=[TimeStampedValue(timer.time() - 0.1, 0)],
|
|
aggregated_queued_requests=0,
|
|
aggregated_metrics={
|
|
RUNNING_REQUESTS_KEY: {
|
|
ds._replicas.get()[0]._actor.replica_id.to_full_id_str(): 2
|
|
}
|
|
},
|
|
metrics={
|
|
RUNNING_REQUESTS_KEY: {
|
|
ds._replicas.get()[0]._actor.replica_id.to_full_id_str(): [
|
|
TimeStampedValue(timer.time() - 0.1, 2)
|
|
]
|
|
}
|
|
},
|
|
timestamp=timer.time(),
|
|
)
|
|
asm.record_request_metrics_for_handle(handle_metric_report)
|
|
asm.drop_stale_handle_metrics(dsm.get_alive_replica_actor_ids())
|
|
self.scale(dsm, asm, [TEST_DEPLOYMENT_ID])
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 1, None),
|
|
(ReplicaState.STARTING, 1, None),
|
|
],
|
|
)
|
|
assert asm.get_total_num_requests_for_deployment(TEST_DEPLOYMENT_ID) == 2
|
|
ds._replicas.get([ReplicaState.STARTING])[0]._actor.set_ready()
|
|
asm.drop_stale_handle_metrics(dsm.get_alive_replica_actor_ids())
|
|
self.scale(dsm, asm, [TEST_DEPLOYMENT_ID])
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, None)])
|
|
assert asm.get_total_num_requests_for_deployment(TEST_DEPLOYMENT_ID) == 2
|
|
|
|
# Simulate handle was on an actor that died. 10 seconds later
|
|
# the handle fails to push metrics
|
|
timer.advance(10)
|
|
asm.drop_stale_handle_metrics(dsm.get_alive_replica_actor_ids())
|
|
self.scale(dsm, asm, [TEST_DEPLOYMENT_ID])
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, None)])
|
|
assert asm.get_total_num_requests_for_deployment(TEST_DEPLOYMENT_ID) == 2
|
|
|
|
# Another 10 seconds later handle still fails to push metrics. At
|
|
# this point the data from the handle should be invalidated. As a
|
|
# result, the replicas should scale back down to 0.
|
|
timer.advance(10)
|
|
asm.drop_stale_handle_metrics(dsm.get_alive_replica_actor_ids())
|
|
# The first update will trigger the first stage of downscaling to 1
|
|
self.scale(dsm, asm, [TEST_DEPLOYMENT_ID])
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[
|
|
(ReplicaState.STOPPING, 1, None),
|
|
(ReplicaState.RUNNING, 1, None),
|
|
],
|
|
)
|
|
# The second update will trigger the second stage of downscaling from 1 to 0
|
|
self.scale(dsm, asm, [TEST_DEPLOYMENT_ID])
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STOPPING, 2, None)])
|
|
assert asm.get_total_num_requests_for_deployment(TEST_DEPLOYMENT_ID) == 0
|
|
|
|
@pytest.mark.skipif(
|
|
not RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE,
|
|
reason="Testing handle metrics behavior.",
|
|
)
|
|
def test_handle_metrics_on_dead_serve_actor(self, mock_deployment_state_manager):
|
|
"""Metrics for handles on dead serve actors should be dropped."""
|
|
|
|
create_dsm, timer, _, asm = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
asm: AutoscalingStateManager = asm
|
|
d_id1 = DeploymentID("d1", "app")
|
|
d_id2 = DeploymentID("d2", "app")
|
|
|
|
# Deploy, start with 1 replica
|
|
info1, _ = deployment_info(
|
|
autoscaling_config={
|
|
"target_ongoing_requests": 1,
|
|
"min_replicas": 0,
|
|
"max_replicas": 6,
|
|
"initial_replicas": 1,
|
|
"upscale_delay_s": 0,
|
|
"downscale_delay_s": 0,
|
|
},
|
|
)
|
|
info2, _ = deployment_info(health_check_period_s=0.1)
|
|
dsm.deploy(d_id1, info1)
|
|
dsm.deploy(d_id2, info2)
|
|
|
|
ds1: DeploymentState = dsm._deployment_states[d_id1]
|
|
ds2: DeploymentState = dsm._deployment_states[d_id2]
|
|
|
|
# One replica each
|
|
asm.drop_stale_handle_metrics(dsm.get_alive_replica_actor_ids())
|
|
dsm.update()
|
|
ds1._replicas.get()[0]._actor.set_ready()
|
|
ds2._replicas.get()[0]._actor.set_ready()
|
|
ds2._replicas.get()[0]._actor.set_actor_id("d2_replica_actor_id")
|
|
asm.drop_stale_handle_metrics(dsm.get_alive_replica_actor_ids())
|
|
dsm.update()
|
|
check_counts(ds1, total=1, by_state=[(ReplicaState.RUNNING, 1, None)])
|
|
check_counts(ds2, total=1, by_state=[(ReplicaState.RUNNING, 1, None)])
|
|
|
|
# Record 2 requests/replica (sent from d2 replica) -> trigger upscale
|
|
handle_metric_report = HandleMetricReport(
|
|
deployment_id=d_id1,
|
|
handle_id="random",
|
|
actor_id="d2_replica_actor_id",
|
|
handle_source=DeploymentHandleSource.REPLICA,
|
|
queued_requests=[TimeStampedValue(timer.time() - 0.1, 0)],
|
|
aggregated_queued_requests=0,
|
|
aggregated_metrics={
|
|
RUNNING_REQUESTS_KEY: {
|
|
ds1._replicas.get()[0]._actor.replica_id.to_full_id_str(): 2
|
|
}
|
|
},
|
|
metrics={
|
|
RUNNING_REQUESTS_KEY: {
|
|
ds1._replicas.get()[0]._actor.replica_id.to_full_id_str(): [
|
|
TimeStampedValue(timer.time() - 0.1, 2)
|
|
]
|
|
}
|
|
},
|
|
timestamp=timer.time(),
|
|
)
|
|
asm.record_request_metrics_for_handle(handle_metric_report)
|
|
asm.drop_stale_handle_metrics(dsm.get_alive_replica_actor_ids())
|
|
self.scale(dsm, asm, [d_id1, d_id2])
|
|
dsm.update()
|
|
check_counts(
|
|
ds1,
|
|
total=2,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 1, None),
|
|
(ReplicaState.STARTING, 1, None),
|
|
],
|
|
)
|
|
assert asm.get_total_num_requests_for_deployment(d_id1) == 2
|
|
ds1._replicas.get([ReplicaState.STARTING])[0]._actor.set_ready()
|
|
asm.drop_stale_handle_metrics(dsm.get_alive_replica_actor_ids())
|
|
self.scale(dsm, asm, [d_id1, d_id2])
|
|
dsm.update()
|
|
check_counts(ds1, total=2, by_state=[(ReplicaState.RUNNING, 2, None)])
|
|
assert asm.get_total_num_requests_for_deployment(d_id1) == 2
|
|
|
|
# d2 replica died
|
|
ds2._replicas.get()[0]._actor.set_unhealthy()
|
|
asm.drop_stale_handle_metrics(dsm.get_alive_replica_actor_ids())
|
|
self.scale(dsm, asm, [d_id1, d_id2])
|
|
dsm.update()
|
|
check_counts(
|
|
ds2,
|
|
total=2,
|
|
by_state=[
|
|
(ReplicaState.STARTING, 1, None),
|
|
(ReplicaState.STOPPING, 1, None),
|
|
],
|
|
)
|
|
ds2._replicas.get(states=[ReplicaState.STOPPING])[0]._actor.set_done_stopping()
|
|
asm.drop_stale_handle_metrics(dsm.get_alive_replica_actor_ids())
|
|
self.scale(dsm, asm, [d_id1, d_id2])
|
|
dsm.update()
|
|
check_counts(ds2, total=1, by_state=[(ReplicaState.STARTING, 1, None)])
|
|
|
|
# Now that the d2 replica is dead, its metrics should be dropped.
|
|
# Consequently d1 should scale down to 0 replicas
|
|
asm.drop_stale_handle_metrics(dsm.get_alive_replica_actor_ids())
|
|
self.scale(dsm, asm, [d_id1, d_id2])
|
|
dsm.update()
|
|
# Due to two-stage downscaling one of the replicas will still be running
|
|
check_counts(
|
|
ds1,
|
|
total=2,
|
|
by_state=[
|
|
(ReplicaState.STOPPING, 1, None),
|
|
(ReplicaState.RUNNING, 1, None),
|
|
],
|
|
)
|
|
# Trigger the second stage of downscaling
|
|
self.scale(dsm, asm, [d_id1, d_id2])
|
|
dsm.update()
|
|
check_counts(ds1, total=2, by_state=[(ReplicaState.STOPPING, 2, None)])
|
|
|
|
def test_autoscaling_timestamps(self, mock_deployment_state_manager):
|
|
"""Test that last_scale_up_time and last_scale_down_time are properly tracked.
|
|
|
|
This test verifies that:
|
|
1. Timestamps are None initially
|
|
2. last_scale_up_time is set after a scale-up event
|
|
3. last_scale_down_time is set after a scale-down event
|
|
4. Timestamps are available in AutoscalingContext
|
|
"""
|
|
# Create deployment state manager
|
|
create_dsm, timer, _, asm = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
asm: AutoscalingStateManager = asm
|
|
|
|
# Deploy deployment with autoscaling
|
|
info, _ = deployment_info(
|
|
autoscaling_config={
|
|
"target_ongoing_requests": 1,
|
|
"min_replicas": 1,
|
|
"max_replicas": 5,
|
|
"initial_replicas": 2,
|
|
"upscale_delay_s": 0,
|
|
"downscale_delay_s": 0,
|
|
"metrics_interval_s": 100,
|
|
"look_back_period_s": 200,
|
|
}
|
|
)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds: DeploymentState = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Make replicas ready
|
|
dsm.update()
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
dsm.update()
|
|
|
|
# Get autoscaling state
|
|
app_state = asm._app_autoscaling_states[TEST_DEPLOYMENT_ID.app_name]
|
|
dep_autoscaling_state = app_state._deployment_autoscaling_states[
|
|
TEST_DEPLOYMENT_ID
|
|
]
|
|
|
|
# Initially, timestamps should be None
|
|
ctx = dep_autoscaling_state.get_autoscaling_context(2)
|
|
assert ctx.last_scale_up_time is None
|
|
assert ctx.last_scale_down_time is None
|
|
|
|
# Trigger scale-up by setting high request metrics
|
|
replicas = ds._replicas.get()
|
|
req_per_replica = 5 # High load to trigger scale-up
|
|
|
|
if RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE:
|
|
handle_metric_report = HandleMetricReport(
|
|
deployment_id=TEST_DEPLOYMENT_ID,
|
|
handle_id="test_handle",
|
|
actor_id="test_actor",
|
|
handle_source=DeploymentHandleSource.UNKNOWN,
|
|
queued_requests=[TimeStampedValue(timer.time() - 0.1, 0)],
|
|
aggregated_queued_requests=0,
|
|
aggregated_metrics={
|
|
RUNNING_REQUESTS_KEY: {
|
|
replica._actor.replica_id.to_full_id_str(): req_per_replica
|
|
for replica in replicas
|
|
}
|
|
},
|
|
metrics={
|
|
RUNNING_REQUESTS_KEY: {
|
|
replica._actor.replica_id.to_full_id_str(): [
|
|
TimeStampedValue(timer.time() - 0.1, req_per_replica)
|
|
]
|
|
for replica in replicas
|
|
}
|
|
},
|
|
timestamp=timer.time(),
|
|
)
|
|
asm.record_request_metrics_for_handle(handle_metric_report)
|
|
else:
|
|
for replica in replicas:
|
|
replica_metric_report = ReplicaMetricReport(
|
|
replica_id=replica._actor.replica_id,
|
|
aggregated_metrics={RUNNING_REQUESTS_KEY: req_per_replica},
|
|
metrics={
|
|
RUNNING_REQUESTS_KEY: [
|
|
TimeStampedValue(timer.time() - 0.1, req_per_replica)
|
|
]
|
|
},
|
|
timestamp=timer.time(),
|
|
)
|
|
asm.record_request_metrics_for_replica(replica_metric_report)
|
|
|
|
# Record time before scale-up
|
|
time_before_scale_up = timer.time()
|
|
|
|
# Trigger autoscaling decision
|
|
self.scale(dsm, asm, [TEST_DEPLOYMENT_ID])
|
|
|
|
# After scale-up, last_scale_up_time should be set and greater than the time before
|
|
ctx_after_scale_up = dep_autoscaling_state.get_autoscaling_context(5)
|
|
assert ctx_after_scale_up.last_scale_up_time is not None
|
|
assert ctx_after_scale_up.last_scale_up_time >= time_before_scale_up
|
|
assert ctx_after_scale_up.last_scale_down_time is None
|
|
|
|
scale_up_time = ctx_after_scale_up.last_scale_up_time
|
|
|
|
# Advance timer to simulate time passing
|
|
timer.advance(10)
|
|
|
|
# Set replicas ready
|
|
dsm.update()
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
dsm.update()
|
|
|
|
# Now trigger scale-down by setting low request metrics
|
|
replicas = ds._replicas.get()
|
|
req_per_replica = 0 # No load to trigger scale-down
|
|
|
|
if RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE:
|
|
handle_metric_report = HandleMetricReport(
|
|
deployment_id=TEST_DEPLOYMENT_ID,
|
|
handle_id="test_handle",
|
|
actor_id="test_actor",
|
|
handle_source=DeploymentHandleSource.UNKNOWN,
|
|
queued_requests=[TimeStampedValue(timer.time() - 0.1, 0)],
|
|
aggregated_queued_requests=0,
|
|
aggregated_metrics={
|
|
RUNNING_REQUESTS_KEY: {
|
|
replica._actor.replica_id.to_full_id_str(): req_per_replica
|
|
for replica in replicas
|
|
}
|
|
},
|
|
metrics={
|
|
RUNNING_REQUESTS_KEY: {
|
|
replica._actor.replica_id.to_full_id_str(): [
|
|
TimeStampedValue(timer.time() - 0.1, req_per_replica)
|
|
]
|
|
for replica in replicas
|
|
}
|
|
},
|
|
timestamp=timer.time(),
|
|
)
|
|
asm.record_request_metrics_for_handle(handle_metric_report)
|
|
else:
|
|
for replica in replicas:
|
|
replica_metric_report = ReplicaMetricReport(
|
|
replica_id=replica._actor.replica_id,
|
|
aggregated_metrics={RUNNING_REQUESTS_KEY: req_per_replica},
|
|
metrics={
|
|
RUNNING_REQUESTS_KEY: [
|
|
TimeStampedValue(timer.time() - 0.1, req_per_replica)
|
|
]
|
|
},
|
|
timestamp=timer.time(),
|
|
)
|
|
asm.record_request_metrics_for_replica(replica_metric_report)
|
|
|
|
# Record time before scale-down
|
|
time_before_scale_down = timer.time()
|
|
|
|
# Trigger autoscaling decision for scale-down
|
|
self.scale(dsm, asm, [TEST_DEPLOYMENT_ID])
|
|
|
|
# After scale-down, last_scale_down_time should be set and greater than the time before
|
|
ctx_after_scale_down = dep_autoscaling_state.get_autoscaling_context(1)
|
|
assert (
|
|
ctx_after_scale_down.last_scale_up_time == scale_up_time
|
|
) # Should remain unchanged
|
|
assert ctx_after_scale_down.last_scale_down_time is not None
|
|
assert ctx_after_scale_down.last_scale_down_time >= time_before_scale_down
|
|
assert ctx_after_scale_down.last_scale_down_time > scale_up_time
|
|
|
|
|
|
class TestTargetCapacity:
|
|
"""
|
|
Tests related to the `target_capacity` field that adjusts the target num_replicas.
|
|
"""
|
|
|
|
def update_target_capacity(
|
|
self,
|
|
deployment_state: DeploymentState,
|
|
curr_deployment_info: DeploymentInfo,
|
|
target_capacity: Optional[float],
|
|
target_capacity_direction: Optional[TargetCapacityDirection],
|
|
):
|
|
new_deployment_info = deepcopy(curr_deployment_info)
|
|
new_deployment_info.set_target_capacity(
|
|
new_target_capacity=target_capacity,
|
|
new_target_capacity_direction=target_capacity_direction,
|
|
)
|
|
updating = deployment_state.deploy(new_deployment_info)
|
|
assert updating
|
|
|
|
@pytest.mark.parametrize(
|
|
"num_replicas,target_capacity,expected_output",
|
|
[
|
|
(10, None, 10),
|
|
(10, 100, 10),
|
|
(10, 99, 10),
|
|
(10, 50, 5),
|
|
(10, 1, 1),
|
|
(10, 0, 0),
|
|
(10, 25, 3),
|
|
(1, None, 1),
|
|
(1, 100, 1),
|
|
(1, 1, 1),
|
|
(1, 0, 0),
|
|
(1, 23, 1),
|
|
(3, 20, 1),
|
|
(3, 40, 1),
|
|
(3, 70, 2),
|
|
(3, 90, 3),
|
|
(0, None, 0),
|
|
(0, 1, 0),
|
|
(0, 99, 0),
|
|
(0, 100, 0),
|
|
],
|
|
)
|
|
def test_get_capacity_adjusted_num_replicas(
|
|
self, num_replicas: int, target_capacity: Optional[float], expected_output: int
|
|
):
|
|
result = get_capacity_adjusted_num_replicas(num_replicas, target_capacity)
|
|
assert isinstance(result, int)
|
|
assert result == expected_output
|
|
|
|
def test_initial_deploy(self, mock_deployment_state_manager):
|
|
"""Deploy with target_capacity set, should apply immediately."""
|
|
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
b_info_1, _ = deployment_info(num_replicas=2)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_1,
|
|
target_capacity=50,
|
|
target_capacity_direction=TargetCapacityDirection.UP,
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
dsm.update()
|
|
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_target_capacity_100_no_effect(self, mock_deployment_state_manager):
|
|
"""
|
|
Deploy with no target_capacity set, then set to 100. Should take no effect.
|
|
|
|
Then go back to no target_capacity, should still have no effect.
|
|
"""
|
|
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
code_version = "arbitrary_version"
|
|
b_info_1, _ = deployment_info(num_replicas=2, version=code_version)
|
|
# Initially deploy with no target_capacity set.
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Now update target_capacity to 100, should have no effect.
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_1,
|
|
target_capacity=100,
|
|
target_capacity_direction=TargetCapacityDirection.UP,
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Now update target_capacity back to None, should have no effect.
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_1,
|
|
target_capacity=None,
|
|
target_capacity_direction=None,
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_target_capacity_0(self, mock_deployment_state_manager):
|
|
"""Deploy with target_capacity set to 0. Should have no replicas."""
|
|
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
b_info_1, _ = deployment_info(num_replicas=100)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_1,
|
|
target_capacity=0,
|
|
target_capacity_direction=TargetCapacityDirection.UP,
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=0)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_reduce_target_capacity(self, mock_deployment_state_manager):
|
|
"""
|
|
Deploy with target capacity set to 100, then reduce to 50, then reduce to 0.
|
|
"""
|
|
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
code_version = "arbitrary_version"
|
|
b_info_1, _ = deployment_info(num_replicas=10, version=code_version)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Start with target_capacity 100.
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_1,
|
|
target_capacity=100,
|
|
target_capacity_direction=TargetCapacityDirection.UP,
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=10, by_state=[(ReplicaState.STARTING, 10, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=10, by_state=[(ReplicaState.RUNNING, 10, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Reduce target_capacity to 50, half the replicas should be stopped.
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_1,
|
|
target_capacity=50,
|
|
target_capacity_direction=TargetCapacityDirection.DOWN,
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=10,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 5, None),
|
|
(ReplicaState.STOPPING, 5, None),
|
|
],
|
|
)
|
|
|
|
assert ds.curr_status_info.status == DeploymentStatus.DOWNSCALING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
for replica in ds._replicas.get([ReplicaState.STOPPING]):
|
|
replica._actor.set_done_stopping()
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=5, by_state=[(ReplicaState.RUNNING, 5, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Reduce target_capacity to 1, all but 1 of the replicas should be stopped.
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_1,
|
|
target_capacity=1,
|
|
target_capacity_direction=TargetCapacityDirection.DOWN,
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=5,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 1, None),
|
|
(ReplicaState.STOPPING, 4, None),
|
|
],
|
|
)
|
|
|
|
assert ds.curr_status_info.status == DeploymentStatus.DOWNSCALING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
for replica in ds._replicas.get([ReplicaState.STOPPING]):
|
|
replica._actor.set_done_stopping()
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, None)])
|
|
|
|
# Reduce target_capacity to 0, all replicas should be stopped.
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_1,
|
|
target_capacity=0,
|
|
target_capacity_direction=TargetCapacityDirection.DOWN,
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STOPPING, 1, None)])
|
|
|
|
assert ds.curr_status_info.status == DeploymentStatus.DOWNSCALING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
for replica in ds._replicas.get([ReplicaState.STOPPING]):
|
|
replica._actor.set_done_stopping()
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=0)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_increase_target_capacity(self, mock_deployment_state_manager):
|
|
"""
|
|
Deploy with target_capacity set to 0, then increase to 1, then increase to 50,
|
|
then increase to 100.
|
|
"""
|
|
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
code_version = "arbitrary_version"
|
|
b_info_1, _ = deployment_info(num_replicas=10, version=code_version)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Start with target_capacity set to 0, should have no replicas start up.
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_1,
|
|
target_capacity=0,
|
|
target_capacity_direction=TargetCapacityDirection.UP,
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=0)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Increase target_capacity to 1, should have 1 replica start up.
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_1,
|
|
target_capacity=1,
|
|
target_capacity_direction=TargetCapacityDirection.UP,
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPSCALING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Set target_capacity to 50, should have 4 more replicas start up.
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_1,
|
|
target_capacity=50,
|
|
target_capacity_direction=TargetCapacityDirection.UP,
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=5,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 1, None),
|
|
(ReplicaState.STARTING, 4, None),
|
|
],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPSCALING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=5, by_state=[(ReplicaState.RUNNING, 5, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Set target_capacity to 100, should have 5 more replicas start up.
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_1,
|
|
target_capacity=100,
|
|
target_capacity_direction=TargetCapacityDirection.UP,
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=10,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 5, None),
|
|
(ReplicaState.STARTING, 5, None),
|
|
],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPSCALING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=10, by_state=[(ReplicaState.RUNNING, 10, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_clear_target_capacity(self, mock_deployment_state_manager):
|
|
"""Deploy with target_capacity set, should apply immediately."""
|
|
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
code_version = "arbitrary_version"
|
|
b_info_1, _ = deployment_info(num_replicas=10, version=code_version)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Start with target_capacity set to 50, should have 5 replicas start up.
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_1,
|
|
target_capacity=50,
|
|
target_capacity_direction=TargetCapacityDirection.UP,
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=5, by_state=[(ReplicaState.STARTING, 5, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=5, by_state=[(ReplicaState.RUNNING, 5, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Clear target_capacity, should have 5 more replicas start up.
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_1,
|
|
target_capacity=None,
|
|
target_capacity_direction=None,
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=10,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 5, None),
|
|
(ReplicaState.STARTING, 5, None),
|
|
],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPSCALING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=10, by_state=[(ReplicaState.RUNNING, 10, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_target_num_replicas_is_zero(self, mock_deployment_state_manager):
|
|
"""
|
|
If the target `num_replicas` is zero (i.e., scale-to-zero is enabled and it's
|
|
autoscaled down), then replicas should remain at zero regardless of
|
|
target_capacity.
|
|
"""
|
|
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
# Set num_replicas to 0.
|
|
code_version = "arbitrary_version"
|
|
b_info_1, _ = deployment_info(num_replicas=0, version=code_version)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Start with target_capacity of 50.
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_1,
|
|
target_capacity=50,
|
|
target_capacity_direction=TargetCapacityDirection.UP,
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=0)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=0)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=0)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Regardless of target_capacity, should stay at 0 replicas.
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_1,
|
|
target_capacity=None,
|
|
target_capacity_direction=None,
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=0)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_1,
|
|
target_capacity=0,
|
|
target_capacity_direction=TargetCapacityDirection.UP,
|
|
)
|
|
dsm.update()
|
|
check_counts(ds, total=0)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_1,
|
|
target_capacity=50,
|
|
target_capacity_direction=TargetCapacityDirection.UP,
|
|
)
|
|
dsm.update()
|
|
check_counts(ds, total=0)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_1,
|
|
target_capacity=100,
|
|
target_capacity_direction=TargetCapacityDirection.UP,
|
|
)
|
|
check_counts(ds, total=0)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Now scale back up to 1 replica.
|
|
b_info_2, _ = deployment_info(num_replicas=1, version=code_version)
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_2,
|
|
target_capacity=100,
|
|
target_capacity_direction=TargetCapacityDirection.UP,
|
|
)
|
|
|
|
ds._target_state.num_replicas = 1
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPSCALING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# TODO(edoakes): this test should be updated to go through the autoscaling policy.
|
|
def test_target_capacity_with_changing_num_replicas(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""
|
|
Test that target_capacity works with changing num_replicas (emulating
|
|
autoscaling).
|
|
"""
|
|
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
# Set num_replicas to 0.
|
|
code_version = "arbitrary_version"
|
|
b_info_1, _ = deployment_info(num_replicas=2, version=code_version)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Start with target_capacity set to 0, should have 0 replica start up
|
|
# regardless of the autoscaling decision.
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_1,
|
|
target_capacity=0,
|
|
target_capacity_direction=TargetCapacityDirection.UP,
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=0)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_1,
|
|
target_capacity=1,
|
|
target_capacity_direction=TargetCapacityDirection.UP,
|
|
)
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPSCALING
|
|
# TODO (shrekris): once this test uses the autoscaling logic, this
|
|
# status trigger should be DeploymentStatusTrigger.AUTOSCALING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Increase the target number of replicas. Should still only have 1.
|
|
b_info_2, _ = deployment_info(num_replicas=10, version=code_version)
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_2,
|
|
target_capacity=1,
|
|
target_capacity_direction=TargetCapacityDirection.UP,
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Increase target_capacity to 50, should have 4 more replicas start up.
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_2,
|
|
target_capacity=50,
|
|
target_capacity_direction=TargetCapacityDirection.UP,
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=5,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 1, None),
|
|
(ReplicaState.STARTING, 4, None),
|
|
],
|
|
)
|
|
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPSCALING
|
|
# TODO (shrekris): once this test uses the autoscaling logic, this
|
|
# status trigger should be DeploymentStatusTrigger.AUTOSCALING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=5, by_state=[(ReplicaState.RUNNING, 5, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Reduce num_replicas and remove target_capacity, should stay the same.
|
|
b_info_3, _ = deployment_info(num_replicas=5, version=code_version)
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_3,
|
|
target_capacity=None,
|
|
target_capacity_direction=None,
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=5,
|
|
by_state=[(ReplicaState.RUNNING, 5, None)],
|
|
)
|
|
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.UPSCALE_COMPLETED
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=5, by_state=[(ReplicaState.RUNNING, 5, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Set target_capacity to 50 and increase num_replicas to 6, should have 2 stop.
|
|
b_info_4, _ = deployment_info(num_replicas=6, version=code_version)
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_4,
|
|
target_capacity=50,
|
|
target_capacity_direction=TargetCapacityDirection.UP,
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=5,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 3, None),
|
|
(ReplicaState.STOPPING, 2, None),
|
|
],
|
|
)
|
|
|
|
assert ds.curr_status_info.status == DeploymentStatus.DOWNSCALING
|
|
# TODO (shrekris): once this test uses the autoscaling logic, this
|
|
# status trigger should be DeploymentStatusTrigger.AUTOSCALING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
for replica in ds._replicas.get([ReplicaState.STOPPING]):
|
|
replica._actor.set_done_stopping()
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.RUNNING, 3, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Unset target capacity, should scale back up to 6.
|
|
self.update_target_capacity(
|
|
ds,
|
|
b_info_4,
|
|
target_capacity=None,
|
|
target_capacity_direction=None,
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=6,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 3, None),
|
|
(ReplicaState.STARTING, 3, None),
|
|
],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPSCALING
|
|
# TODO (shrekris): once this test uses the autoscaling logic, this
|
|
# status trigger should be DeploymentStatusTrigger.AUTOSCALING
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.CONFIG_UPDATE_STARTED
|
|
)
|
|
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=6, by_state=[(ReplicaState.RUNNING, 6, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
|
|
class TestStopReplicasOnDrainingNodes:
|
|
"""Test the behavior when draining node(s)."""
|
|
|
|
def test_draining_start_then_stop_replica(self, mock_deployment_state_manager):
|
|
"""A new replica should be started before stopping old replica.
|
|
|
|
If the new replica starts quickly, the replica on the draining
|
|
node should then be gracefully stopped after the new replica
|
|
transitions to RUNNING.
|
|
"""
|
|
|
|
create_dsm, timer, cluster_node_info_cache, _ = mock_deployment_state_manager
|
|
node_1 = NodeID.from_random().hex()
|
|
node_2 = NodeID.from_random().hex()
|
|
cluster_node_info_cache.add_node(node_1)
|
|
cluster_node_info_cache.add_node(node_2)
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
timer.reset(0)
|
|
|
|
b_info_1, v1 = deployment_info(
|
|
num_replicas=2, graceful_shutdown_timeout_s=20, version="1"
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, v1)])
|
|
|
|
# Drain node-2 with deadline 60. Since the replicas are still
|
|
# starting and we don't know the actor node id yet nothing happens
|
|
cluster_node_info_cache.draining_nodes = {node_2: 60 * 1000}
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, v1)])
|
|
|
|
one_replica, another_replica = ds._replicas.get()
|
|
|
|
one_replica._actor.set_node_id(node_1)
|
|
one_replica._actor.set_ready()
|
|
|
|
another_replica._actor.set_node_id(node_2)
|
|
another_replica._actor.set_ready()
|
|
|
|
# Try to start a new replica before initiating the graceful stop
|
|
# process for the replica on the draining node
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=3,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 1, v1),
|
|
(ReplicaState.PENDING_MIGRATION, 1, v1),
|
|
(ReplicaState.STARTING, 1, v1),
|
|
],
|
|
)
|
|
|
|
# 5 seconds later, the replica hasn't started yet. The replica on
|
|
# the draining node should not start graceful termination yet.
|
|
timer.advance(5)
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=3,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 1, v1),
|
|
(ReplicaState.PENDING_MIGRATION, 1, v1),
|
|
(ReplicaState.STARTING, 1, v1),
|
|
],
|
|
)
|
|
|
|
# Simulate it took 5 more seconds for the new replica to be started
|
|
timer.advance(5)
|
|
ds._replicas.get([ReplicaState.STARTING])[0]._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=3,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 2, v1),
|
|
(ReplicaState.STOPPING, 1, v1),
|
|
],
|
|
)
|
|
|
|
# After replica on draining node stops, deployment is healthy with 2
|
|
# running replicas.
|
|
another_replica._actor.set_done_stopping()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[(ReplicaState.RUNNING, 2, v1)],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_draining_stop_replica_before_deadline(self, mock_deployment_state_manager):
|
|
"""If the new replacement replica takes a long time to start,
|
|
the replica on the draining node should start gracefully
|
|
terminating ahead of time.
|
|
|
|
The graceful termination should be initiated `graceful_shutdown_timeout_s`
|
|
seconds before the draining node's deadline, even if the new
|
|
replica hasn't transitioned to RUNNING yet.
|
|
"""
|
|
|
|
create_dsm, timer, cluster_node_info_cache, _ = mock_deployment_state_manager
|
|
node_1 = NodeID.from_random().hex()
|
|
node_2 = NodeID.from_random().hex()
|
|
cluster_node_info_cache.add_node(node_1)
|
|
cluster_node_info_cache.add_node(node_2)
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
timer.reset(0)
|
|
|
|
b_info_1, v1 = deployment_info(
|
|
num_replicas=2, graceful_shutdown_timeout_s=20, version="1"
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, v1)])
|
|
|
|
# Drain node-2 with deadline 60. Since the replicas are still
|
|
# starting and we don't know the actor node id yet nothing happens
|
|
cluster_node_info_cache.draining_nodes = {node_2: 60 * 1000}
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, v1)])
|
|
|
|
one_replica, another_replica = ds._replicas.get()
|
|
|
|
one_replica._actor.set_node_id(node_1)
|
|
one_replica._actor.set_ready()
|
|
|
|
another_replica._actor.set_node_id(node_2)
|
|
another_replica._actor.set_ready()
|
|
|
|
# Try to start a new replica before initiating the graceful stop
|
|
# process for the replica on the draining node
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=3,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 1, v1),
|
|
(ReplicaState.PENDING_MIGRATION, 1, v1),
|
|
(ReplicaState.STARTING, 1, v1),
|
|
],
|
|
)
|
|
|
|
# Simulate the replica is not yet started after 40 seconds. The
|
|
# replica on node-2 should start graceful termination even though
|
|
# a new replica hasn't come up yet.
|
|
timer.advance(40)
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=3,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 1, v1),
|
|
(ReplicaState.STOPPING, 1, v1),
|
|
(ReplicaState.STARTING, 1, v1),
|
|
],
|
|
)
|
|
|
|
# Mark replica as finished stopping.
|
|
another_replica._actor.set_done_stopping()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[(ReplicaState.STARTING, 1, v1), (ReplicaState.RUNNING, 1, v1)],
|
|
)
|
|
|
|
# 5 seconds later, the replica finally starts.
|
|
timer.advance(5)
|
|
ds._replicas.get([ReplicaState.STARTING])[0]._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_draining_multiple_nodes(self, mock_deployment_state_manager):
|
|
"""Test multiple nodes draining at the same time.
|
|
|
|
We should choose to stop replicas on nodes with the earliest
|
|
deadlines when new replicas are started.
|
|
"""
|
|
|
|
create_dsm, timer, cluster_node_info_cache, _ = mock_deployment_state_manager
|
|
node_1 = NodeID.from_random().hex()
|
|
node_2 = NodeID.from_random().hex()
|
|
node_3 = NodeID.from_random().hex()
|
|
node_4 = NodeID.from_random().hex()
|
|
cluster_node_info_cache.add_node(node_1)
|
|
cluster_node_info_cache.add_node(node_2)
|
|
cluster_node_info_cache.add_node(node_3)
|
|
cluster_node_info_cache.add_node(node_4)
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
timer.reset(0)
|
|
|
|
b_info_1, v1 = deployment_info(
|
|
num_replicas=4, graceful_shutdown_timeout_s=20, version="1"
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=4, by_state=[(ReplicaState.STARTING, 4, v1)])
|
|
|
|
# Drain node-2 with deadline 60. Since the replicas are still
|
|
# starting and we don't know the actor node id yet nothing happens
|
|
cluster_node_info_cache.draining_nodes = {
|
|
node_2: 60 * 1000,
|
|
node_3: 100 * 1000,
|
|
node_4: 40 * 1000,
|
|
}
|
|
dsm.update()
|
|
check_counts(ds, total=4, by_state=[(ReplicaState.STARTING, 4, v1)])
|
|
|
|
for i, replica in enumerate(ds._replicas.get()):
|
|
replica._actor.set_node_id([node_1, node_2, node_3, node_4][i])
|
|
replica._actor.set_ready()
|
|
|
|
# Try to start new replicas before initiating the graceful stop
|
|
# process for the replica on the draining node
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=7,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 1, v1),
|
|
(ReplicaState.PENDING_MIGRATION, 3, v1),
|
|
(ReplicaState.STARTING, 3, v1),
|
|
],
|
|
)
|
|
|
|
# First new replica transitions to RUNNING.
|
|
timer.advance(5)
|
|
ds._replicas.get([ReplicaState.STARTING])[0]._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=7,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 2, v1),
|
|
(ReplicaState.STOPPING, 1, v1),
|
|
(ReplicaState.PENDING_MIGRATION, 2, v1),
|
|
(ReplicaState.STARTING, 2, v1),
|
|
],
|
|
)
|
|
# The replica on node_4 should be selected for graceful termination,
|
|
# because node_4 has the earliest deadline.
|
|
stopping_replica = ds._replicas.get([ReplicaState.STOPPING])[0]
|
|
assert stopping_replica.actor_node_id == node_4
|
|
stopping_replica._actor.set_done_stopping()
|
|
dsm.update()
|
|
|
|
# Second new replica transitions to RUNNING.
|
|
timer.advance(5)
|
|
ds._replicas.get([ReplicaState.STARTING])[0]._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=6,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 3, v1),
|
|
(ReplicaState.STOPPING, 1, v1),
|
|
(ReplicaState.PENDING_MIGRATION, 1, v1),
|
|
(ReplicaState.STARTING, 1, v1),
|
|
],
|
|
)
|
|
# The replica on node_2 should be selected for graceful termination,
|
|
# because node_2 has the second earliest deadline.
|
|
stopping_replica = ds._replicas.get([ReplicaState.STOPPING])[0]
|
|
assert stopping_replica.actor_node_id == node_2
|
|
stopping_replica._actor.set_done_stopping()
|
|
dsm.update()
|
|
|
|
# Third new replica transitions to RUNNING.
|
|
timer.advance(5)
|
|
ds._replicas.get([ReplicaState.STARTING])[0]._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=5,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 4, v1),
|
|
(ReplicaState.STOPPING, 1, v1),
|
|
],
|
|
)
|
|
|
|
# The replica on node_3 should be selected for graceful termination
|
|
# last because node_3 has the latest deadline.
|
|
stopping_replica = ds._replicas.get([ReplicaState.STOPPING])[0]
|
|
assert stopping_replica.actor_node_id == node_3
|
|
stopping_replica._actor.set_done_stopping()
|
|
dsm.update()
|
|
|
|
# Finally all 4 replicas are running.
|
|
check_counts(ds, total=4, by_state=[(ReplicaState.RUNNING, 4, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_replicas_unhealthy_on_draining_node(self, mock_deployment_state_manager):
|
|
"""Replicas pending migration should be stopped if unhealthy."""
|
|
|
|
create_dsm, timer, cluster_node_info_cache, _ = mock_deployment_state_manager
|
|
node_1 = NodeID.from_random().hex()
|
|
node_2 = NodeID.from_random().hex()
|
|
cluster_node_info_cache.add_node(node_1)
|
|
cluster_node_info_cache.add_node(node_2)
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
timer.reset(0)
|
|
|
|
b_info_1, v1 = deployment_info(
|
|
num_replicas=2, graceful_shutdown_timeout_s=20, version="1"
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, v1)])
|
|
|
|
# Drain node-2 with deadline 60.
|
|
cluster_node_info_cache.draining_nodes = {node_2: 60 * 1000}
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, v1)])
|
|
|
|
one_replica, another_replica = ds._replicas.get()
|
|
|
|
one_replica._actor.set_node_id(node_1)
|
|
another_replica._actor.set_node_id(node_2)
|
|
one_replica._actor.set_ready()
|
|
another_replica._actor.set_ready()
|
|
|
|
# Try to start a new replica before initiating the graceful stop
|
|
# process for the replica on the draining node
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=3,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 1, v1),
|
|
(ReplicaState.PENDING_MIGRATION, 1, v1),
|
|
(ReplicaState.STARTING, 1, v1),
|
|
],
|
|
)
|
|
|
|
# 5 seconds later, the new replica hasn't started but the
|
|
# replica on the draining node has become unhealthy. It should
|
|
# be stopped.
|
|
timer.advance(5)
|
|
ds._replicas.get([ReplicaState.PENDING_MIGRATION])[0]._actor.set_unhealthy()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=3,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 1, v1),
|
|
(ReplicaState.STOPPING, 1, v1),
|
|
(ReplicaState.STARTING, 1, v1),
|
|
],
|
|
)
|
|
|
|
# Unhealthy replica is stopped.
|
|
ds._replicas.get([ReplicaState.STOPPING])[0]._actor.set_done_stopping()
|
|
check_counts(
|
|
ds,
|
|
total=3,
|
|
by_state=[(ReplicaState.RUNNING, 1, v1), (ReplicaState.STARTING, 1, v1)],
|
|
)
|
|
|
|
# New replica starts.
|
|
ds._replicas.get([ReplicaState.STARTING])[0]._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, v1)])
|
|
|
|
def test_starting_replica_on_draining_node(self, mock_deployment_state_manager):
|
|
"""When a node gets drained, replicas in STARTING state should be stopped."""
|
|
|
|
create_dsm, timer, cluster_node_info_cache, _ = mock_deployment_state_manager
|
|
node_1 = NodeID.from_random().hex()
|
|
node_2 = NodeID.from_random().hex()
|
|
cluster_node_info_cache.add_node(node_1)
|
|
cluster_node_info_cache.add_node(node_2)
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
timer.reset(0)
|
|
|
|
b_info_1, v1 = deployment_info(
|
|
num_replicas=2, graceful_shutdown_timeout_s=20, version="1"
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, v1)])
|
|
|
|
# Mark replica on node_1 as ready, but replica on node_2 is
|
|
# still starting
|
|
one_replica, another_replica = ds._replicas.get()
|
|
one_replica._actor.set_node_id(node_1)
|
|
another_replica._actor.set_node_id(node_2)
|
|
one_replica._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[(ReplicaState.RUNNING, 1, v1), (ReplicaState.STARTING, 1, v1)],
|
|
)
|
|
|
|
# Drain node-2. The starting replica should be stopped immediately
|
|
# without waiting for the replica to start.
|
|
cluster_node_info_cache.draining_nodes = {node_2: 60 * 1000}
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=3,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 1, v1),
|
|
(ReplicaState.STOPPING, 1, v1),
|
|
(ReplicaState.STARTING, 1, v1),
|
|
],
|
|
)
|
|
stopping_replica = ds._replicas.get([ReplicaState.STOPPING])[0]
|
|
assert stopping_replica.actor_node_id == node_2
|
|
|
|
# Finish stopping old replica
|
|
stopping_replica._actor.set_done_stopping()
|
|
dsm.update()
|
|
starting_replica = ds._replicas.get([ReplicaState.STARTING])[0]
|
|
assert starting_replica.actor_node_id != node_2
|
|
|
|
# Finish starting new replica
|
|
starting_replica._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_in_place_update_during_draining(self, mock_deployment_state_manager):
|
|
"""Test that pending migration replicas of old versions are updated."""
|
|
|
|
create_dsm, timer, cluster_node_info_cache, _ = mock_deployment_state_manager
|
|
node_1 = NodeID.from_random().hex()
|
|
node_2 = NodeID.from_random().hex()
|
|
cluster_node_info_cache.add_node(node_1)
|
|
cluster_node_info_cache.add_node(node_2)
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
timer.reset(0)
|
|
|
|
b_info_1, v1 = deployment_info(
|
|
num_replicas=10, graceful_shutdown_timeout_s=20, version="1"
|
|
)
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=10, by_state=[(ReplicaState.STARTING, 10, v1)])
|
|
|
|
replicas = ds._replicas.get()
|
|
replicas[0]._actor.set_node_id(node_2)
|
|
replicas[0]._actor.set_ready()
|
|
for r in replicas[1:]:
|
|
r._actor.set_node_id(node_1)
|
|
r._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=10, by_state=[(ReplicaState.RUNNING, 10, v1)])
|
|
|
|
# Drain node-2 with deadline 60.
|
|
cluster_node_info_cache.draining_nodes = {node_2: 60 * 1000}
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=11,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 9, v1),
|
|
(ReplicaState.PENDING_MIGRATION, 1, v1),
|
|
(ReplicaState.STARTING, 1, v1),
|
|
],
|
|
)
|
|
|
|
# Deploy a new version. The STARTING and PENDING_MIGRATION
|
|
# replicas of the old version should be stopped.
|
|
migrating_replica = ds._replicas.get([ReplicaState.PENDING_MIGRATION])[0]
|
|
b_info_2, v2 = deployment_info(
|
|
num_replicas=10, graceful_shutdown_timeout_s=20, version="2"
|
|
)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, b_info_2)
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=12,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 9, v1),
|
|
(ReplicaState.STOPPING, 2, v1),
|
|
(ReplicaState.STARTING, 1, v2),
|
|
],
|
|
)
|
|
assert migrating_replica.actor_details.state == ReplicaState.STOPPING
|
|
|
|
# Rolling update should continue
|
|
ds._replicas.get([ReplicaState.STOPPING])[0]._actor.set_done_stopping()
|
|
ds._replicas.get([ReplicaState.STOPPING])[1]._actor.set_done_stopping()
|
|
dsm.update()
|
|
|
|
ds._replicas.get([ReplicaState.STARTING])[0]._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=12,
|
|
by_state=[
|
|
# Old and new running replicas
|
|
(ReplicaState.RUNNING, 7, v1),
|
|
(ReplicaState.RUNNING, 1, v2),
|
|
# Being rolling updated
|
|
(ReplicaState.STOPPING, 2, v1),
|
|
(ReplicaState.STARTING, 2, v2),
|
|
],
|
|
)
|
|
|
|
|
|
def test_docs_path_not_updated_for_different_version(mock_deployment_state_manager):
|
|
# Create deployment state manager
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
info_1, v1 = deployment_info(version="1")
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
ds: DeploymentState = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, v1)])
|
|
|
|
test_docs_path = "/test/docs/path"
|
|
|
|
# Set replicas ready and check statuses
|
|
for replica in ds._replicas.get([ReplicaState.STARTING]):
|
|
replica._actor.set_ready()
|
|
replica._actor.set_docs_path(test_docs_path)
|
|
|
|
assert ds.docs_path is None
|
|
|
|
# status=HEALTHY, status_trigger=DEPLOY
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, v1)])
|
|
assert ds.docs_path == test_docs_path
|
|
|
|
# Deploy a new version
|
|
info_2, v2 = deployment_info(version="2")
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_2)
|
|
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[(ReplicaState.STOPPING, 1, v1), (ReplicaState.STARTING, 1, v2)],
|
|
)
|
|
assert ds.docs_path == test_docs_path
|
|
|
|
test_docs_path_new = "/test/docs/path/2"
|
|
# Set done stopping replicas ready and check statuses
|
|
for replica in ds._replicas.get([ReplicaState.STOPPING]):
|
|
replica._actor.set_done_stopping()
|
|
|
|
# status=HEALTHY, status_trigger=DEPLOY
|
|
dsm.update()
|
|
|
|
for replica in ds._replicas.get([ReplicaState.STARTING]):
|
|
replica._actor.set_ready()
|
|
replica._actor.set_docs_path(test_docs_path_new)
|
|
assert ds.docs_path == test_docs_path
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, v2)])
|
|
assert ds.docs_path == test_docs_path_new
|
|
|
|
# Deploy a new version with None docs path
|
|
info_3, v3 = deployment_info(version="3")
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_3)
|
|
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[(ReplicaState.STOPPING, 1, v2), (ReplicaState.STARTING, 1, v3)],
|
|
)
|
|
assert ds.docs_path == test_docs_path_new
|
|
|
|
for replica in ds._replicas.get([ReplicaState.STOPPING]):
|
|
replica._actor.set_done_stopping()
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, v3)])
|
|
assert ds.docs_path == test_docs_path_new
|
|
|
|
for replica in ds._replicas.get([ReplicaState.STARTING]):
|
|
replica._actor.set_ready()
|
|
replica._actor.set_docs_path(None)
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, v3)])
|
|
assert ds.docs_path is None
|
|
|
|
|
|
def test_set_target_num_replicas_api(mock_deployment_state_manager):
|
|
"""Test the new set_target_num_replicas API for scaling deployments."""
|
|
# Create deployment state manager
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
# Deploy initial deployment with 1 replica
|
|
info_1, v1 = deployment_info(version="1", num_replicas=1)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
ds: DeploymentState = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, v1)])
|
|
assert ds.target_num_replicas == 1
|
|
|
|
# Test scaling up using the new API
|
|
dsm.set_target_num_replicas(TEST_DEPLOYMENT_ID, 3)
|
|
assert ds.target_num_replicas == 3
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.STARTING, 3, v1)])
|
|
|
|
|
|
def test_set_target_num_replicas_nonexistent_deployment(mock_deployment_state_manager):
|
|
"""Test that scaling nonexistent deployment raises KeyError."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
nonexistent_id = DeploymentID("nonexistent", "test_app")
|
|
|
|
with pytest.raises(ValueError, match="Deployment.*not found"):
|
|
dsm.set_target_num_replicas(nonexistent_id, 3)
|
|
|
|
|
|
def test_set_target_num_replicas_during_upgrade(mock_deployment_state_manager):
|
|
"""Test setting target replicas while an upgrade is ongoing."""
|
|
|
|
# Create deployment state manager
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
# Deploy initial deployment (v1) with 2 replicas
|
|
info_1, v1 = deployment_info(version="1", num_replicas=2)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
ds: DeploymentState = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, v1)])
|
|
assert ds.target_num_replicas == 2
|
|
|
|
# Get replicas to RUNNING state
|
|
for replica in ds._replicas.get([ReplicaState.STARTING]):
|
|
replica._actor.set_ready()
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, v1)])
|
|
|
|
# Start an upgrade to v2 with 2 replicas
|
|
info_2, v2 = deployment_info(version="2", num_replicas=2)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_2)
|
|
dsm.update()
|
|
|
|
check_counts(
|
|
ds,
|
|
total=3,
|
|
by_state=[
|
|
(ReplicaState.STARTING, 1, v2),
|
|
(ReplicaState.RUNNING, 1, v1),
|
|
(ReplicaState.STOPPING, 1, v1),
|
|
],
|
|
)
|
|
assert ds.target_num_replicas == 2
|
|
|
|
# Scale up to 5 replicas in the middle of the upgrade.
|
|
dsm.set_target_num_replicas(TEST_DEPLOYMENT_ID, 5)
|
|
assert ds.target_num_replicas == 5
|
|
|
|
def dsm_update():
|
|
for replica in ds._replicas.get([ReplicaState.STOPPING]):
|
|
replica._actor.set_done_stopping()
|
|
for replica in ds._replicas.get([ReplicaState.STARTING]):
|
|
replica._actor.set_ready()
|
|
dsm.update()
|
|
|
|
dsm_update()
|
|
check_counts(
|
|
ds,
|
|
total=5,
|
|
by_state=[
|
|
(ReplicaState.STARTING, 3, v2),
|
|
(ReplicaState.RUNNING, 1, v1),
|
|
(ReplicaState.RUNNING, 1, v2),
|
|
],
|
|
)
|
|
|
|
dsm_update()
|
|
check_counts(
|
|
ds,
|
|
total=6,
|
|
by_state=[
|
|
(ReplicaState.STARTING, 1, v2),
|
|
(ReplicaState.RUNNING, 4, v2),
|
|
(ReplicaState.STOPPING, 1, v1),
|
|
],
|
|
)
|
|
|
|
dsm_update()
|
|
check_counts(ds, total=5, by_state=[(ReplicaState.RUNNING, 5, v2)])
|
|
|
|
assert ds.target_num_replicas == 5
|
|
|
|
|
|
def test_set_target_num_replicas_deleting_deployment(mock_deployment_state_manager):
|
|
"""Test scaling deployment that is being deleted."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
# Deploy an initial deployment
|
|
info, v1 = deployment_info(num_replicas=2, version="v1")
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
|
|
ds: DeploymentState = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
dsm.update()
|
|
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, v1)])
|
|
|
|
# Delete the deployment
|
|
dsm.delete_deployment(TEST_DEPLOYMENT_ID)
|
|
|
|
# The deployment status should be DELETING
|
|
statuses = dsm.get_deployment_statuses([TEST_DEPLOYMENT_ID])
|
|
assert statuses[0].status_trigger == DeploymentStatusTrigger.DELETING
|
|
|
|
# Scaling should fail
|
|
with pytest.raises(DeploymentIsBeingDeletedError):
|
|
dsm.set_target_num_replicas(TEST_DEPLOYMENT_ID, 3)
|
|
|
|
|
|
class TestDeploymentRankManagerIntegrationE2E:
|
|
"""End-to-end integration tests for rank functionality through deployment state manager."""
|
|
|
|
def _set_replicas_ready(
|
|
self, ds: DeploymentState, replica_states: List[ReplicaState]
|
|
):
|
|
"""Helper to set replicas in given states to ready."""
|
|
for replica in ds._replicas.get(replica_states):
|
|
replica._actor.set_ready()
|
|
|
|
def _set_replicas_done_stopping(self, ds: DeploymentState):
|
|
"""Helper to set stopping replicas as done stopping."""
|
|
for replica in ds._replicas.get([ReplicaState.STOPPING]):
|
|
replica._actor.set_done_stopping()
|
|
|
|
def test_scaling_up_and_down_scenario(self, mock_deployment_state_manager):
|
|
"""Test a realistic scaling scenario through deployment state manager."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
# Start with 3 replicas
|
|
info_1, v1 = deployment_info(num_replicas=3, version="1")
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
ds: DeploymentState = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Create initial replicas
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.STARTING, 3, v1)])
|
|
|
|
# Set replicas ready
|
|
self._set_replicas_ready(ds, [ReplicaState.STARTING])
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.RUNNING, 3, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Check initial ranks are 0, 1, 2
|
|
ranks_mapping = ds._get_replica_ranks_mapping()
|
|
ranks = sorted([r.rank for r in ranks_mapping.values()])
|
|
assert ranks == [0, 1, 2], f"Expected ranks [0, 1, 2], got {ranks}"
|
|
|
|
# Scale down to 2 replicas - this should trigger rank reassignment
|
|
info_2, _ = deployment_info(num_replicas=2, version="1")
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_2)
|
|
dsm.update()
|
|
|
|
# One replica should be stopping
|
|
check_counts(
|
|
ds,
|
|
total=3,
|
|
by_state=[(ReplicaState.RUNNING, 2, v1), (ReplicaState.STOPPING, 1, v1)],
|
|
)
|
|
|
|
# Complete the scale down
|
|
self._set_replicas_done_stopping(ds)
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Trigger rank consistency check with one more update
|
|
dsm.update()
|
|
|
|
# After scaling down and reaching healthy status, ranks should be contiguous [0, 1]
|
|
ranks_mapping = ds._get_replica_ranks_mapping()
|
|
ranks = sorted([r.rank for r in ranks_mapping.values()])
|
|
assert ranks == [0, 1], f"Expected ranks [0, 1] after scale down, got {ranks}"
|
|
|
|
# Scale back up to 3 replicas - new replica should reuse available rank
|
|
info_3, _ = deployment_info(num_replicas=3, version="1")
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_3)
|
|
dsm.update()
|
|
|
|
# Should have one new starting replica
|
|
check_counts(
|
|
ds,
|
|
total=3,
|
|
by_state=[(ReplicaState.RUNNING, 2, v1), (ReplicaState.STARTING, 1, v1)],
|
|
)
|
|
|
|
# Set new replica ready
|
|
self._set_replicas_ready(ds, [ReplicaState.STARTING])
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.RUNNING, 3, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Trigger rank consistency check with one more update
|
|
dsm.update()
|
|
|
|
# Final ranks should be contiguous [0, 1, 2]
|
|
ranks_mapping = ds._get_replica_ranks_mapping()
|
|
ranks = sorted([r.rank for r in ranks_mapping.values()])
|
|
assert ranks == [0, 1, 2], f"Expected final ranks [0, 1, 2], got {ranks}"
|
|
|
|
def test_controller_recovery_with_scattered_ranks(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Test controller recovery with existing replica ranks through deployment state manager."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
# Deploy with 3 replicas
|
|
info_1, v1 = deployment_info(num_replicas=3, version="1")
|
|
target_state_changed = dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
assert target_state_changed
|
|
dsm.save_checkpoint()
|
|
ds: DeploymentState = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Create replicas and get them running
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.STARTING, 3, v1)])
|
|
self._set_replicas_ready(ds, [ReplicaState.STARTING])
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.RUNNING, 3, v1)])
|
|
|
|
# Get the actual replica objects (not just IDs)
|
|
replicas = ds._replicas.get([ReplicaState.RUNNING])
|
|
replica_ids = [replica.replica_id for replica in replicas]
|
|
|
|
# Simulate controller crashed! Create a new deployment state manager
|
|
# with the existing replica IDs to trigger recovery
|
|
new_dsm: DeploymentStateManager = create_dsm(
|
|
[replica_id.to_full_id_str() for replica_id in replica_ids]
|
|
)
|
|
|
|
# New deployment state should be created and replicas should be RECOVERING
|
|
new_ds = new_dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
check_counts(new_ds, total=3, by_state=[(ReplicaState.RECOVERING, 3, v1)])
|
|
|
|
# Complete recovery - set replicas ready
|
|
self._set_replicas_ready(new_ds, [ReplicaState.RECOVERING])
|
|
new_dsm.update()
|
|
check_counts(new_ds, total=3, by_state=[(ReplicaState.RUNNING, 3, v1)])
|
|
assert new_ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# At this point ranks should be scattered but all values [0, 1, 2] should be present
|
|
ranks_mapping = new_ds._get_replica_ranks_mapping()
|
|
ranks = sorted([r.rank for r in ranks_mapping.values()])
|
|
assert ranks == [0, 1, 2], "Should have recovered scattered ranks"
|
|
|
|
# Trigger rank consistency check with one more update - this should reorder if needed
|
|
new_dsm.update()
|
|
|
|
# After rank consistency check, ranks should still be [0, 1, 2]
|
|
final_ranks_mapping = new_ds._get_replica_ranks_mapping()
|
|
final_ranks = sorted([r.rank for r in final_ranks_mapping.values()])
|
|
assert final_ranks == [
|
|
0,
|
|
1,
|
|
2,
|
|
], f"Expected contiguous ranks [0, 1, 2] after consistency check, got {final_ranks}"
|
|
|
|
# Clean up
|
|
replica_rank_context.clear()
|
|
|
|
def test_rank_recovery_after_lightweight_reconfigure(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Lightweight reconfigure must pass the ReplicaRank object, not the int.
|
|
|
|
Regression test for the lightweight-update branch calling
|
|
`replica.reconfigure(version, rank=current_rank.rank)` (a bare int).
|
|
The replica stored the int and reported it back on controller
|
|
recovery, where `_recover_rank_impl` failed with
|
|
`'int' object has no attribute 'rank'`, leaving every surviving
|
|
replica without a rank and `_check_rank_consistency_impl` looping
|
|
"Rank system is in an invalid state".
|
|
"""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
replica_rank_context.clear()
|
|
|
|
# Deploy 3 replicas with a user_config and get them running.
|
|
info_1, v1 = deployment_info(num_replicas=3, version="1", user_config="1")
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
dsm.save_checkpoint()
|
|
ds: DeploymentState = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.STARTING, 3, v1)])
|
|
self._set_replicas_ready(ds, [ReplicaState.STARTING])
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.RUNNING, 3, v1)])
|
|
|
|
# Lightweight update: same code version, new user_config. Replicas
|
|
# are reconfigured in place (rolling), not restarted.
|
|
info_2, v2 = deployment_info(num_replicas=3, version="1", user_config="2")
|
|
assert dsm.deploy(TEST_DEPLOYMENT_ID, info_2)
|
|
dsm.save_checkpoint()
|
|
for _ in range(10):
|
|
dsm.update()
|
|
self._set_replicas_ready(ds, [ReplicaState.UPDATING])
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.RUNNING, 3, v2)])
|
|
|
|
# The reconfigure must have handed each replica the full ReplicaRank
|
|
# object; this is what replicas report back on controller recovery.
|
|
replicas = ds._replicas.get([ReplicaState.RUNNING])
|
|
for replica in replicas:
|
|
stored_rank = replica_rank_context[replica.replica_id.unique_id]
|
|
assert isinstance(
|
|
stored_rank, ReplicaRank
|
|
), f"Replica stored {stored_rank!r} instead of a ReplicaRank"
|
|
|
|
# Simulate controller crash and recover from the live replicas.
|
|
replica_ids = [replica.replica_id for replica in replicas]
|
|
new_dsm: DeploymentStateManager = create_dsm(
|
|
[replica_id.to_full_id_str() for replica_id in replica_ids]
|
|
)
|
|
new_ds = new_dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
check_counts(new_ds, total=3, by_state=[(ReplicaState.RECOVERING, 3, v2)])
|
|
self._set_replicas_ready(new_ds, [ReplicaState.RECOVERING])
|
|
new_dsm.update()
|
|
check_counts(new_ds, total=3, by_state=[(ReplicaState.RUNNING, 3, v2)])
|
|
|
|
# Every recovered replica must have its rank restored.
|
|
for replica_id in replica_ids:
|
|
assert new_ds._rank_manager.has_replica_rank(
|
|
replica_id.unique_id
|
|
), f"Rank for {replica_id.unique_id} was not recovered"
|
|
ranks_mapping = new_ds._get_replica_ranks_mapping()
|
|
ranks = sorted([r.rank for r in ranks_mapping.values()])
|
|
assert ranks == [0, 1, 2], f"Expected recovered ranks [0, 1, 2], got {ranks}"
|
|
|
|
# Clean up
|
|
replica_rank_context.clear()
|
|
|
|
def test_complex_reassignment_scenario(self, mock_deployment_state_manager):
|
|
"""Test complex reassignment with many gaps through deployment state manager."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
# Deploy with 4 replicas
|
|
info_1, v1 = deployment_info(num_replicas=4, version="1")
|
|
target_state_changed = dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
assert target_state_changed
|
|
dsm.save_checkpoint()
|
|
ds: DeploymentState = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Create replicas and get them running
|
|
dsm.update()
|
|
check_counts(ds, total=4, by_state=[(ReplicaState.STARTING, 4, v1)])
|
|
self._set_replicas_ready(ds, [ReplicaState.STARTING])
|
|
dsm.update()
|
|
check_counts(ds, total=4, by_state=[(ReplicaState.RUNNING, 4, v1)])
|
|
|
|
# Get the actual replica objects
|
|
replicas = ds._replicas.get([ReplicaState.RUNNING])
|
|
replica_ids = [replica.replica_id for replica in replicas]
|
|
|
|
# Simulate very scattered ranks in global context: 0, 3, 7, 10
|
|
global replica_rank_context
|
|
replica_rank_context.clear()
|
|
replica_rank_context[replica_ids[0].unique_id] = ReplicaRank(
|
|
rank=0, node_rank=0, local_rank=0
|
|
)
|
|
replica_rank_context[replica_ids[1].unique_id] = ReplicaRank(
|
|
rank=3, node_rank=0, local_rank=1
|
|
)
|
|
replica_rank_context[replica_ids[2].unique_id] = ReplicaRank(
|
|
rank=7, node_rank=0, local_rank=2
|
|
)
|
|
replica_rank_context[replica_ids[3].unique_id] = ReplicaRank(
|
|
rank=10, node_rank=0, local_rank=3
|
|
)
|
|
|
|
# Simulate controller crashed! Create a new deployment state manager
|
|
# with the existing replica IDs to trigger recovery
|
|
new_dsm: DeploymentStateManager = create_dsm(
|
|
[replica_id.to_full_id_str() for replica_id in replica_ids]
|
|
)
|
|
|
|
# New deployment state should be created and replicas should be RECOVERING
|
|
new_ds = new_dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
check_counts(new_ds, total=4, by_state=[(ReplicaState.RECOVERING, 4, v1)])
|
|
|
|
# Complete recovery - set replicas ready
|
|
self._set_replicas_ready(new_ds, [ReplicaState.RECOVERING])
|
|
new_dsm.update()
|
|
check_counts(new_ds, total=4, by_state=[(ReplicaState.RUNNING, 4, v1)])
|
|
assert new_ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Trigger rank consistency check with one more update
|
|
new_dsm.update()
|
|
|
|
# After reassignment, ranks should be contiguous [0, 1, 2, 3]
|
|
ranks_mapping = new_ds._get_replica_ranks_mapping()
|
|
ranks = sorted([r.rank for r in ranks_mapping.values()])
|
|
assert ranks == [
|
|
0,
|
|
1,
|
|
2,
|
|
3,
|
|
], f"Expected reassigned ranks [0, 1, 2, 3], got {ranks}"
|
|
|
|
def test_rank_consistency_during_version_rollout(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Test that rank consistency is maintained during version rollouts."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
# Start with 3 replicas of version 1
|
|
info_1, v1 = deployment_info(num_replicas=3, version="1")
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
ds: DeploymentState = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Create and ready initial replicas
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.STARTING, 3, v1)])
|
|
self._set_replicas_ready(ds, [ReplicaState.STARTING])
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.RUNNING, 3, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Verify initial ranks are contiguous
|
|
ranks_mapping = ds._get_replica_ranks_mapping()
|
|
initial_ranks = sorted([r.rank for r in ranks_mapping.values()])
|
|
assert initial_ranks == [0, 1, 2]
|
|
|
|
# Deploy version 2 - this should trigger rolling update
|
|
info_2, v2 = deployment_info(num_replicas=3, version="2")
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_2)
|
|
dsm.update()
|
|
|
|
# Complete the rolling update step by step
|
|
while True:
|
|
# Set any new starting replicas ready
|
|
starting_replicas = ds._replicas.get([ReplicaState.STARTING])
|
|
if starting_replicas:
|
|
self._set_replicas_ready(ds, [ReplicaState.STARTING])
|
|
|
|
# Complete any stopping replicas
|
|
stopping_replicas = ds._replicas.get([ReplicaState.STOPPING])
|
|
if stopping_replicas:
|
|
self._set_replicas_done_stopping(ds)
|
|
|
|
dsm.update()
|
|
|
|
# Check if rolling update is complete
|
|
running_replicas = ds._replicas.get([ReplicaState.RUNNING])
|
|
if len(running_replicas) == 3 and all(
|
|
r.version == v2 for r in running_replicas
|
|
):
|
|
break
|
|
|
|
# After rolling update is complete, deployment should be healthy
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Trigger rank consistency check with one more update
|
|
dsm.update()
|
|
|
|
# After rolling update, verify ranks are still contiguous
|
|
final_ranks_mapping = ds._get_replica_ranks_mapping()
|
|
final_ranks = sorted([r.rank for r in final_ranks_mapping.values()])
|
|
assert final_ranks == [
|
|
0,
|
|
1,
|
|
2,
|
|
], f"Expected contiguous ranks [0, 1, 2] after rollout, got {final_ranks}"
|
|
|
|
def test_rank_assignment_with_replica_failures(self, mock_deployment_state_manager):
|
|
"""Test rank handling when replicas fail during startup."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
# Deploy with 3 replicas
|
|
info_1, v1 = deployment_info(num_replicas=3, version="1")
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
ds: DeploymentState = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Create initial replicas
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.STARTING, 3, v1)])
|
|
|
|
# Make first two replicas ready, but let the third fail
|
|
starting_replicas = ds._replicas.get([ReplicaState.STARTING])
|
|
starting_replicas[0]._actor.set_ready()
|
|
starting_replicas[1]._actor.set_ready()
|
|
starting_replicas[2]._actor.set_failed_to_start()
|
|
|
|
dsm.update()
|
|
|
|
running_count = ds._replicas.count(states=[ReplicaState.RUNNING])
|
|
stopping_count = ds._replicas.count(states=[ReplicaState.STOPPING])
|
|
assert running_count == 2, "Should have 2 running replicas"
|
|
assert stopping_count == 1, "Should have 1 stopping replica"
|
|
|
|
self._set_replicas_done_stopping(ds)
|
|
dsm.update()
|
|
|
|
starting_count = ds._replicas.count(states=[ReplicaState.STARTING])
|
|
assert starting_count == 1, "Should have 1 starting replica"
|
|
|
|
self._set_replicas_ready(ds, [ReplicaState.STARTING])
|
|
|
|
dsm.update()
|
|
# second update to reassign ranks
|
|
dsm.update()
|
|
|
|
# Final verification - should have 3 running replicas (ignore failed/stopping replicas)
|
|
running_replicas = ds._replicas.get([ReplicaState.RUNNING])
|
|
assert (
|
|
len(running_replicas) == 3
|
|
), f"Expected 3 running replicas, got {len(running_replicas)}"
|
|
|
|
# Verify that ranks are properly assigned and unique for running replicas
|
|
ranks_mapping = ds._get_replica_ranks_mapping()
|
|
|
|
# Filter ranks to only include those for running replicas
|
|
running_replica_ids = [
|
|
replica.replica_id.unique_id for replica in running_replicas
|
|
]
|
|
running_replica_ranks = [
|
|
ranks_mapping[replica_id].rank
|
|
for replica_id in running_replica_ids
|
|
if replica_id in ranks_mapping
|
|
]
|
|
|
|
# The ranks should be assigned to all running replicas
|
|
assert set(running_replica_ranks) == {
|
|
0,
|
|
1,
|
|
2,
|
|
}, f"Expected ranks [0, 1, 2], got {[r.rank for r in ranks_mapping.values()]}"
|
|
|
|
def test_rank_recovery_skips_when_already_assigned(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Verify that recover_rank is skipped when a replica's rank is already assigned."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
# Deploy 3 replicas: STARTING -> RUNNING (ranks get assigned).
|
|
info_1, v1 = deployment_info(num_replicas=3, version="1")
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
ds: DeploymentState = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.STARTING, 3, v1)])
|
|
|
|
self._set_replicas_ready(ds, [ReplicaState.STARTING])
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.RUNNING, 3, v1)])
|
|
|
|
# Record the actor names and ranks for recovery.
|
|
actor_names = [r.replica_id.to_full_id_str() for r in ds._replicas.get()]
|
|
original_ranks = {
|
|
r.replica_id.unique_id: ds._rank_manager.get_replica_rank(
|
|
r.replica_id.unique_id
|
|
)
|
|
for r in ds._replicas.get()
|
|
}
|
|
dsm.save_checkpoint()
|
|
|
|
# Simulate controller crash: create a new DSM with the live actor names.
|
|
new_dsm: DeploymentStateManager = create_dsm(actor_names)
|
|
new_ds = new_dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
check_counts(new_ds, total=3, by_state=[(ReplicaState.RECOVERING, 3, v1)])
|
|
|
|
# Enable strict rank error mode so duplicate recover_rank raises.
|
|
new_ds._rank_manager._fail_on_rank_error = True
|
|
|
|
# Pre-populate 1 replica's rank in the new rank manager, simulating
|
|
# the scenario where the rank was never released.
|
|
target = new_ds._replicas.get(states=[ReplicaState.RECOVERING])[0]
|
|
target_id = target.replica_id.unique_id
|
|
target_rank = original_ranks[target_id]
|
|
new_ds._rank_manager.recover_rank(target_id, target.actor_node_id, target_rank)
|
|
assert new_ds._rank_manager.has_replica_rank(target_id)
|
|
|
|
# Mark all recovering replicas as ready.
|
|
self._set_replicas_ready(new_ds, [ReplicaState.RECOVERING])
|
|
|
|
# This update() calls _check_startup_replicas(RECOVERING). For the
|
|
# pre-populated replica, has_replica_rank returns True so recover_rank
|
|
# is SKIPPED.
|
|
new_dsm.update()
|
|
check_counts(new_ds, total=3, by_state=[(ReplicaState.RUNNING, 3, v1)])
|
|
|
|
# Verify all ranks were recovered correctly.
|
|
for rid, expected_rank in original_ranks.items():
|
|
assert new_ds._rank_manager.get_replica_rank(rid) == expected_rank
|
|
|
|
|
|
class TestGetOutboundDeployments:
|
|
def test_basic_outbound_deployments(self, mock_deployment_state_manager):
|
|
"""Test that outbound deployments are returned."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
deployment_id = DeploymentID(name="test_deployment", app_name="test_app")
|
|
b_info_1, _ = deployment_info(num_replicas=1)
|
|
dsm.deploy(deployment_id, b_info_1)
|
|
|
|
# Create a RUNNING replica
|
|
ds = dsm._deployment_states[deployment_id]
|
|
dsm.update() # Transitions to STARTING
|
|
for replica in ds._replicas.get([ReplicaState.STARTING]):
|
|
replica._actor.set_ready()
|
|
dsm.update() # Transitions to RUNNING
|
|
|
|
# Set outbound deployments on the mock replica
|
|
running_replicas = ds._replicas.get([ReplicaState.RUNNING])
|
|
assert len(running_replicas) == 1
|
|
|
|
d1 = DeploymentID(name="dep1", app_name="test_app")
|
|
d2 = DeploymentID(name="dep2", app_name="test_app")
|
|
running_replicas[0]._actor._outbound_deployments = [d1, d2]
|
|
|
|
outbound_deployments = ds.get_outbound_deployments()
|
|
assert outbound_deployments == [d1, d2]
|
|
|
|
# Verify it's accessible through DeploymentStateManager
|
|
assert dsm.get_deployment_outbound_deployments(deployment_id) == [
|
|
d1,
|
|
d2,
|
|
]
|
|
|
|
def test_deployment_state_manager_returns_none_for_nonexistent_deployment(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Test that DeploymentStateManager returns None for nonexistent deployments."""
|
|
(
|
|
create_dsm,
|
|
timer,
|
|
cluster_node_info_cache,
|
|
autoscaling_state_manager,
|
|
) = mock_deployment_state_manager
|
|
dsm = create_dsm()
|
|
|
|
deployment_id = DeploymentID(name="nonexistent", app_name="test_app")
|
|
assert dsm.get_deployment_outbound_deployments(deployment_id) is None
|
|
|
|
def test_returns_none_if_replicas_are_not_running(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Test that DeploymentStateManager returns None if replicas are not running."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
deployment_id = DeploymentID(name="test_deployment", app_name="test_app")
|
|
b_info_1, _ = deployment_info(num_replicas=2)
|
|
dsm.deploy(deployment_id, b_info_1)
|
|
ds = dsm._deployment_states[deployment_id]
|
|
dsm.update()
|
|
replicas = ds._replicas.get([ReplicaState.STARTING])
|
|
assert len(replicas) == 2
|
|
d1 = DeploymentID(name="dep1", app_name="test_app")
|
|
d2 = DeploymentID(name="dep2", app_name="test_app")
|
|
d3 = DeploymentID(name="dep3", app_name="test_app")
|
|
d4 = DeploymentID(name="dep4", app_name="test_app")
|
|
replicas[0]._actor._outbound_deployments = [d1, d2]
|
|
replicas[1]._actor._outbound_deployments = [d3, d4]
|
|
dsm.update()
|
|
|
|
outbound_deployments = ds.get_outbound_deployments()
|
|
assert outbound_deployments is None
|
|
|
|
# Set replicas ready
|
|
replicas[0]._actor.set_ready()
|
|
dsm.update()
|
|
outbound_deployments = ds.get_outbound_deployments()
|
|
assert outbound_deployments == [d1, d2]
|
|
|
|
def test_only_considers_replicas_matching_target_version(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Test that only replicas with target version are considered.
|
|
|
|
When a new version is deployed, old version replicas that are still
|
|
running should not be included in the outbound deployments result.
|
|
"""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
# Deploy version 1
|
|
b_info_1, v1 = deployment_info(version="1")
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
dsm.update()
|
|
|
|
# Get v1 replica to RUNNING state
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
|
|
# Set outbound deployments for v1 replica
|
|
d1 = DeploymentID(name="dep1", app_name="test_app")
|
|
d2 = DeploymentID(name="dep2", app_name="test_app")
|
|
ds._replicas.get()[0]._actor._outbound_deployments = [d1, d2]
|
|
|
|
# Verify v1 outbound deployments are returned
|
|
assert ds.get_outbound_deployments() == [d1, d2]
|
|
|
|
# Deploy version 2 - this triggers rolling update
|
|
b_info_2, v2 = deployment_info(version="2")
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, b_info_2)
|
|
dsm.update()
|
|
|
|
# Now we have v1 stopping and v2 starting
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[(ReplicaState.STOPPING, 1, v1), (ReplicaState.STARTING, 1, v2)],
|
|
)
|
|
|
|
# Key test: Even though v1 replica exists (stopping), it should not be
|
|
# included because target version is v2. Since v2 is not RUNNING yet,
|
|
# should return None.
|
|
assert ds.get_outbound_deployments() is None
|
|
|
|
# Set outbound deployments for v2 replica and mark it ready
|
|
d3 = DeploymentID(name="dep3", app_name="test_app")
|
|
ds._replicas.get(states=[ReplicaState.STARTING])[
|
|
0
|
|
]._actor._outbound_deployments = [d3]
|
|
ds._replicas.get(states=[ReplicaState.STARTING])[0]._actor.set_ready()
|
|
dsm.update()
|
|
|
|
# Now v2 is running. Should only return v2's outbound deployments (d3),
|
|
# not v1's outbound deployments (d1, d2).
|
|
assert ds.get_outbound_deployments() == [d3]
|
|
|
|
|
|
def test_broadcast_skips_work_when_replicas_unchanged(mock_deployment_state_manager):
|
|
"""Test that broadcast_running_replicas_if_changed() skips all work in
|
|
steady state when _broadcasted_replicas_set_changed is False and
|
|
_request_routing_info_updated is False."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
info_1, v1 = deployment_info()
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# _broadcasted_replicas_set_changed should be True after deploy.
|
|
assert ds._broadcasted_replicas_set_changed is True
|
|
|
|
# Bring deployment to healthy state.
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# After update(), the broadcast should have cleared the flag.
|
|
assert ds._broadcasted_replicas_set_changed is False
|
|
assert ds._request_routing_info_updated is False
|
|
|
|
# Call broadcast again — should be a no-op (early return).
|
|
# We verify by patching get_running_replica_infos; if the fast path
|
|
# works, the patched method will NOT be called.
|
|
with patch.object(ds, "get_running_replica_infos") as mock_get_infos:
|
|
ds.broadcast_running_replicas_if_changed()
|
|
mock_get_infos.assert_not_called()
|
|
|
|
# Now stop a replica (sets _broadcasted_replicas_set_changed = True).
|
|
ds._stop_one_running_replica_for_testing()
|
|
assert ds._broadcasted_replicas_set_changed is True
|
|
|
|
# broadcast should now do the full check.
|
|
ds.broadcast_running_replicas_if_changed()
|
|
# Flag should be cleared after the broadcast.
|
|
assert ds._broadcasted_replicas_set_changed is False
|
|
|
|
|
|
def test_broadcast_runs_when_routing_info_updated(mock_deployment_state_manager):
|
|
"""Test that broadcast_running_replicas_if_changed() runs when
|
|
_request_routing_info_updated is True even if _broadcasted_replicas_set_changed is False."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
info_1, v1 = deployment_info()
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Bring deployment to healthy state.
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
assert ds._broadcasted_replicas_set_changed is False
|
|
|
|
# Simulate routing info update.
|
|
ds._request_routing_info_updated = True
|
|
|
|
# broadcast should NOT take the fast path.
|
|
with patch.object(
|
|
ds, "get_running_replica_infos", wraps=ds.get_running_replica_infos
|
|
) as mock_get_infos:
|
|
ds.broadcast_running_replicas_if_changed()
|
|
mock_get_infos.assert_called_once()
|
|
|
|
# Both flags should be cleared after broadcast.
|
|
assert ds._broadcasted_replicas_set_changed is False
|
|
assert ds._request_routing_info_updated is False
|
|
|
|
|
|
def test_broadcasted_replicas_set_changed_flag_set_on_state_transitions(
|
|
mock_deployment_state_manager,
|
|
):
|
|
"""Test that _broadcasted_replicas_set_changed is set correctly during replica state
|
|
transitions."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
info_1, v1 = deployment_info(num_replicas=2)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Flag should be True after deploy (_set_target_state sets it).
|
|
assert ds._broadcasted_replicas_set_changed is True
|
|
|
|
# After update, replicas are STARTING.
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, None)])
|
|
|
|
# broadcast clears the flag.
|
|
assert ds._broadcasted_replicas_set_changed is False
|
|
|
|
# Set replicas ready — this transitions them to RUNNING in _check_startup_replicas.
|
|
for r in ds._replicas.get():
|
|
r._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, None)])
|
|
|
|
# Flag should be cleared again after broadcast.
|
|
assert ds._broadcasted_replicas_set_changed is False
|
|
|
|
# Now fail a health check (sets _broadcasted_replicas_set_changed via _stop_replica).
|
|
ds._replicas.get()[0]._actor.set_unhealthy()
|
|
dsm.update()
|
|
# Flag set by _stop_replica then cleared by broadcast.
|
|
assert ds._broadcasted_replicas_set_changed is False
|
|
# Verify a replica was stopped.
|
|
assert ds._replicas.count(states=[ReplicaState.STOPPING]) >= 1
|
|
|
|
|
|
def test_broadcasted_replicas_set_changed_flag_set_on_lightweight_broadcast_config_update(
|
|
mock_deployment_state_manager,
|
|
):
|
|
"""Regression test: when a config change requires a long-poll broadcast
|
|
(e.g. max_ongoing_requests changed) but does NOT require an actor restart
|
|
or reconfigure, the _broadcasted_replicas_set_changed flag must still be set by the
|
|
requires_long_poll_broadcast path so the broadcast is not skipped.
|
|
|
|
This guards against a future scenario where a broadcast-affecting field
|
|
is changed to a lighter update type that doesn't trigger actor_updating.
|
|
"""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
# Deploy v1 and bring to healthy steady state.
|
|
b_info_1, v1 = deployment_info(version="1")
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert ds._broadcasted_replicas_set_changed is False
|
|
|
|
# Deploy v2 with a different max_ongoing_requests.
|
|
b_info_2, v2 = deployment_info(version="1", max_ongoing_requests=42)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, b_info_2)
|
|
|
|
# _set_target_state also sets _broadcasted_replicas_set_changed. Clear it so we can
|
|
# isolate whether _stop_or_update_outdated_version_replicas sets the
|
|
# flag via the requires_long_poll_broadcast path.
|
|
ds._broadcasted_replicas_set_changed = False
|
|
|
|
# Patch the running replica's mock actor so reconfigure() returns False
|
|
# (simulating a broadcast-needed but no-actor-update scenario).
|
|
replica = ds._replicas.get()[0]
|
|
original_reconfigure = replica._actor.reconfigure
|
|
|
|
def patched_reconfigure(version, rank=None):
|
|
# Perform the version/rank bookkeeping but report no actor update.
|
|
original_reconfigure(version, rank=rank)
|
|
return False
|
|
|
|
replica._actor.reconfigure = patched_reconfigure
|
|
|
|
# Confirm preconditions: the version change requires a broadcast.
|
|
assert v1.requires_long_poll_broadcast(v2)
|
|
|
|
# Directly call _stop_or_update_outdated_version_replicas (the method
|
|
# that checks requires_long_poll_broadcast) so we can inspect the flag
|
|
# before broadcast clears it.
|
|
ds._stop_or_update_outdated_version_replicas()
|
|
|
|
# The replica should stay RUNNING (no actor restart, no UPDATING state)
|
|
# because our patched reconfigure() returns False.
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, v2)])
|
|
|
|
# Key assertion: _broadcasted_replicas_set_changed was set by the
|
|
# requires_long_poll_broadcast path, NOT by actor_updating.
|
|
assert ds._broadcasted_replicas_set_changed is True
|
|
|
|
# Now broadcast and verify it fires (clearing the flag).
|
|
ds.broadcast_running_replicas_if_changed()
|
|
assert ds._broadcasted_replicas_set_changed is False
|
|
ds._long_poll_host.notify_changed.assert_called()
|
|
|
|
# Verify the fast path works: no further broadcast on next tick.
|
|
ds._long_poll_host.notify_changed.reset_mock()
|
|
with patch.object(ds, "get_running_replica_infos") as mock_get_infos:
|
|
ds.broadcast_running_replicas_if_changed()
|
|
mock_get_infos.assert_not_called()
|
|
|
|
|
|
def test_broadcast_deferred_while_replicas_recovering(mock_deployment_state_manager):
|
|
"""Regression test: During controller recovery, broadcast_running_replicas_if_changed() must
|
|
be deferred until all RECOVERING replicas have transitioned, then fire once with
|
|
the complete set.
|
|
|
|
More Context: https://github.com/ray-project/ray/issues/62728
|
|
"""
|
|
|
|
def targets_key_was_broadcast(mock_lph):
|
|
"""Return True if notify_changed was called with a DEPLOYMENT_TARGETS key."""
|
|
for call in mock_lph.notify_changed.call_args_list:
|
|
keys_dict = call[0][0]
|
|
if any(
|
|
isinstance(k, tuple) and k[0] == LongPollNamespace.DEPLOYMENT_TARGETS
|
|
for k in keys_dict
|
|
):
|
|
return True
|
|
return False
|
|
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
# Deploy 3 replicas and bring to steady state.
|
|
info_1, v1 = deployment_info(num_replicas=3, version="1")
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
dsm.save_checkpoint()
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm.update()
|
|
for r in ds._replicas.get():
|
|
r._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=3, by_state=[(ReplicaState.RUNNING, 3, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Simulate controller restart
|
|
replica_ids = [r.replica_id.to_full_id_str() for r in ds._replicas.get()]
|
|
new_dsm: DeploymentStateManager = create_dsm(actor_names=replica_ids)
|
|
new_ds = new_dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# All 3 replicas should be RECOVERING.
|
|
check_counts(new_ds, total=3, by_state=[(ReplicaState.RECOVERING, 3, v1)])
|
|
|
|
# While replicas are RECOVERING, broadcast must not fire, even though the
|
|
# _broadcasted_replicas_set_changed=True.
|
|
assert new_ds._broadcasted_replicas_set_changed is True
|
|
new_ds._long_poll_host.notify_changed.reset_mock()
|
|
new_ds.broadcast_running_replicas_if_changed()
|
|
assert not targets_key_was_broadcast(new_ds._long_poll_host)
|
|
|
|
# Partially complete recovery: 2/3 replicas RUNNING.
|
|
# Broadcast still suppressed because 1 replica is RECOVERING.
|
|
new_ds._long_poll_host.notify_changed.reset_mock()
|
|
recovering = new_ds._replicas.get(states=[ReplicaState.RECOVERING])
|
|
for r in recovering[:2]:
|
|
r._actor.set_ready()
|
|
new_dsm.update()
|
|
check_counts(
|
|
new_ds,
|
|
total=3,
|
|
by_state=[(ReplicaState.RUNNING, 2, v1), (ReplicaState.RECOVERING, 1, v1)],
|
|
)
|
|
assert not targets_key_was_broadcast(new_ds._long_poll_host)
|
|
|
|
# All replicas finish recovery, so broadcast fires
|
|
new_ds._long_poll_host.notify_changed.reset_mock()
|
|
for r in new_ds._replicas.get(states=[ReplicaState.RECOVERING]):
|
|
r._actor.set_ready()
|
|
new_dsm.update()
|
|
check_counts(new_ds, total=3, by_state=[(ReplicaState.RUNNING, 3, v1)])
|
|
assert targets_key_was_broadcast(new_ds._long_poll_host)
|
|
# The payload must contain all 3 running replicas.
|
|
for call in new_ds._long_poll_host.notify_changed.call_args_list:
|
|
keys_dict = call[0][0]
|
|
key = (LongPollNamespace.DEPLOYMENT_TARGETS, TEST_DEPLOYMENT_ID)
|
|
if key in keys_dict:
|
|
assert len(keys_dict[key].running_replicas) == 3
|
|
break
|
|
|
|
|
|
def test_in_transition_cleared_at_steady_state(mock_deployment_state_manager):
|
|
"""Test that _in_transition is cleared once a deployment reaches
|
|
HEALTHY steady state and that subsequent ticks skip expensive work."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
info_1, v1 = deployment_info()
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Flag must be True after deploy.
|
|
assert ds._in_transition is True
|
|
|
|
# STARTING phase: flag stays True.
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.STARTING, 1, None)])
|
|
assert ds._in_transition is True
|
|
|
|
# Replica becomes ready → transitions to RUNNING.
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=1, by_state=[(ReplicaState.RUNNING, 1, None)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Steady state reached: flag must now be False.
|
|
assert ds._in_transition is False
|
|
|
|
|
|
def test_in_transition_skips_expensive_methods(mock_deployment_state_manager):
|
|
"""When _in_transition is False, check_curr_status and
|
|
scale_deployment_replicas should be no-ops."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
info_1, v1 = deployment_info()
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Bring to steady state.
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
assert ds._in_transition is False
|
|
|
|
# check_curr_status should return fast (False, False).
|
|
assert ds.check_curr_status() == (False, False)
|
|
|
|
# scale_deployment_replicas should return fast ([], None).
|
|
assert ds.scale_deployment_replicas() == ([], None)
|
|
|
|
# Verify check_and_update_replicas runs health checks but skips
|
|
# startup/stopping by patching _check_startup_replicas.
|
|
with patch.object(ds, "_check_startup_replicas") as mock_startup:
|
|
ds.check_and_update_replicas()
|
|
mock_startup.assert_not_called()
|
|
|
|
|
|
def test_in_transition_set_on_health_check_failure(
|
|
mock_deployment_state_manager,
|
|
):
|
|
"""A health check failure during steady state must re-enable
|
|
reconciliation so the controller can recover."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
info_1, v1 = deployment_info(num_replicas=2)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Bring to steady state.
|
|
dsm.update()
|
|
for r in ds._replicas.get():
|
|
r._actor.set_ready()
|
|
dsm.update()
|
|
assert ds._in_transition is False
|
|
|
|
# Fail a health check.
|
|
ds._replicas.get()[0]._actor.set_unhealthy()
|
|
dsm.update()
|
|
|
|
# Flag must be set (via _stop_replica) and there should be a
|
|
# STOPPING replica being processed.
|
|
assert ds._replicas.count(states=[ReplicaState.STOPPING]) >= 1
|
|
# The flag may already be cleared again if check_curr_status ran
|
|
# in the same tick and found reconciliation needed, so just verify
|
|
# the system is still functioning — the deployment should not be
|
|
# stuck.
|
|
assert ds._in_transition is True
|
|
|
|
|
|
def test_in_transition_set_on_target_state_change(
|
|
mock_deployment_state_manager,
|
|
):
|
|
"""Changing the target state (redeploy, autoscale) must set the flag."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
info_1, v1 = deployment_info(version="1")
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Bring to steady state.
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
assert ds._in_transition is False
|
|
|
|
# Redeploy with a new version.
|
|
info_2, v2 = deployment_info(version="2")
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_2)
|
|
assert ds._in_transition is True
|
|
|
|
|
|
def test_routing_stats_change_triggers_broadcast(mock_deployment_state_manager):
|
|
"""Routing stats changes during health checks must set _broadcasted_replicas_set_changed
|
|
so that the broadcast fast path does not skip the update."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
|
|
info_1, v1 = deployment_info()
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Bring to steady state.
|
|
dsm.update()
|
|
ds._replicas.get()[0]._actor.set_ready()
|
|
dsm.update()
|
|
assert ds._broadcasted_replicas_set_changed is False
|
|
assert ds._request_routing_info_updated is False
|
|
|
|
# Simulate routing stats changing on the replica actor.
|
|
ds._replicas.get()[0]._actor.get_routing_stats = lambda: {"new_key": 42}
|
|
|
|
# Run health checks (STEP 1 of update loop).
|
|
ds.check_and_update_replicas()
|
|
|
|
# The flag must be set because routing_stats changed.
|
|
assert ds._broadcasted_replicas_set_changed is True
|
|
|
|
# Broadcast should now run (not take the fast path).
|
|
with patch.object(
|
|
ds, "get_running_replica_infos", wraps=ds.get_running_replica_infos
|
|
) as mock_get_infos:
|
|
ds.broadcast_running_replicas_if_changed()
|
|
mock_get_infos.assert_called_once()
|
|
|
|
assert ds._broadcasted_replicas_set_changed is False
|
|
|
|
|
|
def test_pending_migration_prevents_in_transition_clear(
|
|
mock_deployment_state_manager,
|
|
):
|
|
create_dsm, timer, cluster_node_info_cache, _ = mock_deployment_state_manager
|
|
node_1 = NodeID.from_random().hex()
|
|
node_2 = NodeID.from_random().hex()
|
|
cluster_node_info_cache.add_node(node_1)
|
|
cluster_node_info_cache.add_node(node_2)
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
timer.reset(0)
|
|
|
|
b_info_1, v1 = deployment_info(
|
|
num_replicas=2, graceful_shutdown_timeout_s=20, version="1"
|
|
)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, b_info_1)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Start replicas on different nodes.
|
|
dsm.update()
|
|
one_replica, another_replica = ds._replicas.get()
|
|
one_replica._actor.set_node_id(node_1)
|
|
one_replica._actor.set_ready()
|
|
another_replica._actor.set_node_id(node_2)
|
|
another_replica._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=2, by_state=[(ReplicaState.RUNNING, 2, v1)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
assert ds._in_transition is False
|
|
|
|
# Drain node_2: one replica transitions to PENDING_MIGRATION and a
|
|
# replacement STARTING replica is created.
|
|
cluster_node_info_cache.draining_nodes = {node_2: 60 * 1000}
|
|
dsm.update()
|
|
assert ds._replicas.count(states=[ReplicaState.PENDING_MIGRATION]) == 1
|
|
assert ds._replicas.count(states=[ReplicaState.STARTING]) == 1
|
|
assert ds._in_transition is True
|
|
|
|
# Node stops draining before the replacement is ready. The
|
|
# PENDING_MIGRATION replica should move back to RUNNING.
|
|
cluster_node_info_cache.draining_nodes = {}
|
|
dsm.update()
|
|
|
|
pending_migration_count = ds._replicas.count(
|
|
states=[ReplicaState.PENDING_MIGRATION]
|
|
)
|
|
assert pending_migration_count == 0, (
|
|
f"Expected 0 PENDING_MIGRATION replicas but found {pending_migration_count}. "
|
|
"The replica is stuck because _in_transition was incorrectly cleared."
|
|
)
|
|
|
|
|
|
class TestIsGangDeploymentProperty:
|
|
"""Tests for DeploymentState._is_gang_deployment property."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"gang_scheduling_config, expected_value",
|
|
[
|
|
pytest.param(GangSchedulingConfig(gang_size=2), True, id="gang"),
|
|
pytest.param(None, False, id="non-gang"),
|
|
],
|
|
)
|
|
def test_is_gang_deployment(
|
|
self,
|
|
mock_deployment_state_manager,
|
|
gang_scheduling_config,
|
|
expected_value,
|
|
):
|
|
"""_is_gang_deployment reflects whether gang scheduling is configured."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
|
|
create_dsm_kwargs = {}
|
|
deployment_info_kwargs = {"num_replicas": 2}
|
|
if gang_scheduling_config is not None:
|
|
create_dsm_kwargs[
|
|
"create_placement_group_fn_override"
|
|
] = lambda *args, **kwargs: Mock()
|
|
deployment_info_kwargs["gang_scheduling_config"] = gang_scheduling_config
|
|
|
|
dsm: DeploymentStateManager = create_dsm(**create_dsm_kwargs)
|
|
b_info, _ = deployment_info(**deployment_info_kwargs)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, b_info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
assert ds._is_gang_deployment is expected_value
|
|
|
|
|
|
class TestScaleDeploymentGangReplicas:
|
|
def test_stopping_replicas_skip_upscale(self, mock_deployment_state_manager):
|
|
"""Skips upscale while gang replicas are stopping after startup failures, then recovers to healthy."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm(
|
|
create_placement_group_fn_override=lambda *args, **kwargs: Mock(),
|
|
)
|
|
gang_size = 2
|
|
target_replicas = 2
|
|
deployment_id = DeploymentID(name="gang_stopping_skip", app_name="app")
|
|
|
|
info, version = deployment_info(
|
|
num_replicas=target_replicas,
|
|
version="v1",
|
|
gang_scheduling_config=GangSchedulingConfig(gang_size=gang_size),
|
|
)
|
|
dsm.deploy(deployment_id, info)
|
|
ds = dsm._deployment_states[deployment_id]
|
|
|
|
dsm.update()
|
|
check_counts(
|
|
ds, total=target_replicas, by_state=[(ReplicaState.STARTING, 2, version)]
|
|
)
|
|
|
|
for replica in ds._replicas.get([ReplicaState.STARTING]):
|
|
replica._actor.set_failed_to_start()
|
|
|
|
dsm._deployment_scheduler.schedule_gang_placement_groups = Mock(return_value={})
|
|
captured_upscales = {}
|
|
original_schedule = dsm._deployment_scheduler.schedule
|
|
|
|
def schedule_with_capture(upscales, downscales):
|
|
captured_upscales.update(upscales)
|
|
return original_schedule(upscales, downscales)
|
|
|
|
dsm._deployment_scheduler.schedule = Mock(side_effect=schedule_with_capture)
|
|
dsm.update()
|
|
|
|
assert captured_upscales == {}
|
|
dsm._deployment_scheduler.schedule_gang_placement_groups.assert_not_called()
|
|
check_counts(
|
|
ds, total=target_replicas, by_state=[(ReplicaState.STOPPING, 2, version)]
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
|
|
for replica in ds._replicas.get([ReplicaState.STOPPING]):
|
|
replica._actor.set_done_stopping()
|
|
|
|
dsm.update()
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
|
|
dsm._deployment_scheduler.schedule_gang_placement_groups = Mock(
|
|
return_value={
|
|
deployment_id: GangReservationResult(
|
|
success=True,
|
|
gang_pgs=[Mock(name="pg-0")],
|
|
gang_ids=["g0"],
|
|
gang_pg_names=["SERVE_GANG::pg-0"],
|
|
)
|
|
}
|
|
)
|
|
dsm.update()
|
|
check_counts(
|
|
ds, total=target_replicas, by_state=[(ReplicaState.STARTING, 2, version)]
|
|
)
|
|
|
|
for replica in ds._replicas.get([ReplicaState.STARTING]):
|
|
replica._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(
|
|
ds, total=target_replicas, by_state=[(ReplicaState.RUNNING, 2, version)]
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_gang_reservation_failure_records_startup_failure(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Keeps upscale empty and records reservation failure details before recovering to healthy."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
deployment_id = DeploymentID(name="gang_reservation_fail", app_name="app")
|
|
error_msg = "simulated gang placement reservation failure"
|
|
|
|
info, version = deployment_info(
|
|
num_replicas=4,
|
|
version="v1",
|
|
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
|
)
|
|
dsm.deploy(deployment_id, info)
|
|
ds = dsm._deployment_states[deployment_id]
|
|
|
|
dsm._deployment_scheduler.schedule_gang_placement_groups = Mock(
|
|
return_value={
|
|
deployment_id: GangReservationResult(
|
|
success=False, error_message=error_msg
|
|
)
|
|
}
|
|
)
|
|
captured_upscales = {}
|
|
original_schedule = dsm._deployment_scheduler.schedule
|
|
|
|
def schedule_with_capture(upscales, downscales):
|
|
captured_upscales.update(upscales)
|
|
return original_schedule(upscales, downscales)
|
|
|
|
dsm._deployment_scheduler.schedule = Mock(side_effect=schedule_with_capture)
|
|
dsm.update()
|
|
|
|
assert captured_upscales == {}
|
|
check_counts(ds, total=0)
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
assert "Gang scheduling failed" in ds.curr_status_info.message
|
|
assert error_msg in ds.curr_status_info.message
|
|
|
|
dsm._deployment_scheduler.schedule_gang_placement_groups = Mock(
|
|
return_value={
|
|
deployment_id: GangReservationResult(
|
|
success=True,
|
|
gang_pgs=[Mock(name="pg-0"), Mock(name="pg-1")],
|
|
gang_ids=["g0", "g1"],
|
|
gang_pg_names=["SERVE_GANG::pg-0", "SERVE_GANG::pg-1"],
|
|
)
|
|
}
|
|
)
|
|
dsm.update()
|
|
check_counts(ds, total=4, by_state=[(ReplicaState.STARTING, 4, version)])
|
|
|
|
for replica in ds._replicas.get([ReplicaState.STARTING]):
|
|
replica._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(ds, total=4, by_state=[(ReplicaState.RUNNING, 4, version)])
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_successful_gang_reservation(self, mock_deployment_state_manager):
|
|
"""Creates expected gang scheduling requests and reaches healthy when all replicas become ready."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
gang_size = 2
|
|
num_gangs = 2
|
|
target_replicas = gang_size * num_gangs
|
|
deployment_id = DeploymentID(name="gang_success_sched", app_name="app")
|
|
gang_pgs = [Mock(name="pg-0"), Mock(name="pg-1")]
|
|
|
|
info, version = deployment_info(
|
|
num_replicas=target_replicas,
|
|
version="v1",
|
|
gang_scheduling_config=GangSchedulingConfig(gang_size=gang_size),
|
|
)
|
|
dsm.deploy(deployment_id, info)
|
|
ds = dsm._deployment_states[deployment_id]
|
|
|
|
gang_ids = ["gang_0", "gang_1"]
|
|
gang_pg_names = ["SERVE_GANG::pg-0", "SERVE_GANG::pg-1"]
|
|
dsm._deployment_scheduler.schedule_gang_placement_groups = Mock(
|
|
return_value={
|
|
deployment_id: GangReservationResult(
|
|
success=True,
|
|
gang_pgs=gang_pgs,
|
|
gang_ids=gang_ids,
|
|
gang_pg_names=gang_pg_names,
|
|
)
|
|
}
|
|
)
|
|
|
|
captured_upscales = {}
|
|
original_schedule = dsm._deployment_scheduler.schedule
|
|
|
|
def schedule_with_capture(upscales, downscales):
|
|
captured_upscales.update(upscales)
|
|
return original_schedule(upscales, downscales)
|
|
|
|
dsm._deployment_scheduler.schedule = Mock(side_effect=schedule_with_capture)
|
|
dsm.update()
|
|
|
|
assert deployment_id in captured_upscales
|
|
scheduling_requests = captured_upscales[deployment_id]
|
|
assert len(scheduling_requests) == target_replicas
|
|
assert {r.gang_placement_group for r in scheduling_requests} == set(gang_pgs)
|
|
assert sorted(r.gang_pg_index for r in scheduling_requests) == [0, 0, 1, 1]
|
|
check_counts(
|
|
ds,
|
|
total=target_replicas,
|
|
by_state=[(ReplicaState.STARTING, target_replicas, version)],
|
|
)
|
|
starting_replicas = ds._replicas.get([ReplicaState.STARTING])
|
|
assert len(starting_replicas) == target_replicas
|
|
gang_to_replicas = {}
|
|
for replica in starting_replicas:
|
|
gang_to_replicas.setdefault(replica.gang_context.gang_id, []).append(
|
|
replica
|
|
)
|
|
|
|
assert len(gang_to_replicas) == num_gangs
|
|
for gang_id, replicas in gang_to_replicas.items():
|
|
assert len(replicas) == gang_size
|
|
member_ids = {r.replica_id.unique_id for r in replicas}
|
|
assert sorted(r.gang_context.rank for r in replicas) == list(
|
|
range(gang_size)
|
|
)
|
|
for replica in replicas:
|
|
gang_context = replica.gang_context
|
|
assert gang_context.gang_id == gang_id
|
|
assert gang_context.world_size == gang_size
|
|
assert set(gang_context.member_replica_ids) == member_ids
|
|
assert gang_context.pg_name in gang_pg_names
|
|
|
|
for replica in starting_replicas:
|
|
replica._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=target_replicas,
|
|
by_state=[(ReplicaState.RUNNING, target_replicas, version)],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_gang_sibling_cleanup_on_startup_failure(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Stops gang siblings when one member fails startup to avoid partial gangs, then recovers to healthy."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm(
|
|
create_placement_group_fn_override=lambda *args, **kwargs: Mock(),
|
|
)
|
|
gang_size = 2
|
|
target_replicas = 4
|
|
deployment_id = DeploymentID(name="gang_sibling_cleanup", app_name="app")
|
|
|
|
info, version = deployment_info(
|
|
num_replicas=target_replicas,
|
|
version="v1",
|
|
gang_scheduling_config=GangSchedulingConfig(gang_size=gang_size),
|
|
)
|
|
dsm.deploy(deployment_id, info)
|
|
ds = dsm._deployment_states[deployment_id]
|
|
|
|
dsm.update()
|
|
starting_replicas = ds._replicas.get([ReplicaState.STARTING])
|
|
initial_context_by_replica = {
|
|
r.replica_id.unique_id: (
|
|
r.gang_context.gang_id,
|
|
r.gang_context.rank,
|
|
r.gang_context.world_size,
|
|
tuple(r.gang_context.member_replica_ids),
|
|
)
|
|
for r in starting_replicas
|
|
}
|
|
gang_to_replicas = {}
|
|
for replica in starting_replicas:
|
|
gang_to_replicas.setdefault(replica.gang_context.gang_id, []).append(
|
|
replica
|
|
)
|
|
failed_gang_id, failed_gang_members = next(iter(gang_to_replicas.items()))
|
|
|
|
failed_gang_members[0]._actor.set_failed_to_start()
|
|
failed_gang_members[1]._actor.set_ready()
|
|
dsm.update()
|
|
|
|
stopping_replicas = ds._replicas.get([ReplicaState.STOPPING])
|
|
starting_replicas = ds._replicas.get([ReplicaState.STARTING])
|
|
assert len(stopping_replicas) == gang_size
|
|
assert all(r.gang_context.gang_id == failed_gang_id for r in stopping_replicas)
|
|
assert all(r.gang_context.gang_id != failed_gang_id for r in starting_replicas)
|
|
surviving_gang_ids = {r.gang_context.gang_id for r in starting_replicas}
|
|
assert len(surviving_gang_ids) == 1
|
|
for replica in starting_replicas:
|
|
context_snapshot = initial_context_by_replica[replica.replica_id.unique_id]
|
|
assert context_snapshot == (
|
|
replica.gang_context.gang_id,
|
|
replica.gang_context.rank,
|
|
replica.gang_context.world_size,
|
|
tuple(replica.gang_context.member_replica_ids),
|
|
)
|
|
check_counts(
|
|
ds,
|
|
total=target_replicas,
|
|
by_state=[
|
|
(ReplicaState.STOPPING, 2, version),
|
|
(ReplicaState.STARTING, 2, version),
|
|
],
|
|
)
|
|
|
|
for replica in ds._replicas.get([ReplicaState.STOPPING]):
|
|
replica._actor.set_done_stopping()
|
|
for replica in ds._replicas.get([ReplicaState.STARTING]):
|
|
replica._actor.set_ready()
|
|
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=target_replicas,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 2, version),
|
|
(ReplicaState.STARTING, 2, version),
|
|
],
|
|
)
|
|
|
|
for replica in ds._replicas.get([ReplicaState.STARTING]):
|
|
replica._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=target_replicas,
|
|
by_state=[(ReplicaState.RUNNING, target_replicas, version)],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_gang_startup_failure_per_gang_counter(self, mock_deployment_state_manager):
|
|
"""When a gang of replicas fails to start, the failure counter should increment once per gang and not once per replica."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm(
|
|
create_placement_group_fn_override=lambda *args, **kwargs: Mock(),
|
|
)
|
|
gang_size = 2
|
|
target_replicas = 2
|
|
deployment_id = DeploymentID(name="gang_startup_threshold", app_name="app")
|
|
|
|
with patch(
|
|
"ray.serve._private.deployment_state.MAX_PER_REPLICA_RETRY_COUNT", 2
|
|
):
|
|
info, _ = deployment_info(
|
|
num_replicas=target_replicas,
|
|
version="v1",
|
|
max_constructor_retry_count=10,
|
|
gang_scheduling_config=GangSchedulingConfig(gang_size=gang_size),
|
|
)
|
|
dsm.deploy(deployment_id, info)
|
|
ds = dsm._deployment_states[deployment_id]
|
|
|
|
# Set by _failed_to_start_threshold -> min(max_constructor_retry_count, target_replicas * MAX_PER_REPLICA_RETRY_COUNT) = min(10, 2*2) = 4
|
|
expected_threshold = 4
|
|
assert ds._failed_to_start_threshold == expected_threshold
|
|
|
|
def run_failure_cycle():
|
|
"""Run one full cycle: start replicas → fail → stop → clean up."""
|
|
dsm.update()
|
|
starting = ds._replicas.get([ReplicaState.STARTING])
|
|
for replica in starting:
|
|
replica._actor.set_failed_to_start()
|
|
# Transition failed replicas to STOPPING
|
|
dsm.update()
|
|
# Complete stopping
|
|
for replica in ds._replicas.get([ReplicaState.STOPPING]):
|
|
replica._actor.set_done_stopping()
|
|
dsm.update()
|
|
|
|
num_failure_cycles = 2
|
|
for _ in range(num_failure_cycles):
|
|
run_failure_cycle()
|
|
assert ds.curr_status_info.status == DeploymentStatus.UPDATING
|
|
|
|
assert ds._replica_constructor_retry_counter == num_failure_cycles
|
|
|
|
def test_terminally_failed_deployment_skips_gang_reservation(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Does not reserve gang placement groups after terminal failure, and can recover on redeploy."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm()
|
|
deployment_id = DeploymentID(name="gang_terminal_failure", app_name="app")
|
|
info, _ = deployment_info(
|
|
num_replicas=2,
|
|
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
|
)
|
|
dsm.deploy(deployment_id, info)
|
|
ds = dsm._deployment_states[deployment_id]
|
|
|
|
dsm._deployment_scheduler.schedule_gang_placement_groups = Mock(
|
|
return_value={
|
|
deployment_id: GangReservationResult(
|
|
success=False, error_message="simulated gang reservation failure"
|
|
)
|
|
}
|
|
)
|
|
|
|
for _ in range(20):
|
|
dsm.update()
|
|
if ds.curr_status_info.status == DeploymentStatus.DEPLOY_FAILED:
|
|
break
|
|
assert ds.curr_status_info.status == DeploymentStatus.DEPLOY_FAILED
|
|
|
|
dsm._deployment_scheduler.schedule_gang_placement_groups.reset_mock()
|
|
dsm.update()
|
|
dsm._deployment_scheduler.schedule_gang_placement_groups.assert_not_called()
|
|
|
|
recovery_info, recovery_version = deployment_info(
|
|
num_replicas=2,
|
|
version="v2",
|
|
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
|
)
|
|
dsm.deploy(deployment_id, recovery_info)
|
|
dsm._deployment_scheduler.schedule_gang_placement_groups = Mock(
|
|
return_value={
|
|
deployment_id: GangReservationResult(
|
|
success=True,
|
|
gang_pgs=[Mock(name="pg-recovery")],
|
|
gang_ids=["g0"],
|
|
gang_pg_names=["SERVE_GANG::pg-recovery"],
|
|
)
|
|
}
|
|
)
|
|
|
|
dsm.update()
|
|
check_counts(
|
|
ds, total=2, by_state=[(ReplicaState.STARTING, 2, recovery_version)]
|
|
)
|
|
for replica in ds._replicas.get([ReplicaState.STARTING]):
|
|
replica._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(
|
|
ds, total=2, by_state=[(ReplicaState.RUNNING, 2, recovery_version)]
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_gang_downscale_stops_complete_gangs(self, mock_deployment_state_manager):
|
|
"""Downscaling a gang deployment stops complete gangs and recovers to healthy."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm(
|
|
create_placement_group_fn_override=lambda *args, **kwargs: Mock(),
|
|
)
|
|
gang_size = 2
|
|
initial_replicas = 4
|
|
deployment_id = DeploymentID(name="gang_downscale", app_name="app")
|
|
|
|
info, version = deployment_info(
|
|
num_replicas=initial_replicas,
|
|
version="v1",
|
|
gang_scheduling_config=GangSchedulingConfig(gang_size=gang_size),
|
|
)
|
|
dsm.deploy(deployment_id, info)
|
|
ds = dsm._deployment_states[deployment_id]
|
|
|
|
# Start all replicas and reach HEALTHY
|
|
dsm.update()
|
|
for replica in ds._replicas.get([ReplicaState.STARTING]):
|
|
replica._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=initial_replicas,
|
|
by_state=[(ReplicaState.RUNNING, initial_replicas, version)],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Downscale to 2 replicas (remove 1 gang)
|
|
new_info, new_version = deployment_info(
|
|
num_replicas=2,
|
|
version="v1",
|
|
gang_scheduling_config=GangSchedulingConfig(gang_size=gang_size),
|
|
)
|
|
dsm.deploy(deployment_id, new_info)
|
|
dsm.update()
|
|
|
|
# Verify exactly 1 complete gang (2 replicas) is stopping
|
|
stopping = ds._replicas.get([ReplicaState.STOPPING])
|
|
running = ds._replicas.get([ReplicaState.RUNNING])
|
|
assert len(stopping) == 2
|
|
assert len(running) == 2
|
|
|
|
# The 2 stopping replicas must belong to the same gang
|
|
stopping_gang_ids = {r.gang_context.gang_id for r in stopping}
|
|
assert len(stopping_gang_ids) == 1
|
|
|
|
# The 2 running replicas must belong to the same (surviving) gang
|
|
running_gang_ids = {r.gang_context.gang_id for r in running}
|
|
assert len(running_gang_ids) == 1
|
|
assert stopping_gang_ids != running_gang_ids
|
|
|
|
# Complete stopping and verify recovery to HEALTHY
|
|
for replica in ds._replicas.get([ReplicaState.STOPPING]):
|
|
replica._actor.set_done_stopping()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[(ReplicaState.RUNNING, 2, new_version)],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_gang_downscale_prefers_pending_gang(self, mock_deployment_state_manager):
|
|
"""Downscaling prefers the gang that still has a pending replica."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm(
|
|
create_placement_group_fn_override=lambda *args, **kwargs: Mock(),
|
|
)
|
|
gang_size = 2
|
|
initial_replicas = 4
|
|
deployment_id = DeploymentID(name="gang_downscale_pending", app_name="app")
|
|
|
|
info, version = deployment_info(
|
|
num_replicas=initial_replicas,
|
|
version="v1",
|
|
gang_scheduling_config=GangSchedulingConfig(gang_size=gang_size),
|
|
)
|
|
dsm.deploy(deployment_id, info)
|
|
ds = dsm._deployment_states[deployment_id]
|
|
|
|
# First update creates all 4 replicas in STARTING state
|
|
dsm.update()
|
|
starting = ds._replicas.get([ReplicaState.STARTING])
|
|
assert len(starting) == initial_replicas
|
|
|
|
# Mark 3 of 4 replicas ready, leaving 1 replica from the second gang still pending.
|
|
gangs: dict = {}
|
|
for replica in starting:
|
|
gangs.setdefault(replica.gang_context.gang_id, []).append(replica)
|
|
gang_id1, gang_id2 = list(gangs.keys())
|
|
|
|
for replica in gangs[gang_id1]:
|
|
replica._actor.set_ready()
|
|
gangs[gang_id2][0]._actor.set_ready()
|
|
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=initial_replicas,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, 3, version),
|
|
(ReplicaState.STARTING, 1, version),
|
|
],
|
|
)
|
|
|
|
# Downscale to 2 replicas — should prefer gang 2 (has a pending member)
|
|
new_info, new_version = deployment_info(
|
|
num_replicas=2,
|
|
version="v1",
|
|
gang_scheduling_config=GangSchedulingConfig(gang_size=gang_size),
|
|
)
|
|
dsm.deploy(deployment_id, new_info)
|
|
dsm.update()
|
|
|
|
stopping = ds._replicas.get([ReplicaState.STOPPING])
|
|
running = ds._replicas.get([ReplicaState.RUNNING])
|
|
assert len(stopping) == 2
|
|
assert len(running) == 2
|
|
|
|
stopping_gang_ids = {r.gang_context.gang_id for r in stopping}
|
|
assert stopping_gang_ids == {gang_id2}
|
|
running_gang_ids = {r.gang_context.gang_id for r in running}
|
|
assert running_gang_ids == {gang_id1}
|
|
|
|
# Complete stopping and verify recovery to HEALTHY
|
|
for replica in ds._replicas.get([ReplicaState.STOPPING]):
|
|
replica._actor.set_done_stopping()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=2,
|
|
by_state=[(ReplicaState.RUNNING, 2, new_version)],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
|
|
class TestGangHealthCheck:
|
|
def _deploy_gang(self, mock_deployment_state_manager, gang_size, num_replicas):
|
|
"""Deploy gang-scheduled replicas and wait for them to become RUNNING."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm(
|
|
create_placement_group_fn_override=lambda *args, **kwargs: MockPlacementGroup(
|
|
*args, **kwargs
|
|
),
|
|
)
|
|
b_info, v1 = deployment_info(
|
|
version="1",
|
|
num_replicas=num_replicas,
|
|
gang_scheduling_config=GangSchedulingConfig(gang_size=gang_size),
|
|
)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, b_info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
# Reserves gang PGs and creates replicas
|
|
dsm.update()
|
|
check_counts(
|
|
ds, total=num_replicas, by_state=[(ReplicaState.STARTING, num_replicas, v1)]
|
|
)
|
|
|
|
# Capture replica references and wait for them to become RUNNING
|
|
replicas = ds._replicas.get()
|
|
for replica in replicas:
|
|
replica._actor.set_ready()
|
|
dsm.update()
|
|
|
|
check_counts(
|
|
ds, total=num_replicas, by_state=[(ReplicaState.RUNNING, num_replicas, v1)]
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
# Group captured replicas by gang
|
|
gangs = {}
|
|
for r in replicas:
|
|
assert r.gang_context is not None
|
|
gangs.setdefault(r.gang_context.gang_id, []).append(r)
|
|
|
|
return dsm, ds, v1, gangs
|
|
|
|
@pytest.mark.parametrize("force_stop_unhealthy", [True, False])
|
|
def test_restart_gang_entire_gang_stopped(
|
|
self, mock_deployment_state_manager, force_stop_unhealthy
|
|
):
|
|
"""Unhealthy gang is force-stopped regardless of FORCE_STOP_UNHEALTHY_REPLICAS;
|
|
healthy gangs are unaffected."""
|
|
gang_size = 2
|
|
num_replicas = 4
|
|
num_gangs = num_replicas // gang_size
|
|
dsm, ds, v1, gangs = self._deploy_gang(
|
|
mock_deployment_state_manager, gang_size, num_replicas
|
|
)
|
|
assert len(gangs) == num_gangs
|
|
|
|
gang_ids = list(gangs.keys())
|
|
target_gang = gangs[gang_ids[0]]
|
|
healthy_gang = gangs[gang_ids[1]]
|
|
|
|
ds.FORCE_STOP_UNHEALTHY_REPLICAS = force_stop_unhealthy
|
|
|
|
# Initialize health checks, then mark one replica in the target gang as unhealthy.
|
|
dsm.update()
|
|
target_gang[0]._actor.set_unhealthy()
|
|
dsm.update()
|
|
|
|
# Both replicas of the affected gang should be stopping (force-stopped).
|
|
check_counts(
|
|
ds,
|
|
total=num_replicas,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, gang_size, v1),
|
|
(ReplicaState.STOPPING, gang_size, v1),
|
|
],
|
|
)
|
|
for r in target_gang:
|
|
assert r._actor.force_stopped_counter == 1
|
|
|
|
# Healthy gang replicas should still be running.
|
|
for r in healthy_gang:
|
|
assert r._actor.force_stopped_counter == 0
|
|
|
|
assert ds.curr_status_info.status == DeploymentStatus.UNHEALTHY
|
|
assert (
|
|
ds.curr_status_info.status_trigger
|
|
== DeploymentStatusTrigger.HEALTH_CHECK_FAILED
|
|
)
|
|
assert "UNHEALTHY" in ds.curr_status_info.message
|
|
|
|
# After the stopped replicas finish stopping, new replicas should start.
|
|
for r in target_gang:
|
|
r._actor.set_done_stopping()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=num_replicas,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, gang_size, v1),
|
|
(ReplicaState.STARTING, gang_size, v1),
|
|
],
|
|
)
|
|
|
|
# New replicas become ready -> deployment should recover to HEALTHY.
|
|
for r in ds._replicas.get([ReplicaState.STARTING]):
|
|
r._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=num_replicas,
|
|
by_state=[(ReplicaState.RUNNING, num_replicas, v1)],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_restart_gang_multiple_unhealthy_gang_replicas(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Verify gang replicas are force-stopped once when there are multiple unhealthy replicas in the same gang."""
|
|
gang_size = 2
|
|
num_replicas = 4
|
|
dsm, ds, v1, gangs = self._deploy_gang(
|
|
mock_deployment_state_manager, gang_size, num_replicas
|
|
)
|
|
|
|
gang_ids = list(gangs.keys())
|
|
target_gang = gangs[gang_ids[0]]
|
|
healthy_gang = gangs[gang_ids[1]]
|
|
|
|
# Initialize health checks, then mark both replicas unhealthy.
|
|
dsm.update()
|
|
for r in target_gang:
|
|
r._actor.set_unhealthy()
|
|
dsm.update()
|
|
|
|
check_counts(
|
|
ds,
|
|
total=num_replicas,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, gang_size, v1),
|
|
(ReplicaState.STOPPING, gang_size, v1),
|
|
],
|
|
)
|
|
for r in target_gang:
|
|
assert r._actor.force_stopped_counter == 1
|
|
for r in healthy_gang:
|
|
assert r._actor.force_stopped_counter == 0
|
|
|
|
assert ds.curr_status_info.status == DeploymentStatus.UNHEALTHY
|
|
|
|
# Finish stopping -> new replicas start -> become ready -> HEALTHY.
|
|
for r in target_gang:
|
|
r._actor.set_done_stopping()
|
|
dsm.update()
|
|
for r in ds._replicas.get([ReplicaState.STARTING]):
|
|
r._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=num_replicas,
|
|
by_state=[(ReplicaState.RUNNING, num_replicas, v1)],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_restart_gang_multiple_gangs_failing(self, mock_deployment_state_manager):
|
|
"""Multiple gangs with unhealthy replicas are all stopped; surviving gang is untouched."""
|
|
gang_size = 2
|
|
num_replicas = 6 # 3 gangs
|
|
dsm, ds, v1, gangs = self._deploy_gang(
|
|
mock_deployment_state_manager, gang_size, num_replicas
|
|
)
|
|
assert len(gangs) == 3
|
|
|
|
gang_ids = list(gangs.keys())
|
|
failed_gang_0 = gangs[gang_ids[0]]
|
|
failed_gang_1 = gangs[gang_ids[1]]
|
|
surviving_gang = gangs[gang_ids[2]]
|
|
|
|
dsm.update()
|
|
failed_gang_0[0]._actor.set_unhealthy()
|
|
failed_gang_1[0]._actor.set_unhealthy()
|
|
dsm.update()
|
|
|
|
check_counts(
|
|
ds,
|
|
total=num_replicas,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, gang_size, v1),
|
|
(ReplicaState.STOPPING, gang_size * 2, v1),
|
|
],
|
|
)
|
|
for r in failed_gang_0 + failed_gang_1:
|
|
assert r._actor.force_stopped_counter == 1
|
|
for r in surviving_gang:
|
|
assert r._actor.force_stopped_counter == 0
|
|
|
|
assert ds.curr_status_info.status == DeploymentStatus.UNHEALTHY
|
|
|
|
# Finish stopping -> new replicas start -> become ready -> HEALTHY.
|
|
for r in failed_gang_0 + failed_gang_1:
|
|
r._actor.set_done_stopping()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=num_replicas,
|
|
by_state=[
|
|
(ReplicaState.RUNNING, gang_size, v1),
|
|
(ReplicaState.STARTING, gang_size * 2, v1),
|
|
],
|
|
)
|
|
for r in ds._replicas.get([ReplicaState.STARTING]):
|
|
r._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=num_replicas,
|
|
by_state=[(ReplicaState.RUNNING, num_replicas, v1)],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
|
|
class TestGangRollingUpdate:
|
|
def _deploy_gang(self, mock_deployment_state_manager, gang_size, num_replicas):
|
|
"""Deploy a gang-scheduled deployment and advance to HEALTHY."""
|
|
create_dsm, _, _, _ = mock_deployment_state_manager
|
|
dsm: DeploymentStateManager = create_dsm(
|
|
create_placement_group_fn_override=lambda *args, **kwargs: MockPlacementGroup(
|
|
*args, **kwargs
|
|
),
|
|
)
|
|
info, _ = deployment_info(
|
|
version="v1",
|
|
num_replicas=num_replicas,
|
|
gang_scheduling_config=GangSchedulingConfig(gang_size=gang_size),
|
|
)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
dsm.update()
|
|
for replica in ds._replicas.get():
|
|
replica._actor.set_ready()
|
|
dsm.update()
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
return dsm, ds
|
|
|
|
def _deploy_new_version(self, dsm, gang_size, num_replicas, version, **kwargs):
|
|
"""Deploy a new version and return its version tag."""
|
|
info, v = deployment_info(
|
|
version=version,
|
|
num_replicas=num_replicas,
|
|
gang_scheduling_config=GangSchedulingConfig(gang_size=gang_size),
|
|
**kwargs,
|
|
)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
return v
|
|
|
|
def _mock_gang_pgs(self, dsm, gang_size, num_new):
|
|
"""Mock gang PG reservation for new replicas."""
|
|
n = num_new // gang_size
|
|
dsm._deployment_scheduler.schedule_gang_placement_groups = Mock(
|
|
return_value={
|
|
TEST_DEPLOYMENT_ID: GangReservationResult(
|
|
success=True,
|
|
gang_pgs=[Mock() for _ in range(n)],
|
|
gang_ids=[f"new_gang_{i}" for i in range(n)],
|
|
gang_pg_names=[f"SERVE_GANG::pg-{i}" for i in range(n)],
|
|
)
|
|
}
|
|
)
|
|
|
|
def _finish_stopping(self, ds):
|
|
"""Mark all STOPPING replicas as done."""
|
|
for r in ds._replicas.get(states=[ReplicaState.STOPPING]):
|
|
r._actor.set_done_stopping()
|
|
|
|
def _finish_starting(self, ds):
|
|
"""Mark all STARTING replicas as ready."""
|
|
for r in ds._replicas.get(states=[ReplicaState.STARTING]):
|
|
r._actor.set_ready()
|
|
|
|
def _advance_wave(self, dsm, ds, gang_size):
|
|
"""Complete one rolling-update wave: finish stops, mock PGs, start new."""
|
|
self._finish_stopping(ds)
|
|
self._mock_gang_pgs(dsm, gang_size, gang_size)
|
|
dsm.update()
|
|
self._finish_starting(ds)
|
|
|
|
def test_stop_gang_atomically(self, mock_deployment_state_manager):
|
|
"""Stops one complete gang per wave, never partially tearing down a gang."""
|
|
gang_size, num_replicas = 2, 4
|
|
dsm, ds = self._deploy_gang(
|
|
mock_deployment_state_manager, gang_size, num_replicas
|
|
)
|
|
|
|
v2 = self._deploy_new_version(dsm, gang_size, num_replicas, "v2")
|
|
dsm.update()
|
|
|
|
# First wave: exactly one gang (2 replicas) stops
|
|
stopping = ds._replicas.get(states=[ReplicaState.STOPPING])
|
|
assert len(stopping) == gang_size
|
|
assert len({r.gang_context.gang_id for r in stopping}) == 1
|
|
assert all(r.version != v2 for r in stopping)
|
|
|
|
# Complete wave 1, trigger wave 2
|
|
self._advance_wave(dsm, ds, gang_size)
|
|
dsm.update()
|
|
|
|
# Second old gang now stopping
|
|
assert (
|
|
ds._replicas.count(exclude_version=v2, states=[ReplicaState.STOPPING])
|
|
== gang_size
|
|
)
|
|
|
|
# Complete wave 2
|
|
self._advance_wave(dsm, ds, gang_size)
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=num_replicas,
|
|
by_state=[(ReplicaState.RUNNING, num_replicas, v2)],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_rollout_size_rounding(self, mock_deployment_state_manager):
|
|
"""With gang_size=3, num_replicas=9, rollout_size rounds up from 1 to 3."""
|
|
gang_size, num_replicas = 3, 9
|
|
dsm, ds = self._deploy_gang(
|
|
mock_deployment_state_manager, gang_size, num_replicas
|
|
)
|
|
|
|
v2 = self._deploy_new_version(dsm, gang_size, num_replicas, "v2")
|
|
dsm.update()
|
|
|
|
# Exactly one gang (3 replicas) stops
|
|
stopping = ds._replicas.get(states=[ReplicaState.STOPPING])
|
|
assert len(stopping) == gang_size
|
|
assert len({r.gang_context.gang_id for r in stopping}) == 1
|
|
|
|
# Advance through all 3 waves
|
|
for _ in range(3):
|
|
self._advance_wave(dsm, ds, gang_size)
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=num_replicas,
|
|
by_state=[(ReplicaState.RUNNING, num_replicas, v2)],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_reconfigure(self, mock_deployment_state_manager):
|
|
"""Config-only change (same code version) reconfigures in place, no stops."""
|
|
gang_size, num_replicas = 2, 4
|
|
dsm, ds = self._deploy_gang(
|
|
mock_deployment_state_manager, gang_size, num_replicas
|
|
)
|
|
|
|
self._deploy_new_version(
|
|
dsm, gang_size, num_replicas, "v1", user_config={"key": "new_value"}
|
|
)
|
|
dsm.update()
|
|
|
|
assert ds._replicas.count(states=[ReplicaState.STOPPING]) == 0
|
|
running = ds._replicas.count(states=[ReplicaState.RUNNING])
|
|
updating = ds._replicas.count(states=[ReplicaState.UPDATING])
|
|
assert running + updating == num_replicas
|
|
|
|
def test_starting_replicas(self, mock_deployment_state_manager):
|
|
"""Rapid v1->v2->v3: v2 STARTING replicas are stopped when v3 arrives."""
|
|
gang_size, num_replicas = 2, 4
|
|
dsm, ds = self._deploy_gang(
|
|
mock_deployment_state_manager, gang_size, num_replicas
|
|
)
|
|
|
|
# Deploy v2, complete first wave's stops, but leave new replicas STARTING.
|
|
v2 = self._deploy_new_version(dsm, gang_size, num_replicas, "v2")
|
|
dsm.update()
|
|
self._finish_stopping(ds)
|
|
self._mock_gang_pgs(dsm, gang_size, gang_size)
|
|
dsm.update()
|
|
assert (
|
|
ds._replicas.count(version=v2, states=[ReplicaState.STARTING]) == gang_size
|
|
)
|
|
|
|
# Deploy v3 while v2 replicas are still STARTING.
|
|
v3 = self._deploy_new_version(dsm, gang_size, num_replicas, "v3")
|
|
dsm.update()
|
|
assert (
|
|
ds._replicas.count(version=v2, states=[ReplicaState.STOPPING]) == gang_size
|
|
)
|
|
|
|
# State: 2 v1 RUNNING, 2 v2 STOPPING.
|
|
# The rolling-update budget (pending_replicas=2) is already exhausted
|
|
# by the missing slots, so v1 won't be stopped until new v3 replicas
|
|
# fill those slots first.
|
|
|
|
# Wave 1: Finish v2 stops -> update starts 2 v3 (v1 not stopped yet).
|
|
self._finish_stopping(ds)
|
|
self._mock_gang_pgs(dsm, gang_size, gang_size)
|
|
dsm.update()
|
|
assert (
|
|
ds._replicas.count(version=v3, states=[ReplicaState.STARTING]) == gang_size
|
|
)
|
|
assert ds._replicas.count(states=[ReplicaState.STOPPING]) == 0
|
|
|
|
# Wave 2: v3 replicas ready -> update stops the old v1 gang.
|
|
self._finish_starting(ds)
|
|
dsm.update()
|
|
assert (
|
|
ds._replicas.count(version=v3, states=[ReplicaState.RUNNING]) == gang_size
|
|
)
|
|
assert ds._replicas.count(states=[ReplicaState.STOPPING]) == gang_size
|
|
|
|
# Wave 3: Finish v1 stops -> update starts remaining v3 replicas.
|
|
self._finish_stopping(ds)
|
|
self._mock_gang_pgs(dsm, gang_size, gang_size)
|
|
dsm.update()
|
|
self._finish_starting(ds)
|
|
dsm.update()
|
|
|
|
check_counts(
|
|
ds,
|
|
total=num_replicas,
|
|
by_state=[(ReplicaState.RUNNING, num_replicas, v3)],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_multi_gang_stop_per_wave(self, mock_deployment_state_manager):
|
|
"""Validate that multiple gangs are stopped per wave."""
|
|
gang_size, num_replicas = 2, 20
|
|
dsm, ds = self._deploy_gang(
|
|
mock_deployment_state_manager, gang_size, num_replicas
|
|
)
|
|
|
|
self._deploy_new_version(dsm, gang_size, num_replicas, "v2")
|
|
dsm.update()
|
|
|
|
stopping = ds._replicas.get(states=[ReplicaState.STOPPING])
|
|
# rollout_size = max(1, int(0.2 * 20)) = 4
|
|
assert len(stopping) == 4
|
|
stopping_gang_ids = {r.gang_context.gang_id for r in stopping}
|
|
assert len(stopping_gang_ids) == 2
|
|
|
|
def test_recovering_member_skips_gang_update(self, mock_deployment_state_manager):
|
|
"""Gang with a RECOVERING member is skipped during rolling update."""
|
|
gang_size, num_replicas = 2, 4
|
|
dsm, ds = self._deploy_gang(
|
|
mock_deployment_state_manager, gang_size, num_replicas
|
|
)
|
|
|
|
# Move one replica to RECOVERING so its gang is incomplete.
|
|
running = ds._replicas.pop(states=[ReplicaState.RUNNING])
|
|
recovering_replica = running[0]
|
|
for r in running[1:]:
|
|
ds._replicas.add(ReplicaState.RUNNING, r)
|
|
ds._replicas.add(ReplicaState.RECOVERING, recovering_replica)
|
|
|
|
v2 = self._deploy_new_version(dsm, gang_size, num_replicas, "v2")
|
|
dsm.update()
|
|
|
|
# An incomplete gang (with a RECOVERING member) cannot be stopped.
|
|
# Additionally, the budget is insufficient because the RECOVERING
|
|
# replica increases the pending count, preventing any stops.
|
|
assert ds._replicas.count(states=[ReplicaState.STOPPING]) == 0
|
|
|
|
# Recover the replica so the gang is complete again
|
|
recovering_replica._actor.set_ready()
|
|
dsm.update()
|
|
|
|
# Wave 1: first complete gang stops
|
|
stopping = ds._replicas.get(states=[ReplicaState.STOPPING])
|
|
assert len(stopping) == gang_size
|
|
assert len({r.gang_context.gang_id for r in stopping}) == 1
|
|
|
|
# Complete wave 1, trigger wave 2
|
|
self._advance_wave(dsm, ds, gang_size)
|
|
dsm.update()
|
|
|
|
# Wave 2: second gang stops
|
|
assert (
|
|
ds._replicas.count(exclude_version=v2, states=[ReplicaState.STOPPING])
|
|
== gang_size
|
|
)
|
|
|
|
# Complete wave 2
|
|
self._advance_wave(dsm, ds, gang_size)
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=num_replicas,
|
|
by_state=[(ReplicaState.RUNNING, num_replicas, v2)],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
|
|
class TestGangDraining:
|
|
"""Test gang-aware migration when nodes are draining."""
|
|
|
|
def _deploy_gang(
|
|
self, mock_deployment_state_manager, gang_size, num_replicas, nodes
|
|
):
|
|
"""Deploy a gang deployment and assign replicas to nodes round-robin."""
|
|
create_dsm, timer, cluster_node_info_cache, _ = mock_deployment_state_manager
|
|
for node in nodes:
|
|
cluster_node_info_cache.add_node(node)
|
|
dsm: DeploymentStateManager = create_dsm(
|
|
create_placement_group_fn_override=lambda *args, **kwargs: Mock(),
|
|
)
|
|
timer.reset(0)
|
|
info, v = deployment_info(
|
|
num_replicas=num_replicas,
|
|
version="v1",
|
|
graceful_shutdown_timeout_s=20,
|
|
gang_scheduling_config=GangSchedulingConfig(gang_size=gang_size),
|
|
)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
num_gangs = num_replicas // gang_size
|
|
dsm._deployment_scheduler.schedule_gang_placement_groups = Mock(
|
|
return_value={
|
|
TEST_DEPLOYMENT_ID: GangReservationResult(
|
|
success=True,
|
|
gang_pgs=[Mock() for _ in range(num_gangs)],
|
|
gang_ids=[f"gang_{i}" for i in range(num_gangs)],
|
|
gang_pg_names=[f"SERVE_GANG::pg-{i}" for i in range(num_gangs)],
|
|
)
|
|
}
|
|
)
|
|
dsm.update()
|
|
# Assign nodes round-robin and mark ready
|
|
replicas = ds._replicas.get([ReplicaState.STARTING])
|
|
for i, replica in enumerate(replicas):
|
|
replica._actor.set_node_id(nodes[i % len(nodes)])
|
|
replica._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=num_replicas,
|
|
by_state=[(ReplicaState.RUNNING, num_replicas, v)],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
return dsm, ds, timer, cluster_node_info_cache, v
|
|
|
|
def _mock_gang_pgs(self, dsm, gang_size, num_new):
|
|
"""Mock gang PG reservation for new replicas."""
|
|
n = num_new // gang_size
|
|
dsm._deployment_scheduler.schedule_gang_placement_groups = Mock(
|
|
return_value={
|
|
TEST_DEPLOYMENT_ID: GangReservationResult(
|
|
success=True,
|
|
gang_pgs=[Mock() for _ in range(n)],
|
|
gang_ids=[f"new_gang_{i}" for i in range(n)],
|
|
gang_pg_names=[f"SERVE_GANG::pg-new-{i}" for i in range(n)],
|
|
)
|
|
}
|
|
)
|
|
|
|
def test_entire_gang_migration(self, mock_deployment_state_manager):
|
|
"""When gang members' nodes drain, ALL members move to PENDING_MIGRATION.
|
|
When the deadline is up, the entire gang is stopped."""
|
|
gang_size, num_replicas = 2, 4
|
|
node_1 = "node-1"
|
|
node_2 = "node-2"
|
|
node_3 = "node-3" # non-draining target
|
|
# graceful_shutdown_timeout_s=20
|
|
dsm, ds, timer, cache, v1 = self._deploy_gang(
|
|
mock_deployment_state_manager, gang_size, num_replicas, [node_1, node_2]
|
|
)
|
|
cache.add_node(node_3)
|
|
|
|
# Drain both nodes with a 40s deadline, graceful_shutdown_timeout=20s.
|
|
# Gangs are stopped when: curr_time >= deadline - graceful_shutdown_timeout.
|
|
deadline_ms = 40 * 1000
|
|
cache.draining_nodes = {node_1: deadline_ms, node_2: deadline_ms}
|
|
# Block replacements so we can test the deadline path in isolation.
|
|
dsm._deployment_scheduler.schedule_gang_placement_groups = Mock(return_value={})
|
|
dsm.update()
|
|
|
|
assert (
|
|
ds._replicas.count(states=[ReplicaState.PENDING_MIGRATION]) == num_replicas
|
|
)
|
|
|
|
# Advance 15s — still before deadline - timeout (40 - 20 = 20s).
|
|
# Gangs should remain PENDING_MIGRATION (no deadline-triggered stops).
|
|
timer.advance(15)
|
|
dsm.update()
|
|
assert (
|
|
ds._replicas.count(states=[ReplicaState.PENDING_MIGRATION]) == num_replicas
|
|
)
|
|
assert ds._replicas.count(states=[ReplicaState.STOPPING]) == 0
|
|
|
|
# Advance past deadline - graceful_shutdown_timeout (total 25s > 20s).
|
|
# All old gangs should now be stopped due to deadline expiry.
|
|
timer.advance(10)
|
|
dsm.update()
|
|
assert ds._replicas.count(states=[ReplicaState.STOPPING]) == num_replicas
|
|
|
|
for r in ds._replicas.get([ReplicaState.STOPPING]):
|
|
r._actor.set_done_stopping()
|
|
|
|
# Schedule replacements and complete
|
|
self._mock_gang_pgs(dsm, gang_size, num_replicas)
|
|
dsm.update()
|
|
for r in ds._replicas.get([ReplicaState.STARTING]):
|
|
r._actor.set_node_id(node_3)
|
|
r._actor.set_ready()
|
|
dsm.update()
|
|
|
|
check_counts(
|
|
ds,
|
|
total=num_replicas,
|
|
by_state=[(ReplicaState.RUNNING, num_replicas, v1)],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_partial_node_drain_migrates_entire_gang(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""When only one node in a gang is draining, ALL gang members migrate."""
|
|
gang_size, num_replicas = 2, 2
|
|
node_1 = "node-1"
|
|
node_2 = "node-2"
|
|
node_3 = "node-3"
|
|
dsm, ds, timer, cache, v1 = self._deploy_gang(
|
|
mock_deployment_state_manager, gang_size, num_replicas, [node_1, node_2]
|
|
)
|
|
cache.add_node(node_3)
|
|
|
|
# Drain only node_2. The gang has members on both nodes,
|
|
# so ALL members (including the one on healthy node_1) must migrate.
|
|
dsm._deployment_scheduler.schedule_gang_placement_groups = Mock(return_value={})
|
|
cache.draining_nodes = {node_2: 60 * 1000}
|
|
dsm.update()
|
|
|
|
# Both replicas should be PENDING_MIGRATION, not just the one on node_2
|
|
assert (
|
|
ds._replicas.count(states=[ReplicaState.PENDING_MIGRATION]) == num_replicas
|
|
)
|
|
assert ds._replicas.count(states=[ReplicaState.RUNNING]) == 0
|
|
|
|
# Schedule replacements on non-draining node_3
|
|
self._mock_gang_pgs(dsm, gang_size, num_replicas)
|
|
dsm.update()
|
|
for r in ds._replicas.get([ReplicaState.STARTING]):
|
|
r._actor.set_node_id(node_3)
|
|
r._actor.set_ready()
|
|
|
|
# Advance past deadline so old replicas are stopped
|
|
timer.advance(50)
|
|
dsm.update()
|
|
for r in ds._replicas.get([ReplicaState.STOPPING]):
|
|
r._actor.set_done_stopping()
|
|
dsm.update()
|
|
|
|
check_counts(
|
|
ds,
|
|
total=num_replicas,
|
|
by_state=[(ReplicaState.RUNNING, num_replicas, v1)],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_gang_recovery_when_node_stops_draining(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""Gang stays PENDING_MIGRATION while ANY member's node is draining,
|
|
and returns to RUNNING when all members' nodes stop draining."""
|
|
gang_size, num_replicas = 2, 2
|
|
node_1 = "node-1"
|
|
node_2 = "node-2"
|
|
dsm, ds, _, cache, v1 = self._deploy_gang(
|
|
mock_deployment_state_manager, gang_size, num_replicas, [node_1, node_2]
|
|
)
|
|
|
|
# Prevent the scheduler from creating replacements during draining.
|
|
dsm._deployment_scheduler.schedule_gang_placement_groups = Mock(return_value={})
|
|
|
|
# Drain both nodes — all members go to PENDING_MIGRATION.
|
|
cache.draining_nodes = {node_1: 60 * 1000, node_2: 60 * 1000}
|
|
dsm.update()
|
|
assert (
|
|
ds._replicas.count(states=[ReplicaState.PENDING_MIGRATION]) == num_replicas
|
|
)
|
|
|
|
# Stop draining node_1 but keep node_2 draining.
|
|
# The gang still has a member on node_2, so it stays PENDING_MIGRATION.
|
|
cache.draining_nodes = {node_2: 60 * 1000}
|
|
dsm.update()
|
|
assert (
|
|
ds._replicas.count(states=[ReplicaState.PENDING_MIGRATION]) == num_replicas
|
|
)
|
|
|
|
# Stop draining completely — gang should recover to RUNNING.
|
|
cache.draining_nodes = {}
|
|
dsm.update()
|
|
|
|
check_counts(
|
|
ds,
|
|
total=num_replicas,
|
|
by_state=[(ReplicaState.RUNNING, num_replicas, v1)],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_starting_replicas_stopped(self, mock_deployment_state_manager):
|
|
"""STARTING gang members on draining nodes are stopped immediately."""
|
|
gang_size, num_replicas = 2, 2
|
|
node_1 = "node-1"
|
|
node_2 = "node-2"
|
|
create_dsm, timer, cache, _ = mock_deployment_state_manager
|
|
cache.add_node(node_1)
|
|
cache.add_node(node_2)
|
|
dsm: DeploymentStateManager = create_dsm(
|
|
create_placement_group_fn_override=lambda *args, **kwargs: Mock(),
|
|
)
|
|
timer.reset(0)
|
|
info, v1 = deployment_info(
|
|
num_replicas=num_replicas,
|
|
version="v1",
|
|
gang_scheduling_config=GangSchedulingConfig(gang_size=gang_size),
|
|
)
|
|
dsm.deploy(TEST_DEPLOYMENT_ID, info)
|
|
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
|
|
|
|
dsm._deployment_scheduler.schedule_gang_placement_groups = Mock(
|
|
return_value={
|
|
TEST_DEPLOYMENT_ID: GangReservationResult(
|
|
success=True,
|
|
gang_pgs=[Mock()],
|
|
gang_ids=["gang_0"],
|
|
gang_pg_names=["SERVE_GANG::pg-0"],
|
|
)
|
|
}
|
|
)
|
|
dsm.update()
|
|
|
|
# Assign nodes but DON'T mark ready — replicas stay STARTING
|
|
replicas = ds._replicas.get([ReplicaState.STARTING])
|
|
replicas[0]._actor.set_node_id(node_1)
|
|
replicas[1]._actor.set_node_id(node_2)
|
|
|
|
# Drain node_2 while replicas are STARTING
|
|
cache.draining_nodes = {node_2: 60 * 1000}
|
|
dsm.update()
|
|
|
|
# Both STARTING replicas in the gang should be stopped
|
|
stopping = ds._replicas.count(states=[ReplicaState.STOPPING])
|
|
assert stopping == gang_size
|
|
|
|
# Complete stopping and start new replicas
|
|
for r in ds._replicas.get([ReplicaState.STOPPING]):
|
|
r._actor.set_done_stopping()
|
|
self._mock_gang_pgs(dsm, gang_size, num_replicas)
|
|
dsm.update()
|
|
for r in ds._replicas.get([ReplicaState.STARTING]):
|
|
r._actor.set_node_id(node_1)
|
|
r._actor.set_ready()
|
|
dsm.update()
|
|
check_counts(
|
|
ds,
|
|
total=num_replicas,
|
|
by_state=[(ReplicaState.RUNNING, num_replicas, v1)],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
def test_gang_excess_migration_stops_complete_gangs(
|
|
self, mock_deployment_state_manager
|
|
):
|
|
"""When replacement replicas are ready, excess gangs are stopped atomically."""
|
|
gang_size, num_replicas = 2, 4
|
|
node_1 = "node-1"
|
|
node_2 = "node-2"
|
|
dsm, ds, _, cache, v1 = self._deploy_gang(
|
|
mock_deployment_state_manager, gang_size, num_replicas, [node_1]
|
|
)
|
|
cache.add_node(node_2)
|
|
|
|
# Prevent replacements during initial draining
|
|
dsm._deployment_scheduler.schedule_gang_placement_groups = Mock(return_value={})
|
|
|
|
# Drain node_1 with far deadline so no deadline-triggered stops
|
|
cache.draining_nodes = {node_1: 600 * 1000}
|
|
dsm.update()
|
|
assert (
|
|
ds._replicas.count(states=[ReplicaState.PENDING_MIGRATION]) == num_replicas
|
|
)
|
|
|
|
# Start one gang worth of replacements and make them RUNNING
|
|
self._mock_gang_pgs(dsm, gang_size, gang_size)
|
|
dsm.update()
|
|
for r in ds._replicas.get([ReplicaState.STARTING]):
|
|
r._actor.set_node_id(node_2)
|
|
r._actor.set_ready()
|
|
dsm.update()
|
|
|
|
# One complete gang of old replicas should be stopping (excess)
|
|
stopping = ds._replicas.get([ReplicaState.STOPPING])
|
|
assert len(stopping) == gang_size
|
|
# Verify they're all from the same gang
|
|
gang_ids = {r.gang_context.gang_id for r in stopping}
|
|
assert len(gang_ids) == 1
|
|
|
|
# Complete first gang: stop old, start+ready replacement, stop second old gang
|
|
for r in ds._replicas.get([ReplicaState.STOPPING]):
|
|
r._actor.set_done_stopping()
|
|
self._mock_gang_pgs(dsm, gang_size, gang_size)
|
|
dsm.update()
|
|
for r in ds._replicas.get([ReplicaState.STARTING]):
|
|
r._actor.set_node_id(node_2)
|
|
r._actor.set_ready()
|
|
dsm.update()
|
|
|
|
# Clean up the second gang's STOPPING replicas
|
|
for r in ds._replicas.get([ReplicaState.STOPPING]):
|
|
r._actor.set_done_stopping()
|
|
dsm.update()
|
|
|
|
check_counts(
|
|
ds,
|
|
total=num_replicas,
|
|
by_state=[(ReplicaState.RUNNING, num_replicas, v1)],
|
|
)
|
|
assert ds.curr_status_info.status == DeploymentStatus.HEALTHY
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main(["-v", "-s", __file__]))
|