Stanislav Savulchik created KAFKA-20819:
-------------------------------------------

             Summary: StreamThread hangs forever in TaskManager.waitForFuture() 
after its StateUpdater thread dies, silently dropping out of the consumer group
                 Key: KAFKA-20819
                 URL: https://issues.apache.org/jira/browse/KAFKA-20819
             Project: Kafka
          Issue Type: Bug
          Components: streams
    Affects Versions: 3.9.2
            Reporter: Stanislav Savulchik


> *Note:* This report was drafted by an AI assistant (GitHub Copilot CLI) based 
> on
> production evidence (broker logs, thread dump, per-thread application logs, 
> task
> assignment snapshot) supplied by the reporter. All log excerpts and 
> line-number
> references below were verified against the actual evidence files and the 
> Kafka 3.9
> source tree; the reporter has reviewed and confirmed the analysis before 
> filing.

h2. Summary

A Kafka Streams {{StateUpdater}} thread can terminate on its own (its {{run()}} 
loop exits)
without the owning {{StreamThread}}, {{TaskManager}}, or any supervisor ever 
finding out.
{{DefaultStateUpdater.add()}}/{{remove()}} have no check for whether the thread 
is still
alive: they unconditionally enqueue the request and, for {{remove()}}, return a
{{CompletableFuture}} that only the {{StateUpdaterThread}} can complete. If 
that thread is
already dead, the future is never completed.

{{TaskManager.waitForFuture()}} ({{TaskManager.java}} lines ~700-720 on the 3.9 
branch) then
blocks on that future with a plain, *unbounded* {{future.get()}} (no timeout 
argument):

{code:java}removedTaskResult = future.get();
{code}

This call happens on the {{KafkaConsumer.poll()}} thread, inside the rebalance 
listener
callback ({{onPartitionsRevoked}} → {{TaskManager.handleRevocation()}} →
{{revokeTasksInStateUpdater()}} → {{waitForFuture()}}). Because {{poll()}} 
never returns while
blocked there, the consumer stops sending heartbeats/JoinGroup requests, and 
the broker's
group coordinator eventually evicts the member on session-timeout — __while the 
JVM
thread itself is still alive__, parked in {{WAITING}}, with no exception and no 
crash. Basic
liveness checks (thread.isAlive(), process health checks) report the instance 
as healthy.

h3. Environment / configuration
* Kafka Streams 3.9.2
* {{processing.guarantee}} = {{at_least_once}} (this is the intended/correct 
mode for this
  application; not exactly-once)
* Topology deployed across 12 application instances, {{num.stream.threads}} = 2 
per
  instance (24 StreamThreads total)
* {{num.standby.replicas}} = 1
* Default (KIP-892) {{DefaultStateUpdater}} enabled — one dedicated 
{{StateUpdater-N}} thread
  per {{StreamThread}}
* Large RocksDB-backed state stores, standby/warmup replicas configured

h3. Observed sequence (production evidence)
# *2026-07-19 ~07:01–07:04* — a transient full network outage makes the broker
   cluster unreachable from one application instance ({{production-2}}). Both 
of its
   StreamThreads (and their restore consumers) see repeated {{Disconnecting 
from node …
   due to request/socket connection setup timeout}} across many broker nodes at 
the same
   time — this affects {{StreamThread-1}}/{{StateUpdater-1}} and 
{{StreamThread-2}}/
   {{StateUpdater-2}} simultaneously.
# {{StateUpdater-1}} (belonging to the healthy {{StreamThread-1}}) keeps 
retrying broker
   connections for several more minutes and eventually recovers; 
{{StreamThread-1}} is
   healthy today.
# {{StateUpdater-2}} (belonging to {{StreamThread-2}}) does *not* recover the 
same way.
   Its restore consumer logs {{Unsubscribed all topics or patterns and assigned
   partitions}} at {{07:04:51.883}}, and one second later, at {{07:04:52.933}},
   {{DefaultStateUpdater}} logs:
   {code}   state-updater 
