lianetm commented on code in PR #23357:
URL: https://github.com/apache/kafka/pull/23357#discussion_r4037560252


##########
clients/src/main/java/org/apache/kafka/clients/consumer/internals/HeartbeatRequestState.java:
##########
@@ -70,6 +77,17 @@ public void resetTimer() {
 
     public long timeToNextHeartbeatMs(final long currentTimeMs) {
         if (heartbeatTimer.isExpired()) {
+            if (requestInFlight()) {
+                // The timer can be expired while a request is in flight both 
for the first heartbeat (the
+                // interval is initialised to 0 and only learned from the 
first response) and for any later
+                // heartbeat whose response takes longer than the interval. No 
heartbeat can be sent until
+                // the in-flight one completes. The remaining backoff is 
measured from the last response and,
+                // with default settings, is already 0 here, which would 
busy-spin both the application and
+                // network threads. Wait the initial retry backoff (floored at 
1 ms) instead of waiting forever: the network thread still has to notice a 
request timeout promptly
+                // (NetworkClient only checks timed-out requests after 
selector.poll returns, and
+                // ConsumerNetworkThread caps the poll at 5s), so it must come 
back and re-check.
+                return Math.max(MIN_IN_FLIGHT_WAIT_MS, 
exponentialBackoff.initialInterval());

Review Comment:
   let's use the accessor `retryBackoffMs()` ?



##########
clients/src/test/java/org/apache/kafka/clients/consumer/internals/ConsumerHeartbeatRequestManagerTest.java:
##########
@@ -446,6 +451,348 @@ consumerMetadata, mock(BackgroundEventHandler.class), 
false, mock(AsyncConsumerM
         }
     }
 
+    /**
+     * A heartbeat request is in flight and the heartbeat timer is already 
expired. That happens both
+     * while the very first heartbeat is in flight, when the interval is still 
unknown (it is initialised
+     * to 0 and only learned from the first heartbeat response), and later on, 
when a response takes
+     * longer than the interval. In that window no heartbeat can be sent until 
the in-flight one
+     * completes, so both {@link 
NetworkClientDelegate.PollResult#timeUntilNextPollMs} and
+     * {@link AbstractHeartbeatRequestManager#maximumTimeToWait(long)} must 
return a positive delay;
+     * returning 0 busy-spins the consumer network thread and the application 
thread until the in-flight
+     * request completes, which can be as long as request.timeout.ms when the 
coordinator is unreachable.
+     */
+    @ParameterizedTest
+    @ValueSource(longs = {0, 5000})
+    public void testMaximumTimeToWaitWhileHeartbeatInFlightDoesNotSpin(final 
long heartbeatIntervalMs) {
+        createHeartbeatRequestStateWithHeartbeatInterval(heartbeatIntervalMs);
+        // The member keeps joining for both intervals, so the heartbeat below 
is sent without waiting for
+        // the interval and the total simulated time stays under 
max.poll.interval.ms.
+        when(membershipManager.state()).thenReturn(MemberState.JOINING);
+        when(membershipManager.shouldHeartbeatNow()).thenReturn(true);
+        if (heartbeatIntervalMs > 0) {
+            // A non-zero interval is only known after a heartbeat response, 
which also arms the backoff.
+            heartbeatRequestState.onSuccessfulAttempt(time.milliseconds());
+        }

Review Comment:
   why only for hb interval > 0? `onSuccessfulAttempt` happens when we send , 
not when we receive the response (so I expect it's right to call it no matter 
the HB interval we have here?)
   



##########
clients/src/main/java/org/apache/kafka/clients/consumer/internals/HeartbeatRequestState.java:
##########
@@ -28,6 +28,13 @@
  */
 public class HeartbeatRequestState extends RequestState {
 
+    /**
+     * Lower bound for the wait returned while a heartbeat is in flight. 
retry.backoff.ms and
+     * retry.backoff.max.ms both accept 0, and callers use this value as a 
poll timeout, so it must
+     * never be 0.
+     */
+    private static final long MIN_IN_FLIGHT_WAIT_MS = 1L;

Review Comment:
   I get the point with this, but it introduces an inconsistency, so I would 
say we either fix consistently (all req managers) or don't (we haven't used any 
floor in any of the other paths fixed IIRC)
   
   `retry.backoff.ms=0` would be a very extreme/unexpected config setting I 
expect, so I wouldn't rush in any extra logic/complexity in the managers to 
guard against it, wdyt? We can always file something to review if it's worth 
any change but address it consistently
   



##########
clients/src/test/java/org/apache/kafka/clients/consumer/internals/ConsumerHeartbeatRequestManagerTest.java:
##########
@@ -446,6 +451,348 @@ consumerMetadata, mock(BackgroundEventHandler.class), 
false, mock(AsyncConsumerM
         }
     }
 
+    /**
+     * A heartbeat request is in flight and the heartbeat timer is already 
expired. That happens both
+     * while the very first heartbeat is in flight, when the interval is still 
unknown (it is initialised
+     * to 0 and only learned from the first heartbeat response), and later on, 
when a response takes
+     * longer than the interval. In that window no heartbeat can be sent until 
the in-flight one
+     * completes, so both {@link 
NetworkClientDelegate.PollResult#timeUntilNextPollMs} and
+     * {@link AbstractHeartbeatRequestManager#maximumTimeToWait(long)} must 
return a positive delay;
+     * returning 0 busy-spins the consumer network thread and the application 
thread until the in-flight
+     * request completes, which can be as long as request.timeout.ms when the 
coordinator is unreachable.
+     */
+    @ParameterizedTest
+    @ValueSource(longs = {0, 5000})
+    public void testMaximumTimeToWaitWhileHeartbeatInFlightDoesNotSpin(final 
long heartbeatIntervalMs) {
+        createHeartbeatRequestStateWithHeartbeatInterval(heartbeatIntervalMs);
+        // The member keeps joining for both intervals, so the heartbeat below 
is sent without waiting for
+        // the interval and the total simulated time stays under 
max.poll.interval.ms.
+        when(membershipManager.state()).thenReturn(MemberState.JOINING);
+        when(membershipManager.shouldHeartbeatNow()).thenReturn(true);
+        if (heartbeatIntervalMs > 0) {
+            // A non-zero interval is only known after a heartbeat response, 
which also arms the backoff.

Review Comment:
   this comment is a bit confusing too, as `onSuccessfulAttempt` happens when 
we send (maybe remove if my other comment makes sense and we end up calling 
this regardless of the interval



##########
clients/src/test/java/org/apache/kafka/clients/consumer/internals/ConsumerHeartbeatRequestManagerTest.java:
##########
@@ -446,6 +451,348 @@ consumerMetadata, mock(BackgroundEventHandler.class), 
false, mock(AsyncConsumerM
         }
     }
 
+    /**
+     * A heartbeat request is in flight and the heartbeat timer is already 
expired. That happens both
+     * while the very first heartbeat is in flight, when the interval is still 
unknown (it is initialised
+     * to 0 and only learned from the first heartbeat response), and later on, 
when a response takes
+     * longer than the interval. In that window no heartbeat can be sent until 
the in-flight one
+     * completes, so both {@link 
NetworkClientDelegate.PollResult#timeUntilNextPollMs} and
+     * {@link AbstractHeartbeatRequestManager#maximumTimeToWait(long)} must 
return a positive delay;
+     * returning 0 busy-spins the consumer network thread and the application 
thread until the in-flight
+     * request completes, which can be as long as request.timeout.ms when the 
coordinator is unreachable.
+     */
+    @ParameterizedTest
+    @ValueSource(longs = {0, 5000})
+    public void testMaximumTimeToWaitWhileHeartbeatInFlightDoesNotSpin(final 
long heartbeatIntervalMs) {
+        createHeartbeatRequestStateWithHeartbeatInterval(heartbeatIntervalMs);
+        // The member keeps joining for both intervals, so the heartbeat below 
is sent without waiting for
+        // the interval and the total simulated time stays under 
max.poll.interval.ms.
+        when(membershipManager.state()).thenReturn(MemberState.JOINING);
+        when(membershipManager.shouldHeartbeatNow()).thenReturn(true);
+        if (heartbeatIntervalMs > 0) {
+            // A non-zero interval is only known after a heartbeat response, 
which also arms the backoff.
+            heartbeatRequestState.onSuccessfulAttempt(time.milliseconds());
+        }
+
+        NetworkClientDelegate.PollResult firstResult = 
heartbeatRequestManager.poll(time.milliseconds());
+        assertEquals(1, firstResult.unsentRequests.size(),
+            "A heartbeat should be sent as soon as the coordinator is known");
+
+        // Deliberately do not complete the request, so it stays in flight 
while the heartbeat timer expires.
+        time.sleep(heartbeatIntervalMs + 1);
+
+        NetworkClientDelegate.PollResult secondResult = 
heartbeatRequestManager.poll(time.milliseconds());
+        assertEquals(0, secondResult.unsentRequests.size(),
+            "No heartbeat should be sent while another one is in flight");
+        assertTrue(secondResult.timeUntilNextPollMs > 0,
+            "timeUntilNextPollMs must be > 0 while a heartbeat is in flight to 
avoid a busy-spin; got "
+                + secondResult.timeUntilNextPollMs);
+        assertEquals(DEFAULT_RETRY_BACKOFF_MS, 
secondResult.timeUntilNextPollMs);
+
+        long result = 
heartbeatRequestManager.maximumTimeToWait(time.milliseconds());
+        assertTrue(result > 0,
+            "maximumTimeToWait must be > 0 while a heartbeat is in flight to 
avoid a busy-spin; got " + result);
+        // maximumTimeToWait is min(pollTimer.remainingMs() / 2, retry 
backoff), and half of the remaining
+        // max.poll.interval.ms is still larger than the backoff at this point.
+        assertEquals(DEFAULT_RETRY_BACKOFF_MS, result);
+    }
+
+    /**
+     * The "response slower than the interval" way of reaching the same 
window, driven end to end through the
+     * manager instead of by priming the request state directly. The member 
joins, learns its heartbeat interval
+     * from a real successful heartbeat response, becomes STABLE, and then 
sends its steady-state heartbeat when
+     * the interval elapses. That response never arrives, so the heartbeat 
timer expires again while the request
+     * is still in flight. This complements
+     * {@link #testMaximumTimeToWaitWhileHeartbeatInFlightDoesNotSpin(long)}, 
which constructs the request state
+     * with a known interval, by proving that the interval learned through
+     * {@code onResponse -> updateHeartbeatIntervalMs} lands the manager in 
exactly the same state: no heartbeat
+     * can be sent, and both {@link 
NetworkClientDelegate.PollResult#timeUntilNextPollMs} and
+     * {@link AbstractHeartbeatRequestManager#maximumTimeToWait(long)} must 
return a positive delay rather than
+     * busy-spinning the application and network threads.
+     */
+    @Test
+    public void 
testMaximumTimeToWaitWhenResponseIsSlowerThanIntervalDoesNotSpin() {
+        // The interval is unknown until the first heartbeat response, exactly 
as on a freshly created consumer.
+        createHeartbeatRequestStateWithZeroHeartbeatInterval();
+        when(membershipManager.state()).thenReturn(MemberState.JOINING);
+        when(membershipManager.shouldHeartbeatNow()).thenReturn(true);
+
+        NetworkClientDelegate.PollResult joinResult = 
heartbeatRequestManager.poll(time.milliseconds());
+        assertEquals(1, joinResult.unsentRequests.size(),
+            "A heartbeat should be sent as soon as the coordinator is known");
+
+        // A real successful response teaches the manager the interval and 
clears the in-flight flag.
+        joinResult.unsentRequests.get(0).handler().onComplete(
+            createHeartbeatResponse(joinResult.unsentRequests.get(0), 
Errors.NONE, DEFAULT_HEARTBEAT_INTERVAL_MS));
+        assertEquals(DEFAULT_HEARTBEAT_INTERVAL_MS, 
heartbeatRequestState.heartbeatIntervalMs(),
+            "The heartbeat interval should have been learned from the 
heartbeat response");
+
+        // The membership manager is a mock, so onHeartbeatSuccess does not 
move it; stub the joined member state.
+        when(membershipManager.state()).thenReturn(MemberState.STABLE);
+        when(membershipManager.shouldHeartbeatNow()).thenReturn(false);
+        when(membershipManager.shouldSkipHeartbeat()).thenReturn(false);
+
+        // The interval elapses, so the steady-state heartbeat is sent. 
Deliberately leave it in flight.
+        time.sleep(DEFAULT_HEARTBEAT_INTERVAL_MS);
+        NetworkClientDelegate.PollResult heartbeatResult = 
heartbeatRequestManager.poll(time.milliseconds());
+        assertEquals(1, heartbeatResult.unsentRequests.size(),
+            "A heartbeat should be sent once the heartbeat interval has 
expired");
+
+        // The response is slower than the interval, so the heartbeat timer 
expires again while it is in flight.
+        time.sleep(DEFAULT_HEARTBEAT_INTERVAL_MS + 1);
+
+        NetworkClientDelegate.PollResult inFlightResult = 
heartbeatRequestManager.poll(time.milliseconds());
+        assertEquals(0, inFlightResult.unsentRequests.size(),
+            "No heartbeat should be sent while another one is in flight");
+        assertTrue(inFlightResult.timeUntilNextPollMs > 0,
+            "timeUntilNextPollMs must be > 0 while a heartbeat is in flight to 
avoid a busy-spin; got "
+                + inFlightResult.timeUntilNextPollMs);
+        assertEquals(DEFAULT_RETRY_BACKOFF_MS, 
inFlightResult.timeUntilNextPollMs);
+
+        long result = 
heartbeatRequestManager.maximumTimeToWait(time.milliseconds());
+        assertTrue(result > 0,
+            "maximumTimeToWait must be > 0 while a heartbeat is in flight to 
avoid a busy-spin; got " + result);
+        // maximumTimeToWait is min(pollTimer.remainingMs() / 2, retry 
backoff), and half of the remaining
+        // max.poll.interval.ms is still larger than the backoff at this point.
+        assertEquals(DEFAULT_RETRY_BACKOFF_MS, result);
+    }
+
+    /**
+     * The same busy-spin, driven through a real {@link NetworkClient} on a 
{@link MockSelector} and a real
+     * {@link NetworkClientDelegate}, so that the heartbeat really is in 
flight rather than only marked as such.

Review Comment:
   uhm do we really need this test? We're unit testing the req manager and we 
already tested the in-flight case above, but seems this test is to cover the 
same case but going beyond to the other component (network client)? If so I 
would say we shouldn't get there and remove this?



##########
clients/src/test/java/org/apache/kafka/clients/consumer/internals/ConsumerHeartbeatRequestManagerTest.java:
##########
@@ -446,6 +451,348 @@ consumerMetadata, mock(BackgroundEventHandler.class), 
false, mock(AsyncConsumerM
         }
     }
 
+    /**
+     * A heartbeat request is in flight and the heartbeat timer is already 
expired. That happens both
+     * while the very first heartbeat is in flight, when the interval is still 
unknown (it is initialised
+     * to 0 and only learned from the first heartbeat response), and later on, 
when a response takes
+     * longer than the interval. In that window no heartbeat can be sent until 
the in-flight one
+     * completes, so both {@link 
NetworkClientDelegate.PollResult#timeUntilNextPollMs} and
+     * {@link AbstractHeartbeatRequestManager#maximumTimeToWait(long)} must 
return a positive delay;
+     * returning 0 busy-spins the consumer network thread and the application 
thread until the in-flight
+     * request completes, which can be as long as request.timeout.ms when the 
coordinator is unreachable.
+     */
+    @ParameterizedTest
+    @ValueSource(longs = {0, 5000})
+    public void testMaximumTimeToWaitWhileHeartbeatInFlightDoesNotSpin(final 
long heartbeatIntervalMs) {
+        createHeartbeatRequestStateWithHeartbeatInterval(heartbeatIntervalMs);
+        // The member keeps joining for both intervals, so the heartbeat below 
is sent without waiting for
+        // the interval and the total simulated time stays under 
max.poll.interval.ms.
+        when(membershipManager.state()).thenReturn(MemberState.JOINING);
+        when(membershipManager.shouldHeartbeatNow()).thenReturn(true);
+        if (heartbeatIntervalMs > 0) {
+            // A non-zero interval is only known after a heartbeat response, 
which also arms the backoff.
+            heartbeatRequestState.onSuccessfulAttempt(time.milliseconds());
+        }
+
+        NetworkClientDelegate.PollResult firstResult = 
heartbeatRequestManager.poll(time.milliseconds());
+        assertEquals(1, firstResult.unsentRequests.size(),
+            "A heartbeat should be sent as soon as the coordinator is known");
+
+        // Deliberately do not complete the request, so it stays in flight 
while the heartbeat timer expires.
+        time.sleep(heartbeatIntervalMs + 1);
+
+        NetworkClientDelegate.PollResult secondResult = 
heartbeatRequestManager.poll(time.milliseconds());
+        assertEquals(0, secondResult.unsentRequests.size(),
+            "No heartbeat should be sent while another one is in flight");
+        assertTrue(secondResult.timeUntilNextPollMs > 0,
+            "timeUntilNextPollMs must be > 0 while a heartbeat is in flight to 
avoid a busy-spin; got "
+                + secondResult.timeUntilNextPollMs);
+        assertEquals(DEFAULT_RETRY_BACKOFF_MS, 
secondResult.timeUntilNextPollMs);
+
+        long result = 
heartbeatRequestManager.maximumTimeToWait(time.milliseconds());
+        assertTrue(result > 0,
+            "maximumTimeToWait must be > 0 while a heartbeat is in flight to 
avoid a busy-spin; got " + result);
+        // maximumTimeToWait is min(pollTimer.remainingMs() / 2, retry 
backoff), and half of the remaining
+        // max.poll.interval.ms is still larger than the backoff at this point.
+        assertEquals(DEFAULT_RETRY_BACKOFF_MS, result);
+    }
+
+    /**
+     * The "response slower than the interval" way of reaching the same 
window, driven end to end through the
+     * manager instead of by priming the request state directly. The member 
joins, learns its heartbeat interval
+     * from a real successful heartbeat response, becomes STABLE, and then 
sends its steady-state heartbeat when
+     * the interval elapses. That response never arrives, so the heartbeat 
timer expires again while the request
+     * is still in flight. This complements
+     * {@link #testMaximumTimeToWaitWhileHeartbeatInFlightDoesNotSpin(long)}, 
which constructs the request state
+     * with a known interval, by proving that the interval learned through
+     * {@code onResponse -> updateHeartbeatIntervalMs} lands the manager in 
exactly the same state: no heartbeat
+     * can be sent, and both {@link 
NetworkClientDelegate.PollResult#timeUntilNextPollMs} and
+     * {@link AbstractHeartbeatRequestManager#maximumTimeToWait(long)} must 
return a positive delay rather than
+     * busy-spinning the application and network threads.
+     */
+    @Test
+    public void 
testMaximumTimeToWaitWhenResponseIsSlowerThanIntervalDoesNotSpin() {
+        // The interval is unknown until the first heartbeat response, exactly 
as on a freshly created consumer.
+        createHeartbeatRequestStateWithZeroHeartbeatInterval();
+        when(membershipManager.state()).thenReturn(MemberState.JOINING);
+        when(membershipManager.shouldHeartbeatNow()).thenReturn(true);
+
+        NetworkClientDelegate.PollResult joinResult = 
heartbeatRequestManager.poll(time.milliseconds());
+        assertEquals(1, joinResult.unsentRequests.size(),
+            "A heartbeat should be sent as soon as the coordinator is known");
+
+        // A real successful response teaches the manager the interval and 
clears the in-flight flag.
+        joinResult.unsentRequests.get(0).handler().onComplete(
+            createHeartbeatResponse(joinResult.unsentRequests.get(0), 
Errors.NONE, DEFAULT_HEARTBEAT_INTERVAL_MS));
+        assertEquals(DEFAULT_HEARTBEAT_INTERVAL_MS, 
heartbeatRequestState.heartbeatIntervalMs(),
+            "The heartbeat interval should have been learned from the 
heartbeat response");
+
+        // The membership manager is a mock, so onHeartbeatSuccess does not 
move it; stub the joined member state.
+        when(membershipManager.state()).thenReturn(MemberState.STABLE);
+        when(membershipManager.shouldHeartbeatNow()).thenReturn(false);
+        when(membershipManager.shouldSkipHeartbeat()).thenReturn(false);
+
+        // The interval elapses, so the steady-state heartbeat is sent. 
Deliberately leave it in flight.
+        time.sleep(DEFAULT_HEARTBEAT_INTERVAL_MS);
+        NetworkClientDelegate.PollResult heartbeatResult = 
heartbeatRequestManager.poll(time.milliseconds());
+        assertEquals(1, heartbeatResult.unsentRequests.size(),
+            "A heartbeat should be sent once the heartbeat interval has 
expired");
+
+        // The response is slower than the interval, so the heartbeat timer 
expires again while it is in flight.
+        time.sleep(DEFAULT_HEARTBEAT_INTERVAL_MS + 1);
+
+        NetworkClientDelegate.PollResult inFlightResult = 
heartbeatRequestManager.poll(time.milliseconds());
+        assertEquals(0, inFlightResult.unsentRequests.size(),
+            "No heartbeat should be sent while another one is in flight");
+        assertTrue(inFlightResult.timeUntilNextPollMs > 0,
+            "timeUntilNextPollMs must be > 0 while a heartbeat is in flight to 
avoid a busy-spin; got "
+                + inFlightResult.timeUntilNextPollMs);
+        assertEquals(DEFAULT_RETRY_BACKOFF_MS, 
inFlightResult.timeUntilNextPollMs);
+
+        long result = 
heartbeatRequestManager.maximumTimeToWait(time.milliseconds());
+        assertTrue(result > 0,
+            "maximumTimeToWait must be > 0 while a heartbeat is in flight to 
avoid a busy-spin; got " + result);
+        // maximumTimeToWait is min(pollTimer.remainingMs() / 2, retry 
backoff), and half of the remaining
+        // max.poll.interval.ms is still larger than the backoff at this point.

Review Comment:
   if my [comment 
above](https://github.com/apache/kafka/pull/23357/changes#r4037628360)  mnakes 
sense we need to update here too.
   
   Actually maybe helpful to encapsulate the 2 assertion blocks (the 2 tests 
generate different scenarios but have the same expectations timeToNextPoll, ln 
552-557 here and maximumTimeToWait, ln 544-550 right?)



##########
clients/src/main/java/org/apache/kafka/clients/consumer/internals/HeartbeatRequestState.java:
##########
@@ -70,6 +77,17 @@ public void resetTimer() {
 
     public long timeToNextHeartbeatMs(final long currentTimeMs) {
         if (heartbeatTimer.isExpired()) {
+            if (requestInFlight()) {
+                // The timer can be expired while a request is in flight both 
for the first heartbeat (the
+                // interval is initialised to 0 and only learned from the 
first response) and for any later
+                // heartbeat whose response takes longer than the interval. No 
heartbeat can be sent until
+                // the in-flight one completes. The remaining backoff is 
measured from the last response and,
+                // with default settings, is already 0 here, which would 
busy-spin both the application and
+                // network threads. Wait the initial retry backoff (floored at 
1 ms) instead of waiting forever: the network thread still has to notice a 
request timeout promptly
+                // (NetworkClient only checks timed-out requests after 
selector.poll returns, and
+                // ConsumerNetworkThread caps the poll at 5s), so it must come 
back and re-check.

Review Comment:
   could we simplify this long comment? 
   
   Probably ok to just highlight the "why" (timer can be expired while 
in-flight, and remaining backoff can be already 0, measured from the last 
response), so we return backoff to check again after a bit and avoid busy-spin)



##########
clients/src/test/java/org/apache/kafka/clients/consumer/internals/ConsumerHeartbeatRequestManagerTest.java:
##########
@@ -446,6 +451,348 @@ consumerMetadata, mock(BackgroundEventHandler.class), 
false, mock(AsyncConsumerM
         }
     }
 
+    /**
+     * A heartbeat request is in flight and the heartbeat timer is already 
expired. That happens both
+     * while the very first heartbeat is in flight, when the interval is still 
unknown (it is initialised
+     * to 0 and only learned from the first heartbeat response), and later on, 
when a response takes
+     * longer than the interval. In that window no heartbeat can be sent until 
the in-flight one
+     * completes, so both {@link 
NetworkClientDelegate.PollResult#timeUntilNextPollMs} and
+     * {@link AbstractHeartbeatRequestManager#maximumTimeToWait(long)} must 
return a positive delay;
+     * returning 0 busy-spins the consumer network thread and the application 
thread until the in-flight
+     * request completes, which can be as long as request.timeout.ms when the 
coordinator is unreachable.
+     */
+    @ParameterizedTest
+    @ValueSource(longs = {0, 5000})
+    public void testMaximumTimeToWaitWhileHeartbeatInFlightDoesNotSpin(final 
long heartbeatIntervalMs) {
+        createHeartbeatRequestStateWithHeartbeatInterval(heartbeatIntervalMs);
+        // The member keeps joining for both intervals, so the heartbeat below 
is sent without waiting for
+        // the interval and the total simulated time stays under 
max.poll.interval.ms.
+        when(membershipManager.state()).thenReturn(MemberState.JOINING);
+        when(membershipManager.shouldHeartbeatNow()).thenReturn(true);
+        if (heartbeatIntervalMs > 0) {
+            // A non-zero interval is only known after a heartbeat response, 
which also arms the backoff.
+            heartbeatRequestState.onSuccessfulAttempt(time.milliseconds());
+        }
+
+        NetworkClientDelegate.PollResult firstResult = 
heartbeatRequestManager.poll(time.milliseconds());
+        assertEquals(1, firstResult.unsentRequests.size(),
+            "A heartbeat should be sent as soon as the coordinator is known");
+
+        // Deliberately do not complete the request, so it stays in flight 
while the heartbeat timer expires.
+        time.sleep(heartbeatIntervalMs + 1);
+
+        NetworkClientDelegate.PollResult secondResult = 
heartbeatRequestManager.poll(time.milliseconds());
+        assertEquals(0, secondResult.unsentRequests.size(),
+            "No heartbeat should be sent while another one is in flight");
+        assertTrue(secondResult.timeUntilNextPollMs > 0,
+            "timeUntilNextPollMs must be > 0 while a heartbeat is in flight to 
avoid a busy-spin; got "
+                + secondResult.timeUntilNextPollMs);
+        assertEquals(DEFAULT_RETRY_BACKOFF_MS, 
secondResult.timeUntilNextPollMs);
+
+        long result = 
heartbeatRequestManager.maximumTimeToWait(time.milliseconds());
+        assertTrue(result > 0,
+            "maximumTimeToWait must be > 0 while a heartbeat is in flight to 
avoid a busy-spin; got " + result);
+        // maximumTimeToWait is min(pollTimer.remainingMs() / 2, retry 
backoff), and half of the remaining
+        // max.poll.interval.ms is still larger than the backoff at this point.

Review Comment:
   is this comment needed or could we remove? seems like it can easily become 
stale if we make any changes to the implementation.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to