Repository: apache/kafka
Pull request #19544: KAFKA-19190: Handle shutdown application correctly
Author: login: lucasbru
Labels: name: streams
name: consumer
name: clients
name: KIP-1071
description: PRs related to KIP-1071
Created: 2025-04-23T14:31:26Z | Merged: 2025-04-25T07:56:09Z

If the streams rebalance protocol is enabled in
StreamsUncaughtExceptionHandlerIntegrationTest, the streams application
does not shut down correctly upon error.

There are two causes for this   - Sometimes, the SHUTDOWN_APPLICATION
code only sent with the leave heartbeat, but that is not handled broker
side.   - The SHUTDOWN_APPLICATION code wasn't properly handled
client-side at all

Reviewers: Bruno Cadonna <cadonna@apache.org>, Bill Bejeck
 <bill@confluent.io>, PoAn Yang <payang@apache.org>



Diff:
diff --git a/clients/src/main/java/org/apache/kafka/clients/consumer/internals/StreamsGroupHeartbeatRequestManager.java b/clients/src/main/java/org/apache/kafka/clients/consumer/internals/StreamsGroupHeartbeatRequestManager.java
index 5012aba5a32b0..b86c6d0498c35 100644
--- a/clients/src/main/java/org/apache/kafka/clients/consumer/internals/StreamsGroupHeartbeatRequestManager.java
+++ b/clients/src/main/java/org/apache/kafka/clients/consumer/internals/StreamsGroupHeartbeatRequestManager.java
@@ -521,11 +521,14 @@ private void onSuccessResponse(final StreamsGroupHeartbeatResponse response, fin
         }
 
         List<StreamsGroupHeartbeatResponseData.Status> statuses = data.status();
-        if (statuses != null && !statuses.isEmpty()) {
-            String statusDetails = statuses.stream()
-                .map(status -> "(" + status.statusCode() + ") " + status.statusDetail())
-                .collect(Collectors.joining(", "));
-            logger.warn("Membership is in the following statuses: {}", statusDetails);
+        if (statuses != null) {
+            streamsRebalanceData.setStatuses(statuses);
+            if (!statuses.isEmpty()) {
+                String statusDetails = statuses.stream()
+                    .map(status -> "(" + status.statusCode() + ") " + status.statusDetail())
+                    .collect(Collectors.joining(", "));
+                logger.warn("Membership is in the following statuses: {}", statusDetails);
+            }
         }
 
         membershipManager.onHeartbeatSuccess(response);
diff --git a/clients/src/main/java/org/apache/kafka/clients/consumer/internals/StreamsRebalanceData.java b/clients/src/main/java/org/apache/kafka/clients/consumer/internals/StreamsRebalanceData.java
index 6157b66cf16ed..0158370a50999 100644
--- a/clients/src/main/java/org/apache/kafka/clients/consumer/internals/StreamsRebalanceData.java
+++ b/clients/src/main/java/org/apache/kafka/clients/consumer/internals/StreamsRebalanceData.java
@@ -17,6 +17,7 @@
 package org.apache.kafka.clients.consumer.internals;
 
 import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.message.StreamsGroupHeartbeatResponseData;
 
 import java.util.Collection;
 import java.util.Collections;
@@ -300,6 +301,8 @@ public String toString() {
 
     private final AtomicBoolean shutdownRequested = new AtomicBoolean(false);
 
+    private final AtomicReference<List<StreamsGroupHeartbeatResponseData.Status>> statuses = new AtomicReference<>(List.of());
+
     public StreamsRebalanceData(final UUID processId,
                                 final Optional<HostInfo> endpoint,
                                 final Map<String, Subtopology> subtopologies,
@@ -346,11 +349,24 @@ public Map<HostInfo, List<TopicPartition>> partitionsByHost() {
         return partitionsByHost.get();
     }
 
+    /** For the current stream thread to request a shutdown of all Streams clients belonging to the same application. */
     public void requestShutdown() {
         shutdownRequested.set(true);
     }
 
+    /** True if the current stream thread requested a shutdown of all Streams clients belonging to the same application. */
     public boolean shutdownRequested() {
         return shutdownRequested.get();
     }
+
+    /** Updated whenever the status of the streams group is updated. */
+    public void setStatuses(final List<StreamsGroupHeartbeatResponseData.Status> s) {
+        statuses.set(s);
+    }
+
+    /** For communicating the current status of the group to the stream thread */
+    public List<StreamsGroupHeartbeatResponseData.Status> statuses() {
+        return statuses.get();
+    }
+
 }
diff --git a/clients/src/test/java/org/apache/kafka/clients/consumer/internals/StreamsRebalanceDataTest.java b/clients/src/test/java/org/apache/kafka/clients/consumer/internals/StreamsRebalanceDataTest.java
index 3caa298ee164b..a83b0ae2d23df 100644
--- a/clients/src/test/java/org/apache/kafka/clients/consumer/internals/StreamsRebalanceDataTest.java
+++ b/clients/src/test/java/org/apache/kafka/clients/consumer/internals/StreamsRebalanceDataTest.java
@@ -420,4 +420,21 @@ public void streamsRebalanceDataShouldBeConstructedWithShutDownRequestedSetFalse
 
         assertFalse(streamsRebalanceData.shutdownRequested());
     }
+
+    @Test
+    public void streamsRebalanceDataShouldBeConstructedWithEmptyStatuses() {
+        final UUID processId = UUID.randomUUID();
+        final Optional<StreamsRebalanceData.HostInfo> endpoint = Optional.of(new StreamsRebalanceData.HostInfo("localhost", 9090));
+        final Map<String, StreamsRebalanceData.Subtopology> subtopologies = Map.of();
+        final Map<String, String> clientTags = Map.of("clientTag1", "clientTagValue1");
+        final StreamsRebalanceData streamsRebalanceData = new StreamsRebalanceData(
+            processId,
+            endpoint,
+            subtopologies,
+            clientTags
+        );
+
+        assertTrue(streamsRebalanceData.statuses().isEmpty());
+    }
+
 }