[sputnik-friend-locator-state-engine-production-2-StateUpdater-2] State updater 
thread stopped
   {code}
   This is the unconditional {{finally}}-block log line in
   {{DefaultStateUpdater$StateUpdaterThread.run()}}. Critically, *neither* of 
the two
   code paths that are supposed to precede it appear anywhere in the captured 
logs for
   this event:
** no {{"Shutting down state updater thread"}} (the log line the public
     {{shutdown(Duration)}} method emits before setting {{isRunning=false}}), 
and
** no {{"An unexpected error occurred within the state updater thread: …"}} (the
     {{ERROR}}-level line {{handleRuntimeException()}} emits before setting
     {{isRunning=false}}).

   This suggests the thread's {{run()}} loop exited via an uncaught 
{{Throwable}} that is
   _not_ a {{RuntimeException}} (e.g. an {{Error}}) — which bypasses the
   {{catch (RuntimeException)}} block entirely (so {{handleRuntimeException}} 
never runs and
   never logs), while the {{finally}} block still executes 
({{clearInputQueue()}},
   {{clearUpdatingAndPausedTasks()}}, {{shutdownGate.countDown()}}, and the 
"State updater
   thread stopped" log). Such a {{Throwable}} propagating out of 
{{Thread.run()}} is handled
   by the default {{Thread.UncaughtExceptionHandler}}, which prints to 
{{System.err}} — a
   stream that was not captured in this evidence bundle (only stdout was 
captured), so we
   cannot pinpoint the exact {{Throwable}}. This gap is noted as a caveat; 
regardless of the
   trigger, the architectural problem below holds for any path that ends the
   {{StateUpdaterThread}} without going through {{shutdown(Duration)}}.
# {{StateUpdater-2}} never restarts. No new thread by that name appears again.
# Roughly *7 hours later*, at {{14:14:48}}, {{StreamThread-2}} 
(group-instance-id
   {{…production-2-2}}) performs a fresh {{JoinGroup}} and is briefly 
re-admitted to the
   consumer group (generation 28757, then a couple more generations through 
{{14:19:57}}).
# At {{14:20:57}}, the broker {{GroupCoordinator}} removes that member again:
   {{"Member …production-2-2-e3dc728b… in group … has failed, removing it from 
the
   group"}} (heartbeat/session-timeout expiration). The group stabilizes one 
member
   short and *never re-admits it again* — it is still short one member days 
later.
# A thread dump taken *~2 days later* (2026-07-21) still shows 
{{StreamThread-2}} parked
   at exactly this call chain:
   {code}   
java.util.concurrent.CompletableFuture.get(CompletableFuture.java:2072)
   
org.apache.kafka.streams.processor.internals.TaskManager.waitForFuture(TaskManager.java:704)
   
org.apache.kafka.streams.processor.internals.TaskManager.lambda$getNonFailedTasks$10(TaskManager.java:667)
   ...
   
org.apache.kafka.streams.processor.internals.TaskManager.revokeTasksInStateUpdater(TaskManager.java:1215)
   
org.apache.kafka.streams.processor.internals.TaskManager.handleRevocation(TaskManager.java:1114)
   
org.apache.kafka.streams.processor.internals.StreamsRebalanceListener.onPartitionsRevoked(StreamsRebalanceListener.java:98)
   
org.apache.kafka.clients.consumer.internals.ConsumerCoordinator.onJoinComplete(ConsumerCoordinator.java:402)
   
org.apache.kafka.clients.consumer.internals.AbstractCoordinator.joinGroupIfNeeded(AbstractCoordinator.java:504)
   
org.apache.kafka.clients.consumer.internals.AbstractCoordinator.ensureActiveGroup(AbstractCoordinator.java:415)
   
org.apache.kafka.clients.consumer.internals.ConsumerCoordinator.poll(ConsumerCoordinator.java:511)
   
org.apache.kafka.streams.processor.internals.StreamThread.pollRequests(StreamThread.java:1280)
   {code}
   i.e. it is stuck in the exact revoke path described above, waiting on a
   {{CompletableFuture}} from a {{StateUpdater-2}} thread that stopped existing 
2 days
   earlier. There is no {{StateUpdater-2}} thread anywhere in that thread dump 
— only
   {{StateUpdater-1}}, which belongs to the healthy {{StreamThread-1}} and is 
legitimately
   {{RUNNABLE}}, actively restoring tasks.
# Consequence: the client-side task-assignment view still lists 
{{StreamThread-2}} with
   only stale STANDBY/WARMUP tasks and *zero active tasks*, while the broker's
   consumer-group listing shows one fewer member than the number of live 
StreamThreads —
   a discrepancy that is very hard to diagnose operationally, since there is no 
error, no
   crash, and no metric distinguishing "healthy idle thread" from "livelocked 
thread that
   fell out of the group because its state updater died."

h3. Relationship to existing tickets

This shares the same underlying hazard — a {{StreamThread}} blocking on a
{{StateUpdater}}-completed future during a rebalance listener callback — as:
* KAFKA-19853 — *StreamThread blocks on StateUpdater during 
{{onAssignment()}}*, causing
  transaction timeouts under EOS. Same blocking pattern, different callback
  ({{onAssignment}} vs. {{onPartitionsRevoked}}) and different trigger (slow 
restore vs. a
  dead thread).
* KAFKA-20456 (resolved) / KAFKA-20721 (open) — {{waitForFuture()}} timing out 
(after a
  *5-minute bounded* {{future.get(5, TimeUnit.MINUTES)}}, added for 
4.1.2+/4.3.0) and
  producing an {{IllegalStateException}} ("ISE" — 
{{java.lang.IllegalStateException}}, thrown
  when a removal future resolves to {{null}}).
* KAFKA-18355 / KAFKA-17789 — other cases of a Streams-internal thread blocking
  indefinitely / getting stuck.

What appears to be new here, specific to 3.9.x:
* {{TaskManager.waitForFuture()}} in 3.9.2 has **no timeout at all** (confirmed 
by reading
  the 3.9 branch source directly), so instead of eventually throwing the ISE 
described in
  KAFKA-20456/20721, the {{StreamThread}} hangs *forever*.
* The trigger is not "the state updater is busy/slow" but that **the state 
updater
  thread is confirmed dead__ (absent from the thread dump, with an explicit 
"State
  updater thread stopped" log and no subsequent restart), and nothing in 
{{TaskManager}} or
  {{DefaultStateUpdater}} detects or reports that condition before a later 
caller blocks on
  a future that thread can no longer fulfill.

h3. Suggested direction
* Bound {{TaskManager.waitForFuture()}} (all call sites) with a timeout tied to
  {{max.poll.interval.ms}} / the rebalance timeout, and fail loudly (log + 
crash/replace the
  {{StreamThread}}) rather than allowing an indefinite park.
* Have {{DefaultStateUpdater.add()}}/{{remove()}} check whether 
{{stateUpdaterThread}} is still
  alive (or fail the returned future immediately) instead of silently enqueuing 
work for a
  thread that has already exited.
* Ensure {{StateUpdaterThread.run()}} cannot exit without going through a 
single,
  well-logged path: catch {{Throwable}} (not just {{RuntimeException}}) so 
unexpected {{Error}}s
  are logged at {{ERROR}} before the thread stops, and/or install a
  {{Thread.UncaughtExceptionHandler}} on the {{StateUpdaterThread}} so any 
uncaught fatal error
  is guaranteed to reach the structured application logs (not just 
{{System.err}}).
* Consider a metric/log line when a {{StreamThread}} misses heartbeats due to a 
stuck
  rebalance-listener callback, to make this diagnosable without a thread dump.

h2. Related issues

KAFKA-19853, KAFKA-20456, KAFKA-20721, KAFKA-18355, KAFKA-17789

h2. Attachments

None at filing time. Evidence files (coordinator logs, task-assignment CSV,
consumer-group state, thread dump, and the per-thread {{StateUpdater-2}} log) 
are available
and can be attached later on request — recommend redacting 
instance/consumer-group names
first, since this is a public ASF Jira.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to