@@ -160,7 +160,13 @@ public void testUnhealthyNodeDetectedByHB() throws Exception {
160160 CompletableFuture <TransportClientResponse > requestFuture = new CompletableFuture <>();
161161 ChainedCompletableFuture <Integer , Integer > chainedRequestFuture =
162162 monitor .trackHealthBasedOnRequestToInstance (instance , requestFuture );
163- Thread .sleep (1500 ); // must exceed routingRequestDefaultTimeoutMS (1000ms) to trigger unhealthy detection
163+ // Wait for the routing request to time out (it completes the request future) before completing the request,
164+ // so the timeout is not cancelled and the instance is marked suspicious.
165+ TestUtils .waitForNonDeterministicAssertion (
166+ 5 ,
167+ TimeUnit .SECONDS ,
168+ true ,
169+ () -> assertTrue (requestFuture .isCompletedExceptionally (), "routing request should have timed out" ));
164170 requestFuture .complete (null );
165171 chainedRequestFuture .getOriginalFuture ().complete (SC_GONE );
166172 // Pending request counter will be reset with a delay
@@ -198,38 +204,14 @@ public void testUnhealthyNodeDetectedByHB() throws Exception {
198204 */
199205 @ Test
200206 public void testUpdateLiveInstanceSetEvictsHostRemovedFromFleet () throws Exception {
201- Map <String , Long > requestPathToResponseDelayMap = new VeniceConcurrentHashMap <>();
202- Map <String , CompletableFuture <RestResponse >> requestPathToResponseFutureMap = new VeniceConcurrentHashMap <>();
203- CompletableFuture <RestResponse > hbResponseFuture =
204- CompletableFuture .completedFuture (new RestResponseBuilder ().setStatus (SC_OK ).build ());
205- String hbPath = instance + "/" + QueryAction .HEALTH .toString ().toLowerCase ();
206- requestPathToResponseFutureMap .put (hbPath , hbResponseFuture );
207- // a large delay forces every heartbeat to this instance to time out, so it keeps failing
208- requestPathToResponseDelayMap .put (hbPath , 10000L );
209- MockClient client = new MockClient (requestPathToResponseDelayMap , requestPathToResponseFutureMap );
210-
211- InstanceHealthMonitorConfig config = InstanceHealthMonitorConfig .builder ()
212- .setRoutingRequestDefaultTimeoutMS (1000L )
213- .setRoutingPendingRequestCounterInstanceBlockThreshold (10 )
214- .setHeartBeatIntervalSeconds (1 )
215- .setHeartBeatRequestTimeoutMS (100L )
216- .setRoutingTimedOutRequestCounterResetDelayMS (2000 )
217- .setClient (client )
218- .build ();
219-
220- try (InstanceHealthMonitor monitor = new InstanceHealthMonitor (config )) {
221- // Drive the instance into the unhealthy set via a timed-out user request + a failing heartbeat.
222- CompletableFuture <TransportClientResponse > requestFuture = new CompletableFuture <>();
223- ChainedCompletableFuture <Integer , Integer > chainedRequestFuture =
224- monitor .trackHealthBasedOnRequestToInstance (instance , requestFuture );
225- Thread .sleep (1500 ); // must exceed routingRequestDefaultTimeoutMS (1000ms) to trigger unhealthy detection
226- requestFuture .complete (null );
227- chainedRequestFuture .getOriginalFuture ().complete (SC_GONE );
207+ try (InstanceHealthMonitor monitor = new InstanceHealthMonitor (failingHeartbeatConfig ())) {
208+ // A request to the host hangs, so the routing timeout + failing heartbeat mark it unhealthy.
209+ monitor .trackHealthBasedOnRequestToInstance (instance , new CompletableFuture <>());
228210 TestUtils .waitForNonDeterministicAssertion (
229211 5 ,
230212 TimeUnit .SECONDS ,
231213 true ,
232- () -> assertFalse (monitor .isInstanceHealthy (instance ), "instance should be marked unhealthy" ));
214+ () -> assertFalse (monitor .isInstanceHealthy (instance ), "slow host should be marked unhealthy" ));
233215 assertEquals (monitor .getUnhealthyInstanceCount (), 1 );
234216
235217 // A refresh whose serving set still contains the host keeps it tracked (a live-but-unhealthy host).
@@ -255,6 +237,64 @@ public void testUpdateLiveInstanceSetEvictsHostRemovedFromFleet() throws Excepti
255237 }
256238 }
257239
240+ /**
241+ * Unhealthy-host lifecycle: a slow host is marked unhealthy by the monitor, evicted when it leaves the serving set,
242+ * then rejoins clean.
243+ */
244+ @ Test
245+ public void testUnhealthyHostEvictedThenRejoinsClean () throws Exception {
246+ try (InstanceHealthMonitor monitor = new InstanceHealthMonitor (failingHeartbeatConfig ())) {
247+ // Host is in the cluster; a request to it hangs, so the routing timeout + failing heartbeat mark it unhealthy.
248+ monitor .updateLiveInstanceSet (Collections .singleton (instance ));
249+ monitor .trackHealthBasedOnRequestToInstance (instance , new CompletableFuture <>());
250+ TestUtils .waitForNonDeterministicAssertion (5 , TimeUnit .SECONDS , true , () -> {
251+ assertFalse (monitor .isInstanceHealthy (instance ), "slow host should be marked unhealthy" );
252+ assertEquals (monitor .getUnhealthyInstanceCount (), 1 );
253+ });
254+
255+ // Host leaves the cluster: the next serving set drops it, so the monitor evicts it.
256+ monitor .updateLiveInstanceSet (Collections .singleton ("https://other.host:4321" ));
257+ TestUtils .waitForNonDeterministicAssertion (5 , TimeUnit .SECONDS , true , () -> {
258+ assertTrue (monitor .isInstanceHealthy (instance ), "departed host should no longer be tracked" );
259+ assertEquals (monitor .getUnhealthyInstanceCount (), 0 );
260+ });
261+
262+ // The same host returns and starts clean.
263+ monitor .updateLiveInstanceSet (Collections .singleton (instance ));
264+ assertTrue (monitor .isInstanceHealthy (instance ), "rejoined host should start clean" );
265+ assertEquals (monitor .getUnhealthyInstanceCount (), 0 );
266+ }
267+ }
268+
269+ /**
270+ * Healthy-host lifecycle: even a host that never went unhealthy has its tracking state evicted when it leaves the
271+ * serving set, and rejoins clean.
272+ */
273+ @ Test
274+ public void testHealthyHostEvictedThenRejoinsClean () throws Exception {
275+ InstanceHealthMonitorConfig config =
276+ InstanceHealthMonitorConfig .builder ().setRoutingRequestDefaultTimeoutMS (10000L ).build ();
277+ try (InstanceHealthMonitor monitor = new InstanceHealthMonitor (config )) {
278+ // Host is in the cluster and serves a successful request, so it is tracked (a drained counter) and healthy.
279+ monitor .updateLiveInstanceSet (Collections .singleton (instance ));
280+ ChainedCompletableFuture <Integer , Integer > chainedFuture = monitor .trackHealthBasedOnRequestToInstance (instance );
281+ chainedFuture .getOriginalFuture ().complete (SC_OK );
282+ waitQuietly (chainedFuture .getResultFuture ());
283+ assertTrue (monitor .isInstanceHealthy (instance ));
284+ assertTrue (monitor .hasPendingRequestCounter (instance ));
285+
286+ // Host leaves the cluster: even though it is healthy, its tracking state is evicted.
287+ monitor .updateLiveInstanceSet (Collections .singleton ("https://other.host:4321" ));
288+ assertTrue (monitor .isInstanceHealthy (instance ));
289+ assertFalse (monitor .hasPendingRequestCounter (instance ));
290+
291+ // The same host returns clean.
292+ monitor .updateLiveInstanceSet (Collections .singleton (instance ));
293+ assertTrue (monitor .isInstanceHealthy (instance ));
294+ assertFalse (monitor .hasPendingRequestCounter (instance ));
295+ }
296+ }
297+
258298 /**
259299 * A drained (zero) pending-request counter for a departed host is evicted; a counter for a live host or one with an
260300 * in-flight request is kept so accounting stays correct.
@@ -292,6 +332,24 @@ public void testUpdateLiveInstanceSetEvictsDrainedPendingRequestCounters() throw
292332 }
293333 }
294334
335+ /** Config whose heartbeat to {@link #instance} always times out, so the instance stays unhealthy once tracked. */
336+ private static InstanceHealthMonitorConfig failingHeartbeatConfig () {
337+ Map <String , CompletableFuture <RestResponse >> futureMap = new VeniceConcurrentHashMap <>();
338+ Map <String , Long > delayMap = new VeniceConcurrentHashMap <>();
339+ String hbPath = instance + "/" + QueryAction .HEALTH .toString ().toLowerCase ();
340+ futureMap .put (hbPath , CompletableFuture .completedFuture (new RestResponseBuilder ().setStatus (SC_OK ).build ()));
341+ // a large delay forces every heartbeat to this instance to time out, so it keeps failing
342+ delayMap .put (hbPath , 10000L );
343+ return InstanceHealthMonitorConfig .builder ()
344+ .setRoutingRequestDefaultTimeoutMS (1000L )
345+ .setRoutingPendingRequestCounterInstanceBlockThreshold (10 )
346+ .setHeartBeatIntervalSeconds (1 )
347+ .setHeartBeatRequestTimeoutMS (100L )
348+ .setRoutingTimedOutRequestCounterResetDelayMS (2000 )
349+ .setClient (new MockClient (delayMap , futureMap ))
350+ .build ();
351+ }
352+
295353 private void waitQuietly (CompletableFuture future ) throws InterruptedException {
296354 try {
297355 future .get ();
0 commit comments