diff --git a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupMetadataManager.java b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupMetadataManager.java
index 2c03b2a30293c..0b0b223feda97 100644
--- a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupMetadataManager.java
+++ b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupMetadataManager.java
@@ -4091,9 +4091,13 @@ private CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> stream
         String groupId,
         String instanceId,
         String memberId,
-        int memberEpoch
+        int memberEpoch,
+        boolean shutdownApplication
     ) throws ApiException {
         StreamsGroup group = streamsGroup(groupId);
+        if (shutdownApplication) {
+            group.setShutdownRequestMemberId(memberId);
+        }
         StreamsGroupHeartbeatResponseData response = new StreamsGroupHeartbeatResponseData()
             .setMemberId(memberId)
             .setMemberEpoch(memberEpoch);
@@ -4846,7 +4850,8 @@ public CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> streams
                 request.groupId(),
                 request.instanceId(),
                 request.memberId(),
-                request.memberEpoch()
+                request.memberEpoch(),
+                request.shutdownApplication()
             );
         } else {
             return streamsGroupHeartbeat(
diff --git a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupMetadataManagerTest.java b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupMetadataManagerTest.java
index d9af2b7052898..93b160e5ff139 100644
--- a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupMetadataManagerTest.java
+++ b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupMetadataManagerTest.java
@@ -16371,6 +16371,97 @@ public void testStreamsGroupMemberRequestingShutdownApplication() {
         assertRecordsEquals(List.of(), result2.records());
     }
 
+    @Test
+    public void testStreamsGroupMemberRequestingShutdownApplicationUponLeaving() {
+        String groupId = "fooup";
+        String memberId1 = Uuid.randomUuid().toString();
+        String memberId2 = Uuid.randomUuid().toString();
+        String subtopology1 = "subtopology1";
+        String fooTopicName = "foo";
+        Uuid fooTopicId = Uuid.randomUuid();
+        Topology topology = new Topology().setSubtopologies(List.of(
+            new Subtopology().setSubtopologyId(subtopology1).setSourceTopics(List.of(fooTopicName))
+        ));
+
+        MockTaskAssignor assignor = new MockTaskAssignor("sticky");
+        GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
+            .withStreamsGroupTaskAssignors(List.of(assignor))
+            .withMetadataImage(new MetadataImageBuilder()
+                .addTopic(fooTopicId, fooTopicName, 2)
+                .build())
+            .withStreamsGroup(new StreamsGroupBuilder(groupId, 10)
+                .withMember(streamsGroupMemberBuilderWithDefaults(memberId1)
+                    .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
+                    .setMemberEpoch(10)
+                    .setPreviousMemberEpoch(9)
+                    .build())
+                .withMember(streamsGroupMemberBuilderWithDefaults(memberId2)
+                    .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
+                    .setMemberEpoch(10)
+                    .setPreviousMemberEpoch(9)
+                    .build())
+                .withTargetAssignmentEpoch(10)
+                .withTopology(StreamsTopology.fromHeartbeatRequest(topology))
+                .withPartitionMetadata(Map.of(
+                    fooTopicName, new org.apache.kafka.coordinator.group.streams.TopicMetadata(fooTopicId, fooTopicName, 6)
+                ))
+            )
+            .build();
+
+        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result1 = context.streamsGroupHeartbeat(
+            new StreamsGroupHeartbeatRequestData()
+                .setGroupId(groupId)
+                .setMemberId(memberId1)
+                .setMemberEpoch(LEAVE_GROUP_MEMBER_EPOCH)
+                .setShutdownApplication(true)
+        );
+
+        String statusDetail = String.format("Streams group member %s encountered a fatal error and requested a shutdown for the entire application.", memberId1);
+
+        assertResponseEquals(
+            new StreamsGroupHeartbeatResponseData()
+                .setMemberId(memberId1)
+                .setMemberEpoch(LEAVE_GROUP_MEMBER_EPOCH),
+            result1.response().data()
+        );
+        assertRecordsEquals(
+            List.of(
+                StreamsCoordinatorRecordHelpers.newStreamsGroupCurrentAssignmentTombstoneRecord(groupId, memberId1),
+                StreamsCoordinatorRecordHelpers.newStreamsGroupTargetAssignmentTombstoneRecord(groupId, memberId1),
+                StreamsCoordinatorRecordHelpers.newStreamsGroupMemberTombstoneRecord(groupId, memberId1),
+                StreamsCoordinatorRecordHelpers.newStreamsGroupEpochRecord(groupId, 11)
+            ),
+            result1.records()
+        );
+
+        for (CoordinatorRecord record : result1.records()) {
+            context.replay(record);
+        }
+        assignor.prepareGroupAssignment(
+            Map.of(memberId1, TasksTuple.EMPTY)
+        );
+
+        CoordinatorResult<StreamsGroupHeartbeatResult, CoordinatorRecord> result2 = context.streamsGroupHeartbeat(
+            new StreamsGroupHeartbeatRequestData()
+                .setGroupId(groupId)
+                .setMemberId(memberId2)
+                .setMemberEpoch(10)
+        );
+
+        assertResponseEquals(
+            new StreamsGroupHeartbeatResponseData()
+                .setMemberId(memberId2)
+                .setMemberEpoch(12)
+                .setHeartbeatIntervalMs(5000)
+                .setStatus(List.of(
+                    new StreamsGroupHeartbeatResponseData.Status()
+                        .setStatusCode(Status.SHUTDOWN_APPLICATION.code())
+                        .setStatusDetail(statusDetail)
+                )),
+            result2.response().data()
+        );
+    }
+
     @Test
     public void testStreamsUpdatingMemberMetadataTriggersNewTargetAssignment() {
         String groupId = "fooup";
diff --git a/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/StreamsUncaughtExceptionHandlerIntegrationTest.java b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/StreamsUncaughtExceptionHandlerIntegrationTest.java
index 2745cc7e02b53..82100a772939e 100644
--- a/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/StreamsUncaughtExceptionHandlerIntegrationTest.java
+++ b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/StreamsUncaughtExceptionHandlerIntegrationTest.java
@@ -25,6 +25,7 @@
 import org.apache.kafka.common.serialization.StringSerializer;
 import org.apache.kafka.common.utils.LogCaptureAppender;
 import org.apache.kafka.common.utils.MockTime;
+import org.apache.kafka.streams.GroupProtocol;
 import org.apache.kafka.streams.KafkaStreams;
 import org.apache.kafka.streams.KeyValue;
 import org.apache.kafka.streams.StreamsBuilder;
@@ -52,9 +53,10 @@
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Tag;
-import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestInfo;
 import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import java.io.IOException;
 import java.time.Duration;
@@ -62,6 +64,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Locale;
 import java.util.Properties;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -86,7 +89,7 @@
 public class StreamsUncaughtExceptionHandlerIntegrationTest {
     private static final long NOW = Instant.now().toEpochMilli();
 
-    public static final EmbeddedKafkaCluster CLUSTER = new EmbeddedKafkaCluster(1);
+    public static final EmbeddedKafkaCluster CLUSTER = EmbeddedKafkaCluster.withStreamsRebalanceProtocol(1);
 
     @BeforeAll
     public static void startCluster() throws IOException {
@@ -111,7 +114,13 @@ public static void closeCluster() {
 
     private Properties properties;
 
-    private Properties basicProps() {
+    private Properties basicProps(final boolean streamsRebalanceProtocolEnabled) {
+        final String protocol;
+        if (streamsRebalanceProtocolEnabled) {
+            protocol = GroupProtocol.STREAMS.name().toLowerCase(Locale.getDefault());
+        } else {
+            protocol = GroupProtocol.CLASSIC.name().toLowerCase(Locale.getDefault());
+        }
         return mkObjectProperties(
             mkMap(
                 mkEntry(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, CLUSTER.bootstrapServers()),
@@ -120,7 +129,8 @@ private Properties basicProps() {
                 mkEntry(StreamsConfig.NUM_STREAM_THREADS_CONFIG, 2),
                 mkEntry(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.StringSerde.class),
                 mkEntry(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.StringSerde.class),
-                mkEntry(StreamsConfig.consumerPrefix(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG), 10000)
+                mkEntry(StreamsConfig.consumerPrefix(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG), 10000),
+                mkEntry(StreamsConfig.GROUP_PROTOCOL_CONFIG, protocol)
             )
         );
     }
@@ -136,7 +146,6 @@ public void setup(final TestInfo testInfo) {
         IntegrationTestUtils.cleanStateBeforeTest(CLUSTER, inputTopic, inputTopic2, outputTopic, outputTopic2);
         final KStream<String, String> stream = builder.stream(inputTopic);
         stream.process(() -> new ShutdownProcessor<>(processorValueCollector), Named.as("process"));
-        properties = basicProps();
     }
 
     @AfterEach
@@ -144,8 +153,10 @@ public void teardown() throws IOException {
         purgeLocalStreamsState(properties);
     }
 
-    @Test
-    public void shouldShutdownClient() throws Exception {
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    public void shouldShutdownClient(final boolean streamsRebalanceProtocolEnabled) throws Exception {
+        properties = basicProps(streamsRebalanceProtocolEnabled);
         try (final KafkaStreams kafkaStreams = new KafkaStreams(builder.build(), properties)) {
 
             kafkaStreams.setUncaughtExceptionHandler(exception -> SHUTDOWN_CLIENT);
@@ -159,29 +170,39 @@ public void shouldShutdownClient() throws Exception {
         }
     }
 
-    @Test
-    public void shouldReplaceThreads() throws Exception {
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    public void shouldReplaceThreads(final boolean streamsRebalanceProtocolEnabled) throws Exception {
+        properties = basicProps(streamsRebalanceProtocolEnabled);
         testReplaceThreads(2);
     }
 
-    @Test
-    public void shouldReplaceThreadsWithoutJavaHandler() throws Exception {
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    public void shouldReplaceThreadsWithoutJavaHandler(final boolean streamsRebalanceProtocolEnabled) throws Exception {
+        properties = basicProps(streamsRebalanceProtocolEnabled);
         Thread.setDefaultUncaughtExceptionHandler((t, e) -> fail("exception thrown"));
         testReplaceThreads(2);
     }
 
-    @Test
-    public void shouldReplaceSingleThread() throws Exception {
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    public void shouldReplaceSingleThread(final boolean streamsRebalanceProtocolEnabled) throws Exception {
+        properties = basicProps(streamsRebalanceProtocolEnabled);
         testReplaceThreads(1);
     }
 
-    @Test
-    public void shouldShutdownMultipleThreadApplication() throws Exception {
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    public void shouldShutdownMultipleThreadApplication(final boolean streamsRebalanceProtocolEnabled) throws Exception {
+        properties = basicProps(streamsRebalanceProtocolEnabled);
         testShutdownApplication(2);
     }
 
-    @Test
-    public void shouldShutdownSingleThreadApplication() throws Exception {
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    public void shouldShutdownSingleThreadApplication(final boolean streamsRebalanceProtocolEnabled) throws Exception {
+        properties = basicProps(streamsRebalanceProtocolEnabled);
         testShutdownApplication(1);
     }
 
@@ -212,8 +233,10 @@ public void process(final Record<KIn, VIn> record) {
         }
     }
 
-    @Test
-    public void shouldShutDownClientIfGlobalStreamThreadWantsToReplaceThread() throws Exception {
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    public void shouldShutDownClientIfGlobalStreamThreadWantsToReplaceThread(final boolean streamsRebalanceProtocolEnabled) throws Exception {
+        properties = basicProps(streamsRebalanceProtocolEnabled);
         builder.addGlobalStore(
                 new KeyValueStoreBuilder<>(
                         Stores.persistentKeyValueStore("globalStore"),
@@ -239,8 +262,10 @@ public void shouldShutDownClientIfGlobalStreamThreadWantsToReplaceThread() throw
         }
     }
 
-    @Test
-    public void shouldEmitSameRecordAfterFailover() throws Exception {
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    public void shouldEmitSameRecordAfterFailover(final boolean streamsRebalanceProtocolEnabled) throws Exception {
+        properties = basicProps(streamsRebalanceProtocolEnabled);
         properties.put(StreamsConfig.NUM_STREAM_THREADS_CONFIG, 1);
         properties.put(StreamsConfig.COMMIT_INTERVAL_MS_CONFIG, 300000L);
         properties.put(StreamsConfig.CACHE_MAX_BYTES_BUFFERING_CONFIG, 0);
diff --git a/streams/src/main/java/org/apache/kafka/streams/KafkaStreams.java b/streams/src/main/java/org/apache/kafka/streams/KafkaStreams.java
index 4ee22f045564e..b51d5733b8275 100644
--- a/streams/src/main/java/org/apache/kafka/streams/KafkaStreams.java
+++ b/streams/src/main/java/org/apache/kafka/streams/KafkaStreams.java
@@ -67,7 +67,6 @@
 import org.apache.kafka.streams.processor.internals.Task;
 import org.apache.kafka.streams.processor.internals.ThreadStateTransitionValidator;
 import org.apache.kafka.streams.processor.internals.TopologyMetadata;
-import org.apache.kafka.streams.processor.internals.assignment.AssignorError;
 import org.apache.kafka.streams.processor.internals.metrics.StreamsMetricsImpl;
 import org.apache.kafka.streams.processor.internals.namedtopology.NamedTopology;
 import org.apache.kafka.streams.query.FailureReason;
@@ -518,7 +517,7 @@ private void handleStreamsUncaughtException(final Throwable throwable,
                 break;
             case SHUTDOWN_CLIENT:
                 log.error(
-                    "Encountered the following exception during processing and the registered exception handler" +
+                    "Encountered the following exception during processing and the registered exception handler " +
                         "opted to {}. The streams client is going to shut down now.",
                     action,
                     throwable
@@ -542,7 +541,7 @@ private void handleStreamsUncaughtException(final Throwable throwable,
                     closeToError();
                     break;
                 }
-                processStreamThread(thread -> thread.sendShutdownRequest(AssignorError.SHUTDOWN_REQUESTED));
+                processStreamThread(StreamThread::sendShutdownRequest);
                 log.error("Encountered the following exception during processing " +
                         "and sent shutdown request for the entire application.", throwable);
                 break;
diff --git a/streams/src/main/java/org/apache/kafka/streams/processor/internals/StreamThread.java b/streams/src/main/java/org/apache/kafka/streams/processor/internals/StreamThread.java
index 5cd747e3cf8ab..2d49a7409d5d7 100644
--- a/streams/src/main/java/org/apache/kafka/streams/processor/internals/StreamThread.java
+++ b/streams/src/main/java/org/apache/kafka/streams/processor/internals/StreamThread.java
@@ -39,7 +39,9 @@
 import org.apache.kafka.common.errors.TimeoutException;
 import org.apache.kafka.common.errors.UnsupportedVersionException;
 import org.apache.kafka.common.internals.KafkaFutureImpl;
+import org.apache.kafka.common.message.StreamsGroupHeartbeatResponseData;
 import org.apache.kafka.common.metrics.Sensor;
+import org.apache.kafka.common.requests.StreamsGroupHeartbeatResponse;
 import org.apache.kafka.common.serialization.ByteArrayDeserializer;
 import org.apache.kafka.common.utils.LogContext;
 import org.apache.kafka.common.utils.Time;
@@ -1079,8 +1081,9 @@ public void shutdownToError() {
         shutdownErrorHook.run();
     }
 
-    public void sendShutdownRequest(final AssignorError assignorError) {
-        assignmentErrorCode.set(assignorError.code());
+    public void sendShutdownRequest() {
+        assignmentErrorCode.set(AssignorError.SHUTDOWN_REQUESTED.code());
+        streamsRebalanceData.ifPresent(StreamsRebalanceData::requestShutdown);
     }
 
     private void handleTaskMigrated(final TaskMigratedException e) {
@@ -1486,9 +1489,10 @@ private ConsumerRecords<byte[], byte[]> pollRequests(final Duration pollTime) {
 
     public void handleStreamsRebalanceData() {
         if (streamsRebalanceData.isPresent()) {
-
-            if (streamsRebalanceData.get().shutdownRequested()) {
-                assignmentErrorCode.set(AssignorError.SHUTDOWN_REQUESTED.code());
+            for (final StreamsGroupHeartbeatResponseData.Status status : streamsRebalanceData.get().statuses()) {
+                if (status.statusCode() == StreamsGroupHeartbeatResponse.Status.SHUTDOWN_APPLICATION.code()) {
+                    shutdownErrorHook.run();
+                }
             }
         }
     }


Reviews:
user: login: cadonna
body: Thanks for the PR, @lucasbru !

I had only minor comments.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T15:43:20Z
user: login: bbejeck
body: Thanks for the PR @lucasbru - overall LGTM with a couple of minor comments
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-23T22:02:36Z
user: login: FrankYang0529
body: Overall LGTM. Leave some minor comments.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-24T06:54:53Z
user: login: lucasbru
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T08:27:08Z
user: login: lucasbru
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T08:30:24Z
user: login: lucasbru
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T08:34:38Z

Review comments:
diff_hunk: @@ -346,11 +349,24 @@ public Map<HostInfo, List<TopicPartition>> partitionsByHost() {
         return partitionsByHost.get();
     }
 
+    /** For the current stream thread to request a shutdown of all applications. */
     public void requestShutdown() {
         shutdownRequested.set(true);
     }
 
+    /** True of the current stream thread requested a shutdown of all applications. */
path: clients/src/main/java/org/apache/kafka/clients/consumer/internals/StreamsRebalanceData.java
user: login: cadonna
body: ```suggestion
    /** True if the current stream thread requested a shutdown of all Streams clients belonging to the same application. */
```
created_at: 2025-04-23T15:40:30Z
author_association: MEMBER
diff_hunk: @@ -346,11 +349,24 @@ public Map<HostInfo, List<TopicPartition>> partitionsByHost() {
         return partitionsByHost.get();
     }
 
+    /** For the current stream thread to request a shutdown of all applications. */
path: clients/src/main/java/org/apache/kafka/clients/consumer/internals/StreamsRebalanceData.java
user: login: cadonna
body: ```suggestion
    /** For the current stream thread to request a shutdown of all Streams clients belonging to the same applications. */
```
created_at: 2025-04-23T15:41:07Z
author_association: MEMBER
diff_hunk: @@ -521,11 +521,14 @@ private void onSuccessResponse(final StreamsGroupHeartbeatResponse response, fin
         }
 
         List<StreamsGroupHeartbeatResponseData.Status> statuses = data.status();
-        if (statuses != null && !statuses.isEmpty()) {
-            String statusDetails = statuses.stream()
-                .map(status -> "(" + status.statusCode() + ") " + status.statusDetail())
-                .collect(Collectors.joining(", "));
-            logger.warn("Membership is in the following statuses: {}", statusDetails);
+        if (statuses != null) {
+            streamsRebalanceData.setStatuses(statuses);
+            if (!statuses.isEmpty()) {
+                String statusDetails = statuses.stream()
+                    .map(status -> "(" + status.statusCode() + ") " + status.statusDetail())
+                    .collect(Collectors.joining(", "));
+                logger.warn("Membership is in the following statuses: {}", statusDetails);
path: clients/src/main/java/org/apache/kafka/clients/consumer/internals/StreamsGroupHeartbeatRequestManager.java
user: login: bbejeck
body: Why a warn? Seems more like an `INFO`
created_at: 2025-04-23T21:44:25Z
author_association: MEMBER
diff_hunk: @@ -518,7 +517,7 @@ private void handleStreamsUncaughtException(final Throwable throwable,
                 break;
             case SHUTDOWN_CLIENT:
                 log.error(
-                    "Encountered the following exception during processing and the registered exception handler" +
+                    "Encountered the following exception during processing and the registered exception handler " +
path: streams/src/main/java/org/apache/kafka/streams/KafkaStreams.java
user: login: bbejeck
body: ```suggestion
                    "Encountered the following exception during processing and the registered exception handler" +
```
created_at: 2025-04-23T21:49:50Z
author_association: MEMBER
diff_hunk: @@ -1079,8 +1081,9 @@ public void shutdownToError() {
         shutdownErrorHook.run();
     }
 
-    public void sendShutdownRequest(final AssignorError assignorError) {
-        assignmentErrorCode.set(assignorError.code());
+    public void sendShutdownRequest() {
+        assignmentErrorCode.set(AssignorError.SHUTDOWN_REQUESTED.code());
path: streams/src/main/java/org/apache/kafka/streams/processor/internals/StreamThread.java
user: login: bbejeck
body: Does this mean a shutdown is always considered an error? What about cases where users proactively shutdown? Or is this only executed in error cases?
created_at: 2025-04-23T21:59:10Z
author_association: MEMBER
diff_hunk: @@ -420,4 +420,21 @@ public void streamsRebalanceDataShouldBeConstructedWithShutDownRequestedSetFalse
 
         assertFalse(streamsRebalanceData.shutdownRequested());
     }
+
+    @Test
+    public void streamsRebalanceDataShouldBeConstructedWithEmptyStatuses() {
+        final UUID processId = UUID.randomUUID();
+        final Optional<StreamsRebalanceData.HostInfo> endpoint = Optional.of(new StreamsRebalanceData.HostInfo("localhost", 9090));
+        final Map<String, StreamsRebalanceData.Subtopology> subtopologies = new HashMap<>();
path: clients/src/test/java/org/apache/kafka/clients/consumer/internals/StreamsRebalanceDataTest.java
user: login: FrankYang0529
body: ```suggestion
        final Map<String, StreamsRebalanceData.Subtopology> subtopologies = Map.of();
```
created_at: 2025-04-24T06:50:14Z
author_association: MEMBER
diff_hunk: @@ -300,6 +301,8 @@ public String toString() {
 
     private final AtomicBoolean shutdownRequested = new AtomicBoolean(false);
 
+    private final AtomicReference<List<StreamsGroupHeartbeatResponseData.Status>> statuses = new AtomicReference<>(Collections.emptyList());
path: clients/src/main/java/org/apache/kafka/clients/consumer/internals/StreamsRebalanceData.java
user: login: FrankYang0529
body: ```suggestion
    private final AtomicReference<List<StreamsGroupHeartbeatResponseData.Status>> statuses = new AtomicReference<>(List.of());
```
created_at: 2025-04-24T06:50:20Z
author_association: MEMBER
diff_hunk: @@ -346,11 +349,24 @@ public Map<HostInfo, List<TopicPartition>> partitionsByHost() {
         return partitionsByHost.get();
     }
 
+    /** For the current stream thread to request a shutdown of all Streams clients belonging to the same applications. */
path: clients/src/main/java/org/apache/kafka/clients/consumer/internals/StreamsRebalanceData.java
user: login: FrankYang0529
body: The comment of `shutdownRequested` uses singular. It would probably be better to unify it.
```suggestion
    /** For the current stream thread to request a shutdown of all Streams clients belonging to the same application. */
```
created_at: 2025-04-24T06:50:44Z
author_association: MEMBER
diff_hunk: @@ -518,7 +517,7 @@ private void handleStreamsUncaughtException(final Throwable throwable,
                 break;
             case SHUTDOWN_CLIENT:
                 log.error(
-                    "Encountered the following exception during processing and the registered exception handler" +
+                    "Encountered the following exception during processing and the registered exception handler " +
path: streams/src/main/java/org/apache/kafka/streams/KafkaStreams.java
user: login: lucasbru
body: This is actually an intentional fix. The error message was "handleropted" before
created_at: 2025-04-24T08:27:07Z
author_association: MEMBER
diff_hunk: @@ -1079,8 +1081,9 @@ public void shutdownToError() {
         shutdownErrorHook.run();
     }
 
-    public void sendShutdownRequest(final AssignorError assignorError) {
-        assignmentErrorCode.set(assignorError.code());
+    public void sendShutdownRequest() {
+        assignmentErrorCode.set(AssignorError.SHUTDOWN_REQUESTED.code());
path: streams/src/main/java/org/apache/kafka/streams/processor/internals/StreamThread.java
user: login: lucasbru
body: I do not intend to change the behavior of Kafka Streams here. I believe, the `SHUTDOWN_REQUESTED` propagated through rebalances is always handled as an error, yes.

The change in this line doesn't change anything functionally, since we'd always pass the same `assignorError` to this method.
created_at: 2025-04-24T08:30:24Z
author_association: MEMBER
diff_hunk: @@ -521,11 +521,14 @@ private void onSuccessResponse(final StreamsGroupHeartbeatResponse response, fin
         }
 
         List<StreamsGroupHeartbeatResponseData.Status> statuses = data.status();
-        if (statuses != null && !statuses.isEmpty()) {
-            String statusDetails = statuses.stream()
-                .map(status -> "(" + status.statusCode() + ") " + status.statusDetail())
-                .collect(Collectors.joining(", "));
-            logger.warn("Membership is in the following statuses: {}", statusDetails);
+        if (statuses != null) {
+            streamsRebalanceData.setStatuses(statuses);
+            if (!statuses.isEmpty()) {
+                String statusDetails = statuses.stream()
+                    .map(status -> "(" + status.statusCode() + ") " + status.statusDetail())
+                    .collect(Collectors.joining(", "));
+                logger.warn("Membership is in the following statuses: {}", statusDetails);
path: clients/src/main/java/org/apache/kafka/clients/consumer/internals/StreamsGroupHeartbeatRequestManager.java
user: login: lucasbru
body: It's a good suggestion, that I was thinking about as well. The thing is, right now the streams application keeps running when a source topic is missing, and this membership log is the only client-side thing that is pointing out why no processing is happening. So I would like to keep it at `WARN` for now. It was intended in the KIP to be like this, but I discussed with Matthias as well, that we want to fail in these cases instead - so if a source topic doesn't exist, internal topics aren't created within a certain time, or topics are malpartitioned, we actually want to throw an exception (inside StreamThread). This PR is preparing the change by propagating the status to the StreamThread.

Lets make this an `INFO` log, once we treat the statuses more explicitly inside `StreamThread`.
created_at: 2025-04-24T08:34:38Z
author_association: MEMBER

PR comments:
user: login: lucasbru
created_at: 2025-04-23T14:34:28Z
body: PTAL @aliehsaeedii 
author_association: MEMBER
user: login: lucasbru
created_at: 2025-04-23T16:24:15Z
body: > Thanks for the PR, @lucasbru !
> 
> I had only minor comments.

Thanks for the review, @cadonna ! Ready for re-review.
author_association: MEMBER