Skip to content

Commit 7ba0799

Browse files
Rewrite the gate tests with dependency injection
Review: the tests built a DeploymentState via __new__ (bypassing __init__) and injected Mocks plus private attributes (_replicas, _rank_manager, _last_rank_membership_ids). That couples them to internal field names and stubs out the logic under test. They now follow the pattern used elsewhere in this file: a real DeploymentStateManager from the mock_deployment_state_manager fixture, driven through deploy()/update() with MockReplicaActorWrapper, and a single injected collaborator -- a DeploymentRankManager subclass that counts consistency passes and delegates to the real implementation, patched at the same seam the fixture uses for the actor wrappers. Real rank logic, observable call counts, no private writes. Membership changes are triggered by a scale-up redeploy rather than a health check failure: a failed health check leaves the deployment UNHEALTHY with a replica stuck in STOPPING, and the gate requires HEALTHY, so those assertions were unsatisfiable. 235 tests pass; disabling the gate fails test_skips_while_membership_unchanged. Signed-off-by: john.taylor <john.taylor@anyscale.com>
1 parent e8615cc commit 7ba0799

1 file changed

Lines changed: 127 additions & 102 deletions

File tree

python/ray/serve/tests/unit/test_deployment_state.py

Lines changed: 127 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -10146,114 +10146,139 @@ def test_dirty_set_gauge_prunes_ids_no_longer_running(
1014610146
assert ds._last_health_check_healthy_replica_ids == running_ids
1014710147

1014810148

10149+
def _scale_to(dsm, ds, num_replicas, version="1", ticks=14):
10150+
"""Change membership via the public deploy path and settle back to HEALTHY."""
10151+
info, _ = deployment_info(num_replicas=num_replicas, version=version)
10152+
dsm.deploy(TEST_DEPLOYMENT_ID, info)
10153+
for _ in range(ticks):
10154+
dsm.update()
10155+
for replica in ds._replicas.get([ReplicaState.STARTING]):
10156+
replica._actor.set_ready()
10157+
if (
10158+
ds._curr_status_info.status == DeploymentStatus.HEALTHY
10159+
and ds._replicas.count(states=[ReplicaState.STARTING]) == 0
10160+
):
10161+
dsm.update()
10162+
return
10163+
raise AssertionError(
10164+
"deployment never settled: status=%s running=%d starting=%d"
10165+
% (
10166+
ds._curr_status_info.status,
10167+
ds._replicas.count(states=[ReplicaState.RUNNING]),
10168+
ds._replicas.count(states=[ReplicaState.STARTING]),
10169+
)
10170+
)
10171+
10172+
10173+
class CountingRankManager(ds_mod.DeploymentRankManager):
10174+
"""Real rank manager that records how often the consistency pass runs.
10175+
10176+
Injected at the same seam the fixture uses for actor wrappers, so the rank logic under
10177+
test stays real; only the call count is added.
10178+
"""
10179+
10180+
instances = []
10181+
10182+
def __init__(self, *args, **kwargs):
10183+
super().__init__(*args, **kwargs)
10184+
self.consistency_calls = 0
10185+
self.raise_next = False
10186+
CountingRankManager.instances.append(self)
10187+
10188+
def check_rank_consistency_and_reassign_minimally(self, active_replicas):
10189+
self.consistency_calls += 1
10190+
if self.raise_next:
10191+
self.raise_next = False
10192+
# Same shape as a real invariant violation: swallowed when
10193+
# fail_on_rank_error is off (the production default).
10194+
return self._execute_with_error_handling(
10195+
lambda: (_ for _ in ()).throw(RuntimeError("injected rank failure")), []
10196+
)
10197+
return super().check_rank_consistency_and_reassign_minimally(active_replicas)
10198+
10199+
10200+
@pytest.fixture
10201+
def rank_gate_dsm(mock_deployment_state_manager, monkeypatch):
10202+
"""A running deployment whose rank manager counts consistency passes."""
10203+
CountingRankManager.instances = []
10204+
monkeypatch.setattr(ds_mod, "DeploymentRankManager", CountingRankManager)
10205+
10206+
create_dsm, _, _, _ = mock_deployment_state_manager
10207+
dsm: DeploymentStateManager = create_dsm()
10208+
info, v1 = deployment_info(num_replicas=3, version="1")
10209+
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
10210+
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
10211+
10212+
dsm.update()
10213+
for replica in ds._replicas.get():
10214+
replica._actor.set_ready()
10215+
dsm.update() # STARTING -> RUNNING
10216+
check_counts(ds, total=3, by_state=[(ReplicaState.RUNNING, 3, v1)])
10217+
10218+
# Settle: the deployment reaches HEALTHY a tick or two after the replicas do, and the
10219+
# gate legitimately runs once for this membership. Tests measure from after that.
10220+
for _ in range(8):
10221+
dsm.update()
10222+
assert ds._curr_status_info.status == DeploymentStatus.HEALTHY
10223+
assert (
10224+
ds._rank_manager.consistency_calls >= 1
10225+
), "gate never ran for a new membership"
10226+
return dsm, ds, ds._rank_manager
10227+
10228+
1014910229
class TestRankConsistencyMembershipGate:
1015010230
"""The rank-consistency pass runs only when replica membership changes."""
1015110231

10152-
def _ds(self, uids):
10153-
ds = ds_mod.DeploymentState.__new__(ds_mod.DeploymentState)
10154-
replicas = []
10155-
for uid in uids:
10156-
r = Mock()
10157-
r.replica_id.unique_id = uid
10158-
replicas.append(r)
10159-
ds._replicas = Mock()
10160-
ds._replicas.get.return_value = replicas
10161-
ds._replicas.count.return_value = 0
10162-
ds._curr_status_info = Mock(status=DeploymentStatus.HEALTHY)
10163-
ds._rank_manager = Mock()
10164-
ds._rank_manager.last_rank_op_errored = False
10165-
ds._rank_manager.check_rank_consistency_and_reassign_minimally.return_value = []
10166-
ds._reconfigure_replicas_with_new_ranks = Mock()
10167-
ds._last_rank_membership_ids = None
10168-
return ds
10232+
def test_skips_while_membership_unchanged(self, rank_gate_dsm):
10233+
dsm, _, rank_manager = rank_gate_dsm
10234+
after_startup = rank_manager.consistency_calls
10235+
for _ in range(5):
10236+
dsm.update()
10237+
assert rank_manager.consistency_calls == after_startup
1016910238

10170-
def test_runs_once_then_skips_for_same_membership(self):
10171-
ds = self._ds(["a", "b", "c"])
10172-
ds._maybe_check_rank_consistency()
10173-
ds._maybe_check_rank_consistency()
10174-
ds._maybe_check_rank_consistency()
10175-
assert (
10176-
ds._rank_manager.check_rank_consistency_and_reassign_minimally.call_count
10177-
== 1
10178-
)
10239+
def test_reruns_when_a_replica_leaves(self, rank_gate_dsm):
10240+
dsm, ds, rank_manager = rank_gate_dsm
10241+
before = rank_manager.consistency_calls
1017910242

10180-
def test_membership_change_reruns(self):
10181-
ds = self._ds(["a", "b", "c"])
10182-
ds._maybe_check_rank_consistency()
10183-
new = []
10184-
for uid in ["a", "b", "d"]:
10185-
r = Mock()
10186-
r.replica_id.unique_id = uid
10187-
new.append(r)
10188-
ds._replicas.get.return_value = new
10189-
ds._maybe_check_rank_consistency()
10190-
assert (
10191-
ds._rank_manager.check_rank_consistency_and_reassign_minimally.call_count
10192-
== 2
10193-
)
10243+
# Membership change through the public path: scale up adds a new replica id.
10244+
_scale_to(dsm, ds, 4)
1019410245

10195-
def test_swallowed_rank_error_reruns_next_tick(self):
10196-
# fail_on_rank_error off: the pass can swallow an error and return [].
10197-
# The membership must NOT be cached, so the next tick retries.
10198-
ds = self._ds(["a", "b", "c"])
10199-
ds._rank_manager.last_rank_op_errored = True
10200-
ds._maybe_check_rank_consistency()
10201-
assert ds._last_rank_membership_ids is None
10202-
ds._maybe_check_rank_consistency()
10203-
assert (
10204-
ds._rank_manager.check_rank_consistency_and_reassign_minimally.call_count
10205-
== 2
10206-
)
10207-
# Once it succeeds, the membership is cached and the pass stops re-running.
10208-
ds._rank_manager.last_rank_op_errored = False
10209-
ds._maybe_check_rank_consistency()
10210-
ds._maybe_check_rank_consistency()
10211-
assert (
10212-
ds._rank_manager.check_rank_consistency_and_reassign_minimally.call_count
10213-
== 3
10214-
)
10215-
10216-
def _real_manager_ds(self, uids, node_id="node-1"):
10217-
"""Gate wired to a real DeploymentRankManager with error-swallowing on (the
10218-
production default), primed so one active replica has no node mapping -- which
10219-
trips the consistency impl's `assert node_id is not None`."""
10220-
ds = self._ds(uids)
10221-
mgr = ds_mod.DeploymentRankManager(fail_on_rank_error=False)
10222-
for uid in uids:
10223-
mgr.assign_rank(uid, node_id)
10224-
del mgr._replica_to_node[uids[-1]]
10225-
ds._rank_manager = mgr
10226-
return ds, mgr
10227-
10228-
def test_real_manager_swallowed_error_is_not_cached(self):
10229-
# RAY_SERVE_FAIL_ON_RANK_ERROR defaults off, so this is the production path:
10230-
# the pass logs, returns [], and the membership must NOT be cached.
10231-
ds, mgr = self._real_manager_ds(["a", "b"])
10232-
10233-
ds._maybe_check_rank_consistency()
10234-
10235-
assert mgr.last_rank_op_errored is True
10236-
assert ds._last_rank_membership_ids is None
10237-
10238-
def test_error_flag_read_before_reconfigure(self):
10239-
# Guards a latent hazard, not a live bug: today safe_default is [] and the
10240-
# reconfigure early-returns on an empty list. Stub it to call back into the
10241-
# manager -- get_replica_rank() clears the flag, so reading it after the
10242-
# reconfigure would see False and wrongly cache an unvalidated membership.
10243-
ds, mgr = self._real_manager_ds(["a", "b"])
10244-
ds._reconfigure_replicas_with_new_ranks = lambda _: mgr.get_replica_rank("a")
10245-
10246-
ds._maybe_check_rank_consistency()
10247-
10248-
assert mgr.last_rank_op_errored is False # cleared by the reconfigure call
10249-
assert ds._last_rank_membership_ids is None # ...yet still not cached
10250-
10251-
def test_starting_replicas_skip_entirely(self):
10252-
ds = self._ds(["a", "b"])
10253-
ds._replicas.count.return_value = 1
10254-
ds._maybe_check_rank_consistency()
10255-
ds._rank_manager.check_rank_consistency_and_reassign_minimally.assert_not_called()
10256-
assert ds._last_rank_membership_ids is None
10246+
assert rank_manager.consistency_calls > before
10247+
10248+
def test_swallowed_error_is_rechecked_next_tick(self, rank_gate_dsm):
10249+
"""fail_on_rank_error is off by default in production, so the pass can log and
10250+
return a safe default. That membership must not be cached as validated."""
10251+
dsm, ds, rank_manager = rank_gate_dsm
10252+
10253+
# Membership change makes the gate run; that run errors and is swallowed.
10254+
rank_manager.raise_next = True
10255+
_scale_to(dsm, ds, 4)
10256+
errored_at = rank_manager.consistency_calls
10257+
assert errored_at > 0, "the errored pass never ran"
10258+
10259+
# An errored pass must not be cached as validated, so it is retried even though
10260+
# membership is now stable.
10261+
for _ in range(4):
10262+
dsm.update()
10263+
assert rank_manager.consistency_calls > errored_at
10264+
10265+
def test_starting_replicas_skip_the_pass(
10266+
self, mock_deployment_state_manager, monkeypatch
10267+
):
10268+
"""A STARTING replica has no rank yet, so running the pass would raise
10269+
"active keys without ranks"; the guard must skip before that."""
10270+
CountingRankManager.instances = []
10271+
monkeypatch.setattr(ds_mod, "DeploymentRankManager", CountingRankManager)
10272+
create_dsm, _, _, _ = mock_deployment_state_manager
10273+
dsm: DeploymentStateManager = create_dsm()
10274+
info, _ = deployment_info(num_replicas=2, version="1")
10275+
assert dsm.deploy(TEST_DEPLOYMENT_ID, info)
10276+
ds = dsm._deployment_states[TEST_DEPLOYMENT_ID]
10277+
10278+
dsm.update() # replicas are STARTING, never marked ready
10279+
dsm.update()
10280+
check_counts(ds, total=2, by_state=[(ReplicaState.STARTING, 2, None)])
10281+
assert ds._rank_manager.consistency_calls == 0
1025710282

1025810283

1025910284
if __name__ == "__main__":

0 commit comments

Comments
 (0)