@@ -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+
1014910229class 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
1025910284if __name__ == "__main__" :
0 commit comments