unknowntpo opened a new pull request, #23357:
URL: https://github.com/apache/kafka/pull/23357

   The async consumer, the share consumer and the streams consumer
   busy-spin on both the network thread and the application thread while a
   group heartbeat request is in flight and the heartbeat timer has
   expired. This happens on the first heartbeat of every consumer instance,
   and on any later heartbeat whose response takes longer than the
   heartbeat interval. Both threads spin until the response or a failure
   arrives.
   
   ### How it happens
   
   `HeartbeatRequestState` resets the heartbeat timer when a heartbeat
   request is generated and marked in flight (it may still be queued for an
   unreachable host). While that request is in flight the manager cannot
   send another one, so `poll()` and `maximumTimeToWait()` fall back to
   `timeToNextHeartbeatMs()`. If the timer is expired at that point, it
   returns `remainingBackoffMs()`, which is measured from the last response
   and, with default settings, is already 0.
   
   The timer is expired while a request is in flight in two cases:
   
   - **First heartbeat.** `heartbeatIntervalMs` is initialised to 0 and
   only known from the first
     response, so `resetTimer()` leaves the timer expired immediately.
   - **Slow response.** The interval is known (say 5 s) but the response
   takes longer than that,
     for example a coordinator in a long GC pause. The timer expires with
   the request still in
     flight.
   
   The heartbeat managers use that value for both wait times:
   
   - `poll()` returns `PollResult(0)`, so `ConsumerNetworkThread` calls
   `client.poll(0)` in a
     tight loop.
   - `maximumTimeToWait()` returns `min(pollTimer.remainingMs() / 2, 0) =
   0`, so the application
     thread does not block in `poll()` and wakes the network thread on
   every iteration.
   
   The guards added by KAFKA-20253 and KAFKA-20970 check
   `coordinator().isEmpty()`. They do not apply here because the
   coordinator is already known.
   
   ### When it happens and why it matters
   
   The heartbeat interval lives in the client, so the window is per
   consumer instance: it opens when a new `KafkaConsumer` sends its first
   heartbeat and closes at the first heartbeat response. Later rejoins of
   the same instance (rebalance, fencing, session timeout) do not reopen it
   because the interval is already known. A coordinator move on its own
   does not open it either; an already-joined consumer goes through the
   coordinator-unknown path and rejoins with a known interval.
   
   What makes the window long is the coordinator not answering the first
   heartbeat. Until it does, every attempt spins for the whole in-flight
   time and only rests during the retry backoff between attempts:
   
   ```
   heartbeat sent
     |
     +-- spin until the response, or until request.timeout.ms
           |
           +-- failure: coordinator unknown, retry backoff,
               rediscovery, heartbeat resent
                 |
                 +-- spin again ... until the first response arrives
   ```
   
   Ordered by impact:
   
   1. **Consumer restarts while the coordinator is moving.** A rolling
   broker upgrade moves the
      coordinator role between brokers. Consumer pods often restart in the
   same window (same
      pipeline, autoscaling, liveness probes reacting to broker churn).
   Every restarted consumer
      is a new instance, and its first heartbeat goes to a coordinator that
   accepts the
      connection but does not answer, or to a host that is not reachable
   yet. Both threads spin
      until `request.timeout.ms` (30 s), or until the connection attempt
   fails if the host is not
      reachable; the failure marks the coordinator unknown, the normal
   retry and rediscovery apply, and the next heartbeat
      spins again until it is answered, for as long as the failover takes.
   2. **Coordinator overloaded or in a long GC pause.** Any consumer whose
   heartbeat response
      takes longer than the broker-provided heartbeat interval (5 s by
   default) spins for the rest of the pause,
      up to `request.timeout.ms`. This does not need a restart;
   already-joined consumers are
      affected too.
   3. **Coordinator not reachable from the client.** Wrong
   `advertised.listeners` (common with
      Docker and Kubernetes), or a firewall that only allows the bootstrap
   broker. The consumer
      never recovers and repeats the cycle above. Request and connection
   timeouts are logged at
      INFO/WARN, but nothing identifies the busy loop; the visible symptom
   is high CPU and no
      assignment, which is easy to misread as a load problem.
   4. **Every normal start.** A few milliseconds of spin per instance on a
   healthy cluster. Not
      visible, but it shows the path is taken by every consumer.
   
   Case 3 is a misconfiguration, but the client is designed to retry
   forever because it cannot tell a transient failure from a permanent one.
   While it retries it should wait quietly. Today it keeps two threads busy
   per consumer, which starves co-located workloads and hides the real
   cause.
   
   ### Fix
   
   `HeartbeatRequestState.timeToNextHeartbeatMs()` returns the effective
   initial retry backoff
   (`min(retry.backoff.ms, retry.backoff.max.ms)`, 100 ms by default,
   without jitter, floored at
   1 ms) when the timer is expired and a request is in flight, instead of
   the remaining backoff. In that state nothing
   can be sent until the in-flight request completes, so the managers
   re-check every backoff
   interval. This is the in-flight wait only; after a failure the normal
   jittered, exponential retry
   and coordinator rediscovery apply. The change is complementary to
   KAFKA-21010 (#23348): a
   request timeout marks the coordinator unknown and moves the client from
   this known-coordinator,
   in-flight state to the unknown-coordinator state that #23348 (pending)
   targets.
   
   The change does not alter the heartbeat cadence: in the normal case the
   timer is reset with the known interval at send time and is not expired
   while the request is in flight, so this branch is never taken. It only
   replaces the 0 in the two cases above.
   
   One change covers the consumer, share and streams heartbeat managers,
   and avoids adding a third special case next to the existing guards in
   `maximumTimeToWait()`.
   
   #### Why the retry backoff
   
   Values considered for this state:
   
   - **0 (trunk).** Busy loop.
   - **`heartbeatIntervalMs`.** It is 0 in the first-heartbeat case, which
   is how KAFKA-21010
     happened.
   - **`Long.MAX_VALUE` ("wait for I/O only").** `NetworkClient.poll()`
   only detects a timed-out
     request after `selector.poll()` returns, and `ConsumerNetworkThread`
   caps that poll at 5 s, so a
     heartbeat that hits `request.timeout.ms` would be noticed up to 5 s
   late, delaying coordinator
     rediscovery.
   - **`retry.backoff.ms`.** Bounded: a request timeout is noticed within
   one backoff. The cost is
     one wakeup per backoff on both threads while a heartbeat is in flight,
   which in the healthy
     case is a few milliseconds per join. It is also the value KAFKA-21010
   (#23348) uses for the
     coordinator-unknown guard, so the heartbeat managers wait the same way
   in both "cannot send"
     and "already sent" states. `retry.backoff.ms` and
   `retry.backoff.max.ms` both accept 0, so
     the value is floored at 1 ms (`MIN_IN_FLIGHT_WAIT_MS`); the sibling
   guards in #23348 do not
     floor, which is worth aligning there.
   
   The typed version of this decision (a manager saying "awaiting input"
   instead of a number) is what KIP-1371 proposes; this change keeps the
   current `long` contract.
   
   ### Testing
   
   All eleven new regression-test invocations fail on trunk and pass with
   the fix (verified on trunk plus the test commit alone, same failures on
   every run). They use the same shapes as the tests added by KAFKA-20253
   and KAFKA-21010.
   
   -
   
`HeartbeatRequestStateTest.testTimeToNextHeartbeatMsWhileRequestInFlightDoesNotSpin`,
     parameterised over a heartbeat interval of 0 (first heartbeat) and
   5000 (response slower
     than the interval), plus `...WithZeroRetryBackoffDoesNotSpin` for a
   zero backoff. They isolate
     the root cause on the state object and assert the exact value
   (`retry.backoff.ms`, or the
     1 ms floor as a literal), in the style of the KAFKA-20253 tests.
   - `ConsumerHeartbeatRequestManagerTest`,
   `ShareHeartbeatRequestManagerTest` and
   
`StreamsGroupHeartbeatRequestManagerTest.testMaximumTimeToWaitWhileHeartbeatInFlightDoesNotSpin`,
     parameterised the same way. They send a heartbeat, leave it in flight
   past the interval, and
     assert that `poll()` sends nothing and returns `retry.backoff.ms` as
   `timeUntilNextPollMs`,
     and that `maximumTimeToWait()` returns the same.
   -
   
`ConsumerHeartbeatRequestManagerTest.testMaximumTimeToWaitDoesNotSpinWhileHeartbeatInFlightOnRealNetworkClient`,
     parameterised the same way, uses the same wiring as
     `testMaximumTimeToWaitDoesNotSpinDuringRealBootstrapDnsResolution`
   (real `NetworkClient` on
     `MockSelector`, real `ConsumerMetadata` and `NetworkClientDelegate`)
   with a known coordinator.
     The heartbeat is really sent and never answered. The network poll is
   driven with the manager's
     own wait time, as `ConsumerNetworkThread.runOnce()` does, so the mock
   clock only advances when
     that wait is positive: the test bounds the number of polls while the
   request is in flight
     (about one per retry backoff) and fails with an iteration-count
   message on trunk, where the
     clock never moves. It then checks that the request times out at
   `request.timeout.ms`, the
     in-flight state is cleared, and a second heartbeat is sent.
   
   Related: KAFKA-20253 (#22836), KAFKA-20970 (#23227), KAFKA-21010
   (#23348). Those cover the coordinator-unknown cases; this change covers
   the request-in-flight case, which they do not reach. The bug is present
   since 3.7.0 (KAFKA-15278, KAFKA-15890; the send-time timer reset since
   3.8.0, KAFKA-16528) and is not a 4.4 regression. Trunk is
   4.5.0-SNAPSHOT; since 4.4 is still in the RC phase, including this in
   the next 4.4 RC is proposed, plus cherry-picks to 4.3 and 4.2 as was
   done for KAFKA-20253.
   
   Generated-by: Claude Fable 5.1 Claude-Session:
   https://claude.ai/code/session_015fJAXoLEZWgC25avikRtzg
   


-- 
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