We found this one the embarrassing way: a seeded demo stream sat inREBALANCING for a day. Not failing — rebalancing. The UI said so. The Streams client said so. The only place the truth existed was a server log nobody was reading.
The trap
Deploy a Kafka Streams topology whose source topic doesn't exist , and here's the exact sequence (Kafka clients 4.x, but the behaviour is old):
- The group leader's assignment fails with
INCOMPLETE_SOURCE_TOPIC_METADATA; the member receives the error in its assignment. - The
StreamThreadlogsMissingSourceTopicException, transitionsPENDING_SHUTDOWN → DEAD, and does not retry. This is deliberate — a missing source topic is not a transient condition Kafka Streams can wait out. - Crucially, the client-level state machine never follows. The
StreamsUncaughtExceptionHandlerisn't consulted (the thread shut down; it didn't throw), so there's noPENDING_ERROR → ERRORtransition.KafkaStreams.state()last saw a rebalance start, and that's where it stays.
The result: a KafkaStreams instance with zero live threads that reportsREBALANCING indefinitely. It will never process a record, never error, and never change state again.
Why every Kafka UI has this bug
If your tool renders KafkaStreams.state() — and that's the obvious, documented thing to render — you have this bug. The state enum simply has no value for "all my threads are dead but nobody told the coordinator layer."REBALANCING is the truthful answer to the wrong question.
The signal that does exist is one call away:metadataForLocalThreads() returns per-thread metadata including each thread's state. A client reporting REBALANCING whose thread set is empty — or whose threads are all DEAD — is not rebalancing. It's gone.
The fix, in two halves
Surface it. We derive the displayed state instead of trusting the raw one:
static KafkaStreams.State effectiveState(KafkaStreams.State state,
Collection<ThreadMetadata> threads) {
if (state == KafkaStreams.State.REBALANCING
&& (threads.isEmpty()
|| threads.stream().allMatch(t -> "DEAD".equals(t.threadState())))) {
return KafkaStreams.State.ERROR;
}
return state;
}
Genuine rebalances have live threads (STARTING, PARTITIONS_ASSIGNED, …) and pass through untouched. In our end-to-end test, deleting a running stream's source topic flips the reported state to ERROR within seconds — where before it showed REBALANCING until someone read the log.
Prevent the common case. The most frequent way to hit this is a typo'd topic name at deploy time. Since a missing source topic is unrecoverable by design, we now validate every source node's topics against the cluster before building the topology, and fail the deploy with the missing names — one listTopics() round-trip. (Fail-open if the listing itself errors: a broker hiccup shouldn't block a deploy that would have succeeded; the state derivation above is the backstop.)
Neither half needs anything from the broker that isn't already public API.
Takeaways
-
KafkaStreams.state()is the state of the coordinator conversation, not the health of your processing. Dead threads don't move it. - If you're operating Kafka Streams with your own dashboards: alert on thread liveness (
metadataForLocalThreads(), or thealive-stream-threadsmetric), not onstate() != RUNNING. - If you're building a tool: derive, don't relay. The raw state is truthful and useless at the same time.
Both fixes shipped in Alginte 0.7.0. The stuck demo that taught us this now recovers in seconds — and deploying against a typo'd topic tells you the topic's name instead of miming a rebalance.
Top comments (0)