This is an automated email from the ASF dual-hosted git repository.

lianetm pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/kafka.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 096cc96fee5 KAFKA-20522: Refine test coverage for consumer 
close-on-interrupt best-effort behavior. (#22138)
096cc96fee5 is described below

commit 096cc96fee5edd924a9c685b2c0084f320914135
Author: ChickenchickenLove <[email protected]>
AuthorDate: Wed Jun 17 03:58:45 2026 +0900

    KAFKA-20522: Refine test coverage for consumer close-on-interrupt 
best-effort behavior. (#22138)
    
    ### Description
    In the previous,
    `PlaintextConsumerTest.testClassicConsumerCloseLeavesGroupOnInterrupt`
    were flaky.
    
    The reason is that `LeaveGroup` on consumer close has best-effort
    semantics, but those tests were written under the assumption that the
    consumer deterministically leaves the consumer group. In practice,
    however, the consumer may fail to leave the group within the bounded
    timeout, for example due to metadata expiration.
    (https://github.com/apache/kafka/pull/21332)
    
    This PR splits the flaky test into two separate tests.
    
    1. The existing integration test now verifies that `callsToRevoked` is
    invoked when `interrupt()` is called. This behavior is still
    deterministic even under best-effort semantics.
    2. A unit test verifies that an `ApiKeys.LEAVE_GROUP` request is
    recorded in `MockClient.requests()` when `interrupt()` is called.
    
    Previously, even if an `ApiKeys.LEAVE_GROUP` request was created,
    request processing could be delayed due to metadata expiration. As a
    result, the exact point at which the consumer leaves the group was
    non-deterministic. Therefore, taking the best-effort semantics into
    account, the unit test only verifies that the `ApiKeys.LEAVE_GROUP`
    request is recorded.
    
    ### Related
    - https://github.com/apache/kafka/pull/21332
    - KAFKA-20522
    - KAFKA-17397
    - KAFKA-18031
    
    Reviewers: Lianet Magrans <[email protected]>
---
 .../clients/consumer/PlaintextConsumerTest.java    | 75 ----------------------
 .../kafka/clients/consumer/KafkaConsumerTest.java  | 54 ++++++++++++++++
 .../consumer/internals/AsyncKafkaConsumerTest.java | 60 +++++++++++++++++
 .../kafka/api/PlaintextConsumerTest.scala          | 75 ----------------------
 4 files changed, 114 insertions(+), 150 deletions(-)

diff --git 
a/clients/clients-integration-tests/src/test/java/org/apache/kafka/clients/consumer/PlaintextConsumerTest.java
 
b/clients/clients-integration-tests/src/test/java/org/apache/kafka/clients/consumer/PlaintextConsumerTest.java
index 6a1b174d188..e5a2c8a831f 100644
--- 
a/clients/clients-integration-tests/src/test/java/org/apache/kafka/clients/consumer/PlaintextConsumerTest.java
+++ 
b/clients/clients-integration-tests/src/test/java/org/apache/kafka/clients/consumer/PlaintextConsumerTest.java
@@ -27,7 +27,6 @@ import org.apache.kafka.clients.producer.RecordMetadata;
 import org.apache.kafka.common.MetricName;
 import org.apache.kafka.common.TopicPartition;
 import org.apache.kafka.common.config.TopicConfig;
-import org.apache.kafka.common.errors.InterruptException;
 import org.apache.kafka.common.errors.InvalidGroupIdException;
 import org.apache.kafka.common.errors.InvalidTopicException;
 import org.apache.kafka.common.errors.TimeoutException;
@@ -46,7 +45,6 @@ import org.apache.kafka.common.test.api.ClusterConfigProperty;
 import org.apache.kafka.common.test.api.ClusterTest;
 import org.apache.kafka.common.test.api.ClusterTestDefaults;
 import org.apache.kafka.common.test.api.ClusterTests;
-import org.apache.kafka.common.test.api.Flaky;
 import org.apache.kafka.common.test.api.Type;
 import org.apache.kafka.server.quota.QuotaType;
 import org.apache.kafka.test.MockConsumerInterceptor;
@@ -63,7 +61,6 @@ import java.util.Optional;
 import java.util.Set;
 import java.util.UUID;
 import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
@@ -1616,78 +1613,6 @@ public class PlaintextConsumerTest {
         }
     }
 
-    @Flaky("KAFKA-18031")
-    @ClusterTest
-    public void testClassicConsumerCloseLeavesGroupOnInterrupt() throws 
Exception {
-        testCloseLeavesGroupOnInterrupt(Map.of(
-            GROUP_PROTOCOL_CONFIG, 
GroupProtocol.CLASSIC.name().toLowerCase(Locale.ROOT),
-            KEY_DESERIALIZER_CLASS_CONFIG, 
ByteArrayDeserializer.class.getName(),
-            VALUE_DESERIALIZER_CLASS_CONFIG, 
ByteArrayDeserializer.class.getName(),
-            AUTO_OFFSET_RESET_CONFIG, "earliest",
-            GROUP_ID_CONFIG, "group_test,",
-            BOOTSTRAP_SERVERS_CONFIG, cluster.bootstrapServers()
-        ));
-    }
-
-    @Flaky("KAFKA-18031")
-    @ClusterTest
-    public void testAsyncConsumerCloseLeavesGroupOnInterrupt() throws 
Exception {
-        testCloseLeavesGroupOnInterrupt(Map.of(
-            GROUP_PROTOCOL_CONFIG, 
GroupProtocol.CONSUMER.name().toLowerCase(Locale.ROOT),
-            KEY_DESERIALIZER_CLASS_CONFIG, 
ByteArrayDeserializer.class.getName(),
-            VALUE_DESERIALIZER_CLASS_CONFIG, 
ByteArrayDeserializer.class.getName(),
-            AUTO_OFFSET_RESET_CONFIG, "earliest",
-            GROUP_ID_CONFIG, "group_test,",
-            BOOTSTRAP_SERVERS_CONFIG, cluster.bootstrapServers()
-        ));
-    }
-
-    private void testCloseLeavesGroupOnInterrupt(Map<String, Object> 
consumerConfig) throws Exception {
-        try (Consumer<byte[], byte[]> consumer = 
cluster.consumer(consumerConfig)) {
-            var listener = new TestConsumerReassignmentListener();
-            consumer.subscribe(List.of(TOPIC), listener);
-            awaitRebalance(consumer, listener);
-
-            assertEquals(1, listener.callsToAssigned);
-            assertEquals(0, listener.callsToRevoked);
-
-            try {
-                Thread.currentThread().interrupt();
-                assertThrows(InterruptException.class, consumer::close);
-            } finally {
-                // Clear the interrupted flag so we don't create problems for 
subsequent tests.
-                Thread.interrupted();
-            }
-
-            assertEquals(1, listener.callsToAssigned);
-            assertEquals(1, listener.callsToRevoked);
-
-            Map<String, Object> consumerConfigMap = new 
HashMap<>(consumerConfig);
-            var config = new ConsumerConfig(consumerConfigMap);
-
-            // Set the wait timeout to be only *half* the configured session 
timeout. This way we can make sure that the
-            // consumer explicitly left the group as opposed to being kicked 
out by the broker.
-            var leaveGroupTimeoutMs = config.getInt(SESSION_TIMEOUT_MS_CONFIG) 
/ 2;
-
-            TestUtils.waitForCondition(
-                () -> checkGroupMemberEmpty(config), 
-                leaveGroupTimeoutMs, 
-                "Consumer did not leave the consumer group within " + 
leaveGroupTimeoutMs + " ms of close"
-            );
-        }
-    }
-
-    private boolean checkGroupMemberEmpty(ConsumerConfig config) {
-        try (var admin = cluster.admin()) {
-            var groupId = config.getString(GROUP_ID_CONFIG);
-            var result = admin.describeConsumerGroups(List.of(groupId));
-            var groupDescription = result.describedGroups().get(groupId).get();
-            return groupDescription.members().isEmpty();
-        } catch (ExecutionException | InterruptedException e) {
-            return false;
-        }
-    }
-
     @ClusterTest
     public void testClassicConsumerOffsetRelatedWhenTimeoutZero() throws 
Exception {
         testOffsetRelatedWhenTimeoutZero(Map.of(
diff --git 
a/clients/src/test/java/org/apache/kafka/clients/consumer/KafkaConsumerTest.java
 
b/clients/src/test/java/org/apache/kafka/clients/consumer/KafkaConsumerTest.java
index b28a2ce9d45..0d4fc11dc7c 100644
--- 
a/clients/src/test/java/org/apache/kafka/clients/consumer/KafkaConsumerTest.java
+++ 
b/clients/src/test/java/org/apache/kafka/clients/consumer/KafkaConsumerTest.java
@@ -2244,6 +2244,60 @@ public class KafkaConsumerTest {
         consumerCloseTest(groupProtocol, Long.MAX_VALUE, 
Collections.emptyList(), 0, true);
     }
 
+    @Test
+    public void 
testClassicConsumerCloseRunsRevocationCallbackAndAttemptsLeaveGroupWhenInterrupted()
 {
+        ConsumerMetadata metadata = createMetadata(subscription);
+        MockClient client = new MockClient(time, metadata);
+
+        initMetadata(client, Map.of(topic, 1));
+        Node node = metadata.fetch().nodes().get(0);
+
+        final KafkaConsumer<String, String> consumer = newConsumer(
+            GroupProtocol.CLASSIC,
+            time,
+            client,
+            subscription,
+            metadata,
+            assignor,
+            false,
+            Optional.empty()
+        );
+
+        AtomicInteger revokedCount = new AtomicInteger(0);
+        AtomicReference<Set<TopicPartition>> revokedPartitions = new 
AtomicReference<>();
+
+        ConsumerRebalanceListener listener = new ConsumerRebalanceListener() {
+            @Override
+            public void onPartitionsRevoked(Collection<TopicPartition> 
partitions) {
+                assertTrue(Thread.currentThread().isInterrupted());
+                revokedCount.incrementAndGet();
+                revokedPartitions.set(Set.copyOf(partitions));
+            }
+
+            @Override
+            public void onPartitionsAssigned(Collection<TopicPartition> 
partitions) {
+                // Preserve the existing helper behavior so assignment setup 
remains equivalent.
+                for (TopicPartition partition : partitions)
+                    consumer.seek(partition, 0);
+            }
+        };
+
+        consumer.subscribe(Set.of(topic), listener);
+        prepareRebalance(client, node, assignor, List.of(tp0), null);
+        consumer.updateAssignmentMetadataIfNeeded(time.timer(Long.MAX_VALUE));
+
+        try {
+            Thread.currentThread().interrupt();
+            assertThrows(InterruptException.class, () -> 
consumer.close(CloseOptions.timeout(Duration.ofMillis(Long.MAX_VALUE))));
+        } finally {
+            Thread.interrupted();
+        }
+
+        assertEquals(1, revokedCount.get());
+        assertEquals(Set.of(tp0), revokedPartitions.get());
+        assertTrue(requestGenerated(client, ApiKeys.LEAVE_GROUP));
+    }
+
     @ParameterizedTest
     @EnumSource(GroupProtocol.class)
     public void testCloseShouldBeIdempotent(GroupProtocol groupProtocol) {
diff --git 
a/clients/src/test/java/org/apache/kafka/clients/consumer/internals/AsyncKafkaConsumerTest.java
 
b/clients/src/test/java/org/apache/kafka/clients/consumer/internals/AsyncKafkaConsumerTest.java
index 95566f2f5b3..6f9ef98a66f 100644
--- 
a/clients/src/test/java/org/apache/kafka/clients/consumer/internals/AsyncKafkaConsumerTest.java
+++ 
b/clients/src/test/java/org/apache/kafka/clients/consumer/internals/AsyncKafkaConsumerTest.java
@@ -905,6 +905,66 @@ public class AsyncKafkaConsumerTest {
         
verify(applicationEventHandler).addAndGet(any(LeaveGroupOnCloseEvent.class));
     }
 
+    @Test
+    public void 
testCloseRunsRevocationCallbackAndSendsLeaveGroupEventOnInterrupt() {
+        final String topicName = "topic";
+        final Set<TopicPartition> partitions = singleton(new 
TopicPartition(topicName, 0));
+
+        final AtomicReference<Set<TopicPartition>> revokedPartitions = new 
AtomicReference<>();
+        final AtomicBoolean revocationCallbackCalled = new 
AtomicBoolean(false);
+        final AtomicReference<LeaveGroupOnCloseEvent> leaveGroupEvent = new 
AtomicReference<>();
+
+        final ConsumerRebalanceListener listener = new 
ConsumerRebalanceListener() {
+            @Override
+            public void onPartitionsRevoked(final Collection<TopicPartition> 
partitions) {
+                assertTrue(Thread.currentThread().isInterrupted());
+                revocationCallbackCalled.set(true);
+                revokedPartitions.set(Set.copyOf(partitions));
+            }
+
+            @Override
+            public void onPartitionsAssigned(final Collection<TopicPartition> 
partitions) {
+                // no-op
+            }
+
+            @Override
+            public void onPartitionsLost(final Collection<TopicPartition> 
partitions) {
+                fail("Expected assigned partitions to be revoked on close");
+            }
+        };
+
+        try (final MockedStatic<RequestManagers> requestManagers = 
mockStatic(RequestManagers.class)) {
+            consumer = 
newConsumer(requiredConsumerConfigAndGroupId("consumerGroup"));
+            completeTopicSubscriptionChangeEventSuccessfully();
+            consumer.subscribe(singletonList(topicName), listener);
+            consumer.subscriptions().assignFromSubscribed(partitions);
+            consumer.setGroupAssignmentSnapshot(partitions);
+
+            final MemberStateListener groupMetadataUpdateListener = 
captureGroupMetadataUpdateListener(requestManagers);
+            groupMetadataUpdateListener.onMemberEpochUpdated(Optional.of(1), 
"memberId");
+
+            doAnswer(invocation -> {
+                LeaveGroupOnCloseEvent event = invocation.getArgument(0);
+                leaveGroupEvent.set(event);
+                assertTrue(Thread.currentThread().isInterrupted());
+                throw new InterruptException("Thread was interrupted");
+            
}).when(applicationEventHandler).addAndGet(ArgumentMatchers.isA(LeaveGroupOnCloseEvent.class));
+
+            try {
+                Thread.currentThread().interrupt();
+                assertThrows(InterruptException.class, () -> 
consumer.close(CloseOptions.timeout(Duration.ZERO)));
+            } finally {
+                Thread.interrupted();
+            }
+        }
+
+        assertTrue(revocationCallbackCalled.get());
+        assertEquals(partitions, revokedPartitions.get());
+        
verify(applicationEventHandler).addAndGet(ArgumentMatchers.isA(LeaveGroupOnCloseEvent.class));
+        assertNotNull(leaveGroupEvent.get());
+        assertEquals(CloseOptions.GroupMembershipOperation.DEFAULT, 
leaveGroupEvent.get().membershipOperation());
+    }
+
     @Test
     public void testCommitSyncAllConsumed() {
         SubscriptionState subscriptions = new SubscriptionState(new 
LogContext(), AutoOffsetResetStrategy.NONE);
diff --git 
a/core/src/test/scala/integration/kafka/api/PlaintextConsumerTest.scala 
b/core/src/test/scala/integration/kafka/api/PlaintextConsumerTest.scala
deleted file mode 100644
index bbc4e6c350c..00000000000
--- a/core/src/test/scala/integration/kafka/api/PlaintextConsumerTest.scala
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
-  * Licensed to the Apache Software Foundation (ASF) under one or more 
contributor license agreements. See the NOTICE
-  * file distributed with this work for additional information regarding 
copyright ownership. The ASF licenses this file
-  * to You under the Apache License, Version 2.0 (the "License"); you may not 
use this file except in compliance with the
-  * License. You may obtain a copy of the License at
-  *
-  * http://www.apache.org/licenses/LICENSE-2.0
-  *
-  * Unless required by applicable law or agreed to in writing, software 
distributed under the License is distributed on
-  * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either 
express or implied. See the License for the
-  * specific language governing permissions and limitations under the License.
-  */
-package kafka.api
-
-import java.util
-import kafka.utils.{TestInfoUtils, TestUtils}
-import org.apache.kafka.clients.consumer._
-import org.apache.kafka.common.errors.InterruptException
-import org.apache.kafka.common.test.api.Flaky
-import org.junit.jupiter.api.Assertions._
-import org.junit.jupiter.api.Timeout
-import org.junit.jupiter.params.ParameterizedTest
-import org.junit.jupiter.params.provider.MethodSource
-
-import java.util.concurrent.ExecutionException
-
-@Timeout(60)
-class PlaintextConsumerTest extends AbstractConsumerTest {
-
-  @Flaky("KAFKA-18031")
-  @ParameterizedTest(name = 
TestInfoUtils.TestWithParameterizedGroupProtocolNames)
-  @MethodSource(Array("getTestGroupProtocolParametersAll"))
-  def testCloseLeavesGroupOnInterrupt(groupProtocol: String): Unit = {
-    val adminClient = createAdminClient()
-    val consumer = createConsumer()
-    val listener = new TestConsumerReassignmentListener()
-    consumer.subscribe(java.util.List.of(topic), listener)
-    awaitRebalance(consumer, listener)
-
-    assertEquals(1, listener.callsToAssigned)
-    assertEquals(0, listener.callsToRevoked)
-
-    try {
-      Thread.currentThread().interrupt()
-      assertThrows(classOf[InterruptException], () => consumer.close())
-    } finally {
-      // Clear the interrupted flag so we don't create problems for subsequent 
tests.
-      Thread.interrupted()
-    }
-
-    assertEquals(1, listener.callsToAssigned)
-    assertEquals(1, listener.callsToRevoked)
-
-    val config = new ConsumerConfig(consumerConfig)
-
-    // Set the wait timeout to be only *half* the configured session timeout. 
This way we can make sure that the
-    // consumer explicitly left the group as opposed to being kicked out by 
the broker.
-    val leaveGroupTimeoutMs = 
config.getInt(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG) / 2
-
-    TestUtils.waitUntilTrue(
-      () => {
-        try {
-          val groupId = config.getString(ConsumerConfig.GROUP_ID_CONFIG)
-          val groupDescription = 
adminClient.describeConsumerGroups(util.List.of(groupId)).describedGroups.get(groupId).get
-          groupDescription.members.isEmpty
-        } catch {
-          case _: ExecutionException | _: InterruptedException =>
-            false
-        }
-      },
-      msg=s"Consumer did not leave the consumer group within 
$leaveGroupTimeoutMs ms of close",
-      waitTimeMs=leaveGroupTimeoutMs
-    )
-  }
-}

Reply via email to