From e5598a9d8c7ec134740af48911cc60e72bf638eb Mon Sep 17 00:00:00 2001
From: Kirk True
Date: Mon, 9 Dec 2024 23:01:51 -0800
Subject: [PATCH 01/13] KAFKA-18040; fix for test that ensures produce during
follower shutdown (#18108)
Test lacked the proper configuration for the offset topic replication. As a result, when the follower was shut down, the coordinator did not failover properly.
Reviewers: TaiJuWu , David Jacot
---
.../scala/integration/kafka/api/BaseProducerSendTest.scala | 4 +++-
.../scala/integration/kafka/server/QuorumTestHarness.scala | 1 -
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/core/src/test/scala/integration/kafka/api/BaseProducerSendTest.scala b/core/src/test/scala/integration/kafka/api/BaseProducerSendTest.scala
index be853d9d990bf..99aefe0e51b87 100644
--- a/core/src/test/scala/integration/kafka/api/BaseProducerSendTest.scala
+++ b/core/src/test/scala/integration/kafka/api/BaseProducerSendTest.scala
@@ -34,6 +34,7 @@ import org.apache.kafka.common.network.{ConnectionMode, ListenerName}
import org.apache.kafka.common.record.TimestampType
import org.apache.kafka.common.security.auth.SecurityProtocol
import org.apache.kafka.common.{KafkaException, TopicPartition}
+import org.apache.kafka.coordinator.group.GroupCoordinatorConfig
import org.apache.kafka.server.config.ServerLogConfigs
import org.junit.jupiter.api.Assertions._
import org.junit.jupiter.api.{AfterEach, BeforeEach, TestInfo}
@@ -50,6 +51,7 @@ abstract class BaseProducerSendTest extends KafkaServerTestHarness {
def generateConfigs: scala.collection.Seq[KafkaConfig] = {
val overridingProps = new Properties()
val numServers = 2
+ overridingProps.put(GroupCoordinatorConfig.OFFSETS_TOPIC_REPLICATION_FACTOR_CONFIG, 2.toShort)
overridingProps.put(ServerLogConfigs.NUM_PARTITIONS_CONFIG, 4.toString)
TestUtils.createBrokerConfigs(
numServers,
@@ -367,7 +369,7 @@ abstract class BaseProducerSendTest extends KafkaServerTestHarness {
}
@ParameterizedTest(name = TestInfoUtils.TestWithParameterizedQuorumAndGroupProtocolNames)
- @MethodSource(Array("getTestQuorumAndGroupProtocolParametersClassicGroupProtocolOnly_KAFKA_16176"))
+ @MethodSource(Array("getTestQuorumAndGroupProtocolParametersAll"))
def testSendToPartitionWithFollowerShutdownShouldNotTimeout(quorum: String, groupProtocol: String): Unit = {
// This test produces to a leader that has follower that is shutting down. It shows that
// the produce request succeed, do not timeout and do not need to be retried.
diff --git a/core/src/test/scala/integration/kafka/server/QuorumTestHarness.scala b/core/src/test/scala/integration/kafka/server/QuorumTestHarness.scala
index f4d6816da8cc4..ac59f026b0c2f 100755
--- a/core/src/test/scala/integration/kafka/server/QuorumTestHarness.scala
+++ b/core/src/test/scala/integration/kafka/server/QuorumTestHarness.scala
@@ -575,7 +575,6 @@ object QuorumTestHarness {
// The following parameter groups are to *temporarily* avoid bugs with the CONSUMER group protocol Consumer
// implementation that would otherwise cause tests to fail.
- def getTestQuorumAndGroupProtocolParametersClassicGroupProtocolOnly_KAFKA_16176: stream.Stream[Arguments] = getTestQuorumAndGroupProtocolParametersClassicGroupProtocolOnly
def getTestQuorumAndGroupProtocolParametersClassicGroupProtocolOnly_KAFKA_17960: stream.Stream[Arguments] = getTestQuorumAndGroupProtocolParametersClassicGroupProtocolOnly
def getTestQuorumAndGroupProtocolParametersClassicGroupProtocolOnly_KAFKA_17961: stream.Stream[Arguments] = getTestQuorumAndGroupProtocolParametersClassicGroupProtocolOnly
def getTestQuorumAndGroupProtocolParametersClassicGroupProtocolOnly_KAFKA_17964: stream.Stream[Arguments] = getTestQuorumAndGroupProtocolParametersClassicGroupProtocolOnly
From 08aa8ec3bfdbf2c508ceaf3072bba516366d7a1a Mon Sep 17 00:00:00 2001
From: Mickael Maison
Date: Tue, 10 Dec 2024 09:00:08 +0100
Subject: [PATCH 02/13] KAFKA-18178 Remove ZkSecurityMigrator (#18092)
Reviewers: Chia-Ping Tsai
---
.../kafka/admin/ZkSecurityMigrator.scala | 307 ------------------
.../kafka/zk/ZkSecurityMigratorUtils.scala | 30 --
docs/upgrade.html | 2 +
3 files changed, 2 insertions(+), 337 deletions(-)
delete mode 100644 core/src/main/scala/kafka/admin/ZkSecurityMigrator.scala
delete mode 100644 core/src/main/scala/kafka/zk/ZkSecurityMigratorUtils.scala
diff --git a/core/src/main/scala/kafka/admin/ZkSecurityMigrator.scala b/core/src/main/scala/kafka/admin/ZkSecurityMigrator.scala
deleted file mode 100644
index 77662c3b11464..0000000000000
--- a/core/src/main/scala/kafka/admin/ZkSecurityMigrator.scala
+++ /dev/null
@@ -1,307 +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.admin
-
-import joptsimple.{OptionSet, OptionSpec, OptionSpecBuilder}
-import kafka.server.KafkaConfig
-import kafka.utils.{Logging, ToolsUtils}
-import kafka.zk.{ControllerZNode, KafkaZkClient, ZkData, ZkSecurityMigratorUtils}
-import org.apache.kafka.common.security.JaasUtils
-import org.apache.kafka.common.utils.{Exit, Time, Utils}
-import org.apache.kafka.server.config.ZkConfigs
-import org.apache.kafka.server.util.{CommandDefaultOptions, CommandLineUtils}
-import org.apache.zookeeper.AsyncCallback.{ChildrenCallback, StatCallback}
-import org.apache.zookeeper.KeeperException
-import org.apache.zookeeper.KeeperException.Code
-import org.apache.zookeeper.client.ZKClientConfig
-import org.apache.zookeeper.data.Stat
-
-import scala.annotation.tailrec
-import scala.collection.mutable
-import scala.jdk.CollectionConverters._
-import scala.concurrent._
-import scala.concurrent.duration._
-
-/**
- * This tool is to be used when making access to ZooKeeper authenticated or
- * the other way around, when removing authenticated access. The exact steps
- * to migrate a Kafka cluster from unsecure to secure with respect to ZooKeeper
- * access are the following:
- *
- * 1- Perform a rolling upgrade of Kafka servers, setting zookeeper.set.acl to false
- * and passing a valid JAAS login file via the system property
- * java.security.auth.login.config
- * 2- Perform a second rolling upgrade keeping the system property for the login file
- * and now setting zookeeper.set.acl to true
- * 3- Finally run this tool. There is a script under ./bin. Run
- * ./bin/zookeeper-security-migration.sh --help
- * to see the configuration parameters. An example of running it is the following:
- * ./bin/zookeeper-security-migration.sh --zookeeper.acl=secure --zookeeper.connect=localhost:2181
- *
- * To convert a cluster from secure to unsecure, we need to perform the following
- * steps:
- * 1- Perform a rolling upgrade setting zookeeper.set.acl to false for each server
- * 2- Run this migration tool, setting zookeeper.acl to unsecure
- * 3- Perform another rolling upgrade to remove the system property setting the
- * login file (java.security.auth.login.config).
- */
-
-object ZkSecurityMigrator extends Logging {
- private val usageMessage = ("ZooKeeper Migration Tool Help. This tool updates the ACLs of "
- + "znodes as part of the process of setting up ZooKeeper "
- + "authentication.")
- private val tlsConfigFileOption = "zk-tls-config-file"
-
- def run(args: Array[String]): Unit = {
- val jaasFile = System.getProperty(JaasUtils.JAVA_LOGIN_CONFIG_PARAM)
- val opts = new ZkSecurityMigratorOptions(args)
-
- CommandLineUtils.maybePrintHelpOrVersion(opts, usageMessage)
-
- // Must have either SASL or TLS mutual authentication enabled to use this tool.
- // Instantiate the client config we will use so that we take into account config provided via the CLI option
- // and system properties passed via -D parameters if no CLI option is given.
- val zkClientConfig = createZkClientConfigFromOption(opts.options, opts.zkTlsConfigFile).getOrElse(new ZKClientConfig())
- val tlsClientAuthEnabled = KafkaConfig.zkTlsClientAuthEnabled(zkClientConfig)
- if (jaasFile == null && !tlsClientAuthEnabled) {
- val errorMsg = s"No JAAS configuration file has been specified and no TLS client certificate has been specified. Please make sure that you set " +
- s"the system property ${JaasUtils.JAVA_LOGIN_CONFIG_PARAM} or provide a ZooKeeper client TLS configuration via --$tlsConfigFileOption " +
- s"identifying at least ${ZkConfigs.ZK_SSL_CLIENT_ENABLE_CONFIG}, ${ZkConfigs.ZK_CLIENT_CNXN_SOCKET_CONFIG}, and ${ZkConfigs.ZK_SSL_KEY_STORE_LOCATION_CONFIG}"
- System.err.println("ERROR: %s".format(errorMsg))
- throw new IllegalArgumentException("Incorrect configuration")
- }
-
- if (!tlsClientAuthEnabled && !JaasUtils.isZkSaslEnabled) {
- val errorMsg = "Security isn't enabled, most likely the file isn't set properly: %s".format(jaasFile)
- System.out.println("ERROR: %s".format(errorMsg))
- throw new IllegalArgumentException("Incorrect configuration")
- }
-
- val zkAcl = opts.options.valueOf(opts.zkAclOpt) match {
- case "secure" =>
- info("zookeeper.acl option is secure")
- true
- case "unsecure" =>
- info("zookeeper.acl option is unsecure")
- false
- case _ =>
- ToolsUtils.printUsageAndExit(opts.parser, usageMessage)
- }
- val zkUrl = opts.options.valueOf(opts.zkUrlOpt)
- val zkSessionTimeout = opts.options.valueOf(opts.zkSessionTimeoutOpt).intValue
- val zkConnectionTimeout = opts.options.valueOf(opts.zkConnectionTimeoutOpt).intValue
- val zkClient = KafkaZkClient(zkUrl, zkAcl, zkSessionTimeout, zkConnectionTimeout,
- Int.MaxValue, Time.SYSTEM, zkClientConfig = zkClientConfig, name = "ZkSecurityMigrator", enableEntityConfigControllerCheck = false)
- val enablePathCheck = opts.options.has(opts.enablePathCheckOpt)
- val migrator = new ZkSecurityMigrator(zkClient)
- migrator.run(enablePathCheck)
- }
-
- def main(args: Array[String]): Unit = {
- try {
- run(args)
- } catch {
- case e: Exception =>
- e.printStackTrace()
- // must exit with non-zero status so system tests will know we failed
- Exit.exit(1)
- }
- }
-
- def createZkClientConfigFromFile(filename: String) : ZKClientConfig = {
- val zkTlsConfigFileProps = Utils.loadProps(filename, ZkConfigs.ZK_SSL_CONFIG_TO_SYSTEM_PROPERTY_MAP.asScala.keys.toList.asJava)
- val zkClientConfig = new ZKClientConfig() // Initializes based on any system properties that have been set
- // Now override any set system properties with explicitly-provided values from the config file
- // Emit INFO logs due to camel-case property names encouraging mistakes -- help people see mistakes they make
- info(s"Found ${zkTlsConfigFileProps.size()} ZooKeeper client configuration properties in file $filename")
- zkTlsConfigFileProps.asScala.foreachEntry { (key, value) =>
- info(s"Setting $key")
- KafkaConfig.setZooKeeperClientProperty(zkClientConfig, key, value)
- }
- zkClientConfig
- }
-
- private[admin] def createZkClientConfigFromOption(options: OptionSet, option: OptionSpec[String]) : Option[ZKClientConfig] =
- if (!options.has(option))
- None
- else
- Some(createZkClientConfigFromFile(options.valueOf(option)))
-
- private class ZkSecurityMigratorOptions(args: Array[String]) extends CommandDefaultOptions(args) {
- val zkAclOpt: OptionSpec[String] = parser.accepts("zookeeper.acl", "Indicates whether to make the Kafka znodes in ZooKeeper secure or unsecure."
- + " The options are 'secure' and 'unsecure'").withRequiredArg().ofType(classOf[String])
- val zkUrlOpt: OptionSpec[String] = parser.accepts("zookeeper.connect", "Sets the ZooKeeper connect string (ensemble). This parameter " +
- "takes a comma-separated list of host:port pairs.").withRequiredArg().defaultsTo("localhost:2181").
- ofType(classOf[String])
- val zkSessionTimeoutOpt: OptionSpec[Integer] = parser.accepts("zookeeper.session.timeout", "Sets the ZooKeeper session timeout.").
- withRequiredArg().ofType(classOf[java.lang.Integer]).defaultsTo(30000)
- val zkConnectionTimeoutOpt: OptionSpec[Integer] = parser.accepts("zookeeper.connection.timeout", "Sets the ZooKeeper connection timeout.").
- withRequiredArg().ofType(classOf[java.lang.Integer]).defaultsTo(30000)
- val enablePathCheckOpt: OptionSpecBuilder = parser.accepts("enable.path.check", "Checks if all the root paths exist in ZooKeeper " +
- "before migration. If not, exit the command.")
- val zkTlsConfigFile: OptionSpec[String] = parser.accepts(tlsConfigFileOption,
- "Identifies the file where ZooKeeper client TLS connectivity properties are defined. Any properties other than " +
- ZkConfigs.ZK_SSL_CONFIG_TO_SYSTEM_PROPERTY_MAP.asScala.keys.mkString(", ") + " are ignored.")
- .withRequiredArg().describedAs("ZooKeeper TLS configuration").ofType(classOf[String])
- options = parser.parse(args : _*)
- }
-}
-
-class ZkSecurityMigrator(zkClient: KafkaZkClient) extends Logging {
- private val zkSecurityMigratorUtils = new ZkSecurityMigratorUtils(zkClient)
- private val futures = new mutable.Queue[Future[String]]
-
- private def setAcl(path: String, setPromise: Promise[String]): Unit = {
- info("Setting ACL for path %s".format(path))
- zkSecurityMigratorUtils.currentZooKeeper.setACL(path, zkClient.defaultAcls(path).asJava, -1, SetACLCallback, setPromise)
- }
-
- private def retrieveChildren(path: String, childrenPromise: Promise[String]): Unit = {
- info("Getting children to set ACLs for path %s".format(path))
- zkSecurityMigratorUtils.currentZooKeeper.getChildren(path, false, GetChildrenCallback, childrenPromise)
- }
-
- private def setAclIndividually(path: String): Unit = {
- val setPromise = Promise[String]()
- futures.synchronized {
- futures += setPromise.future
- }
- setAcl(path, setPromise)
- }
-
- private def setAclsRecursively(path: String): Unit = {
- val setPromise = Promise[String]()
- val childrenPromise = Promise[String]()
- futures.synchronized {
- futures += setPromise.future
- futures += childrenPromise.future
- }
- setAcl(path, setPromise)
- retrieveChildren(path, childrenPromise)
- }
-
- private object GetChildrenCallback extends ChildrenCallback {
- def processResult(rc: Int,
- path: String,
- ctx: Object,
- children: java.util.List[String]): Unit = {
- val zkHandle = zkSecurityMigratorUtils.currentZooKeeper
- val promise = ctx.asInstanceOf[Promise[String]]
- Code.get(rc) match {
- case Code.OK =>
- // Set ACL for each child
- children.asScala.map { child =>
- path match {
- case "/" => s"/$child"
- case path => s"$path/$child"
- }
- }.foreach(setAclsRecursively)
- promise success "done"
- case Code.CONNECTIONLOSS =>
- zkHandle.getChildren(path, false, GetChildrenCallback, ctx)
- case Code.NONODE =>
- warn("Node is gone, it could be have been legitimately deleted: %s".format(path))
- promise success "done"
- case Code.SESSIONEXPIRED =>
- // Starting a new session isn't really a problem, but it'd complicate
- // the logic of the tool, so we quit and let the user re-run it.
- System.out.println("ZooKeeper session expired while changing ACLs")
- promise failure KeeperException.create(Code.get(rc))
- case _ =>
- System.out.println("Unexpected return code: %d".format(rc))
- promise failure KeeperException.create(Code.get(rc))
- }
- }
- }
-
- private object SetACLCallback extends StatCallback {
- def processResult(rc: Int,
- path: String,
- ctx: Object,
- stat: Stat): Unit = {
- val zkHandle = zkSecurityMigratorUtils.currentZooKeeper
- val promise = ctx.asInstanceOf[Promise[String]]
-
- Code.get(rc) match {
- case Code.OK =>
- info("Successfully set ACLs for %s".format(path))
- promise success "done"
- case Code.CONNECTIONLOSS =>
- zkHandle.setACL(path, zkClient.defaultAcls(path).asJava, -1, SetACLCallback, ctx)
- case Code.NONODE =>
- warn("Znode is gone, it could be have been legitimately deleted: %s".format(path))
- promise success "done"
- case Code.SESSIONEXPIRED =>
- // Starting a new session isn't really a problem, but it'd complicate
- // the logic of the tool, so we quit and let the user re-run it.
- System.out.println("ZooKeeper session expired while changing ACLs")
- promise failure KeeperException.create(Code.get(rc))
- case _ =>
- System.out.println("Unexpected return code: %d".format(rc))
- promise failure KeeperException.create(Code.get(rc))
- }
- }
- }
-
- private def run(enablePathCheck: Boolean): Unit = {
- try {
- setAclIndividually("/")
- checkPathExistenceAndMaybeExit(enablePathCheck)
- for (path <- ZkData.SecureRootPaths) {
- debug("Going to set ACL for %s".format(path))
- if (path == ControllerZNode.path && !zkClient.pathExists(path)) {
- debug("Ignoring to set ACL for %s, because it doesn't exist".format(path))
- } else {
- zkClient.makeSurePersistentPathExists(path)
- setAclsRecursively(path)
- }
- }
-
- @tailrec
- def recurse(): Unit = {
- val future = futures.synchronized {
- futures.headOption
- }
- future match {
- case Some(a) =>
- Await.result(a, 6000 millis)
- futures.synchronized { futures.dequeue() }
- recurse()
- case None =>
- }
- }
- recurse()
-
- } finally {
- zkClient.close()
- }
- }
-
- private def checkPathExistenceAndMaybeExit(enablePathCheck: Boolean): Unit = {
- val nonExistingSecureRootPaths = ZkData.SecureRootPaths.filterNot(zkClient.pathExists)
- if (nonExistingSecureRootPaths.nonEmpty) {
- println(s"Warning: The following secure root paths do not exist in ZooKeeper: ${nonExistingSecureRootPaths.mkString(",")}")
- println("That might be due to an incorrect chroot is specified when executing the command.")
- if (enablePathCheck) {
- println("Exit the command.")
- // must exit with non-zero status so system tests will know we failed
- Exit.exit(1)
- }
- }
- }
-}
diff --git a/core/src/main/scala/kafka/zk/ZkSecurityMigratorUtils.scala b/core/src/main/scala/kafka/zk/ZkSecurityMigratorUtils.scala
deleted file mode 100644
index 31a7ba2907379..0000000000000
--- a/core/src/main/scala/kafka/zk/ZkSecurityMigratorUtils.scala
+++ /dev/null
@@ -1,30 +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.zk
-
-import org.apache.zookeeper.ZooKeeper
-
-/**
- * This class should only be used in ZkSecurityMigrator tool.
- * This class will be removed after we migrate ZkSecurityMigrator away from ZK's asynchronous API.
- * @param kafkaZkClient
- */
-class ZkSecurityMigratorUtils(val kafkaZkClient: KafkaZkClient) {
-
- def currentZooKeeper: ZooKeeper = kafkaZkClient.currentZooKeeper
-
-}
diff --git a/docs/upgrade.html b/docs/upgrade.html
index 5967aeb9cee35..dcd9c4334d75a 100644
--- a/docs/upgrade.html
+++ b/docs/upgrade.html
@@ -130,6 +130,8 @@
The kafka.admin.ZkSecurityMigrator tool was removed.
+
Connect
From c8380ae77950bbd2161cf14cbdeb007562655f2f Mon Sep 17 00:00:00 2001
From: PoAn Yang
Date: Tue, 10 Dec 2024 21:02:20 +0800
Subject: [PATCH 03/13] KAFKA-17750: Extend kafka-consumer-groups command line
tool to support new consumer group (part 2) (#18034)
* Add fields `groupEpoch` and `targetAssignmentEpoch` to `ConsumerGroupDescription.java`.
* Add fields `memberEpoch` and `upgraded` to `MemberDescription.java`.
* Add assertion to `PlaintextAdminIntegrationTest#testDescribeClassicGroups` to make sure member in classic group returns `upgraded` as `Optional.empty`.
* Add new case `testConsumerGroupWithMemberMigration` to `PlaintextAdminIntegrationTest` to make sure migration member has correct `upgraded` value. Add assertion for `groupEpoch`, `targetAssignmentEpoch`, `memberEpoch` as well.
Reviewers: David Jacot
Signed-off-by: PoAn Yang
---
.../admin/ConsumerGroupDescription.java | 63 +++++++-----
.../clients/admin/MemberDescription.java | 75 +++++++++++++-
.../DescribeClassicGroupsHandler.java | 5 +-
.../DescribeConsumerGroupsHandler.java | 17 +++-
.../clients/admin/KafkaAdminClientTest.java | 23 ++++-
.../clients/admin/MemberDescriptionTest.java | 33 +++++++
.../DescribeConsumerGroupsHandlerTest.java | 90 ++++++++++++-----
.../api/PlaintextAdminIntegrationTest.scala | 99 ++++++++++++++++++-
.../group/ConsumerGroupServiceTest.java | 13 ++-
9 files changed, 347 insertions(+), 71 deletions(-)
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/ConsumerGroupDescription.java b/clients/src/main/java/org/apache/kafka/clients/admin/ConsumerGroupDescription.java
index 4cbc5b4b43bb3..dd1b4b4cb5c7e 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/ConsumerGroupDescription.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/ConsumerGroupDescription.java
@@ -27,6 +27,7 @@
import java.util.Collections;
import java.util.List;
import java.util.Objects;
+import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@@ -42,9 +43,11 @@ public class ConsumerGroupDescription {
private final GroupState groupState;
private final Node coordinator;
private final Set authorizedOperations;
+ private final Optional groupEpoch;
+ private final Optional targetAssignmentEpoch;
/**
- * @deprecated Since 4.0. Use {@link #ConsumerGroupDescription(String, boolean, Collection, String, GroupState, Node)}.
+ * @deprecated Since 4.0. Use {@link #ConsumerGroupDescription(String, boolean, Collection, String, GroupType, GroupState, Node, Set, Optional, Optional)}.
*/
@Deprecated
public ConsumerGroupDescription(String groupId,
@@ -57,7 +60,7 @@ public ConsumerGroupDescription(String groupId,
}
/**
- * @deprecated Since 4.0. Use {@link #ConsumerGroupDescription(String, boolean, Collection, String, GroupState, Node, Set)}.
+ * @deprecated Since 4.0. Use {@link #ConsumerGroupDescription(String, boolean, Collection, String, GroupType, GroupState, Node, Set, Optional, Optional)}.
*/
@Deprecated
public ConsumerGroupDescription(String groupId,
@@ -71,7 +74,7 @@ public ConsumerGroupDescription(String groupId,
}
/**
- * @deprecated Since 4.0. Use {@link #ConsumerGroupDescription(String, boolean, Collection, String, GroupType, GroupState, Node, Set)}.
+ * @deprecated Since 4.0. Use {@link #ConsumerGroupDescription(String, boolean, Collection, String, GroupType, GroupState, Node, Set, Optional, Optional)}.
*/
@Deprecated
public ConsumerGroupDescription(String groupId,
@@ -90,25 +93,8 @@ public ConsumerGroupDescription(String groupId,
this.groupState = GroupState.parse(state.name());
this.coordinator = coordinator;
this.authorizedOperations = authorizedOperations;
- }
-
- public ConsumerGroupDescription(String groupId,
- boolean isSimpleConsumerGroup,
- Collection members,
- String partitionAssignor,
- GroupState groupState,
- Node coordinator) {
- this(groupId, isSimpleConsumerGroup, members, partitionAssignor, groupState, coordinator, Collections.emptySet());
- }
-
- public ConsumerGroupDescription(String groupId,
- boolean isSimpleConsumerGroup,
- Collection members,
- String partitionAssignor,
- GroupState groupState,
- Node coordinator,
- Set authorizedOperations) {
- this(groupId, isSimpleConsumerGroup, members, partitionAssignor, GroupType.CLASSIC, groupState, coordinator, authorizedOperations);
+ this.groupEpoch = Optional.empty();
+ this.targetAssignmentEpoch = Optional.empty();
}
public ConsumerGroupDescription(String groupId,
@@ -118,7 +104,9 @@ public ConsumerGroupDescription(String groupId,
GroupType type,
GroupState groupState,
Node coordinator,
- Set authorizedOperations) {
+ Set authorizedOperations,
+ Optional groupEpoch,
+ Optional targetAssignmentEpoch) {
this.groupId = groupId == null ? "" : groupId;
this.isSimpleConsumerGroup = isSimpleConsumerGroup;
this.members = members == null ? Collections.emptyList() : List.copyOf(members);
@@ -127,6 +115,8 @@ public ConsumerGroupDescription(String groupId,
this.groupState = groupState;
this.coordinator = coordinator;
this.authorizedOperations = authorizedOperations;
+ this.groupEpoch = groupEpoch;
+ this.targetAssignmentEpoch = targetAssignmentEpoch;
}
@Override
@@ -141,12 +131,15 @@ public boolean equals(final Object o) {
type == that.type &&
groupState == that.groupState &&
Objects.equals(coordinator, that.coordinator) &&
- Objects.equals(authorizedOperations, that.authorizedOperations);
+ Objects.equals(authorizedOperations, that.authorizedOperations) &&
+ Objects.equals(groupEpoch, that.groupEpoch) &&
+ Objects.equals(targetAssignmentEpoch, that.targetAssignmentEpoch);
}
@Override
public int hashCode() {
- return Objects.hash(groupId, isSimpleConsumerGroup, members, partitionAssignor, type, groupState, coordinator, authorizedOperations);
+ return Objects.hash(groupId, isSimpleConsumerGroup, members, partitionAssignor, type, groupState, coordinator,
+ authorizedOperations, groupEpoch, targetAssignmentEpoch);
}
/**
@@ -215,6 +208,24 @@ public Set authorizedOperations() {
return authorizedOperations;
}
+ /**
+ * The epoch of the consumer group.
+ * The optional is set to an integer if it is a {@link GroupType#CONSUMER} group, and to empty if it
+ * is a {@link GroupType#CLASSIC} group.
+ */
+ public Optional groupEpoch() {
+ return groupEpoch;
+ }
+
+ /**
+ * The epoch of the target assignment.
+ * The optional is set to an integer if it is a {@link GroupType#CONSUMER} group, and to empty if it
+ * is a {@link GroupType#CLASSIC} group.
+ */
+ public Optional targetAssignmentEpoch() {
+ return targetAssignmentEpoch;
+ }
+
@Override
public String toString() {
return "(groupId=" + groupId +
@@ -225,6 +236,8 @@ public String toString() {
", groupState=" + groupState +
", coordinator=" + coordinator +
", authorizedOperations=" + authorizedOperations +
+ ", groupEpoch=" + groupEpoch.orElse(null) +
+ ", targetAssignmentEpoch=" + targetAssignmentEpoch.orElse(null) +
")";
}
}
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/MemberDescription.java b/clients/src/main/java/org/apache/kafka/clients/admin/MemberDescription.java
index 5ca7dba86f8f4..0785f2e67155f 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/MemberDescription.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/MemberDescription.java
@@ -16,6 +16,8 @@
*/
package org.apache.kafka.clients.admin;
+import org.apache.kafka.common.GroupType;
+
import java.util.Collections;
import java.util.Objects;
import java.util.Optional;
@@ -30,13 +32,18 @@ public class MemberDescription {
private final String host;
private final MemberAssignment assignment;
private final Optional targetAssignment;
+ private final Optional memberEpoch;
+ private final Optional upgraded;
- public MemberDescription(String memberId,
+ public MemberDescription(
+ String memberId,
Optional groupInstanceId,
String clientId,
String host,
MemberAssignment assignment,
- Optional targetAssignment
+ Optional targetAssignment,
+ Optional memberEpoch,
+ Optional upgraded
) {
this.memberId = memberId == null ? "" : memberId;
this.groupInstanceId = groupInstanceId;
@@ -45,8 +52,38 @@ public MemberDescription(String memberId,
this.assignment = assignment == null ?
new MemberAssignment(Collections.emptySet()) : assignment;
this.targetAssignment = targetAssignment;
+ this.memberEpoch = memberEpoch;
+ this.upgraded = upgraded;
}
+ /**
+ * @deprecated Since 4.0. Use {@link #MemberDescription(String, Optional, String, String, MemberAssignment, Optional, Optional, Optional)}.
+ */
+ @Deprecated
+ public MemberDescription(
+ String memberId,
+ Optional groupInstanceId,
+ String clientId,
+ String host,
+ MemberAssignment assignment,
+ Optional targetAssignment
+ ) {
+ this(
+ memberId,
+ groupInstanceId,
+ clientId,
+ host,
+ assignment,
+ targetAssignment,
+ Optional.empty(),
+ Optional.empty()
+ );
+ }
+
+ /**
+ * @deprecated Since 4.0. Use {@link #MemberDescription(String, Optional, String, String, MemberAssignment, Optional, Optional, Optional)}.
+ */
+ @Deprecated
public MemberDescription(
String memberId,
Optional groupInstanceId,
@@ -64,6 +101,10 @@ public MemberDescription(
);
}
+ /**
+ * @deprecated Since 4.0. Use {@link #MemberDescription(String, Optional, String, String, MemberAssignment, Optional, Optional, Optional)}.
+ */
+ @Deprecated
public MemberDescription(String memberId,
String clientId,
String host,
@@ -81,12 +122,14 @@ public boolean equals(Object o) {
clientId.equals(that.clientId) &&
host.equals(that.host) &&
assignment.equals(that.assignment) &&
- targetAssignment.equals(that.targetAssignment);
+ targetAssignment.equals(that.targetAssignment) &&
+ memberEpoch.equals(that.memberEpoch) &&
+ upgraded.equals(that.upgraded);
}
@Override
public int hashCode() {
- return Objects.hash(memberId, groupInstanceId, clientId, host, assignment, targetAssignment);
+ return Objects.hash(memberId, groupInstanceId, clientId, host, assignment, targetAssignment, memberEpoch, upgraded);
}
/**
@@ -131,6 +174,25 @@ public Optional targetAssignment() {
return targetAssignment;
}
+ /**
+ * The epoch of the group member.
+ * The optional is set to an integer if the member is in a {@link GroupType#CONSUMER} group, and to empty if it
+ * is in a {@link GroupType#CLASSIC} group.
+ */
+ public Optional memberEpoch() {
+ return memberEpoch;
+ }
+
+ /**
+ * The flag indicating whether a member within a {@link GroupType#CONSUMER} group uses the
+ * {@link GroupType#CONSUMER} protocol.
+ * The optional is set to true if it does, to false if it does not, and to empty if it is unknown or if the group
+ * is a {@link GroupType#CLASSIC} group.
+ */
+ public Optional upgraded() {
+ return upgraded;
+ }
+
@Override
public String toString() {
return "(memberId=" + memberId +
@@ -138,6 +200,9 @@ public String toString() {
", clientId=" + clientId +
", host=" + host +
", assignment=" + assignment +
- ", targetAssignment=" + targetAssignment + ")";
+ ", targetAssignment=" + targetAssignment +
+ ", memberEpoch=" + memberEpoch.orElse(null) +
+ ", upgraded=" + upgraded.orElse(null) +
+ ")";
}
}
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/internals/DescribeClassicGroupsHandler.java b/clients/src/main/java/org/apache/kafka/clients/admin/internals/DescribeClassicGroupsHandler.java
index 77c04c5d5f02e..686ee43a44b2b 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/internals/DescribeClassicGroupsHandler.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/internals/DescribeClassicGroupsHandler.java
@@ -136,7 +136,10 @@ public ApiResult handleResponse(
Optional.ofNullable(groupMember.groupInstanceId()),
groupMember.clientId(),
groupMember.clientHost(),
- new MemberAssignment(partitions)));
+ new MemberAssignment(partitions),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty()));
});
final ClassicGroupDescription classicGroupDescription =
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/internals/DescribeConsumerGroupsHandler.java b/clients/src/main/java/org/apache/kafka/clients/admin/internals/DescribeConsumerGroupsHandler.java
index 1d911e2f0c7f4..457675e92675a 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/internals/DescribeConsumerGroupsHandler.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/internals/DescribeConsumerGroupsHandler.java
@@ -222,7 +222,9 @@ private ApiResult handledConsumerGroup
groupMember.clientId(),
groupMember.clientHost(),
new MemberAssignment(convertAssignment(groupMember.assignment())),
- Optional.of(new MemberAssignment(convertAssignment(groupMember.targetAssignment())))
+ Optional.of(new MemberAssignment(convertAssignment(groupMember.targetAssignment()))),
+ Optional.of(groupMember.memberEpoch()),
+ groupMember.memberType() == -1 ? Optional.empty() : Optional.of(groupMember.memberType() == 1)
))
);
@@ -235,7 +237,9 @@ private ApiResult handledConsumerGroup
GroupType.CONSUMER,
GroupState.parse(describedGroup.groupState()),
coordinator,
- authorizedOperations
+ authorizedOperations,
+ Optional.of(describedGroup.groupEpoch()),
+ Optional.of(describedGroup.assignmentEpoch())
);
completed.put(groupIdKey, consumerGroupDescription);
}
@@ -281,7 +285,10 @@ private ApiResult handledClassicGroupR
Optional.ofNullable(groupMember.groupInstanceId()),
groupMember.clientId(),
groupMember.clientHost(),
- new MemberAssignment(partitions)));
+ new MemberAssignment(partitions),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty()));
}
final ConsumerGroupDescription consumerGroupDescription =
new ConsumerGroupDescription(groupIdKey.idValue, protocolType.isEmpty(),
@@ -290,7 +297,9 @@ private ApiResult handledClassicGroupR
GroupType.CLASSIC,
GroupState.parse(describedGroup.groupState()),
coordinator,
- authorizedOperations);
+ authorizedOperations,
+ Optional.empty(),
+ Optional.empty());
completed.put(groupIdKey, consumerGroupDescription);
} else {
failed.put(groupIdKey, new IllegalArgumentException(
diff --git a/clients/src/test/java/org/apache/kafka/clients/admin/KafkaAdminClientTest.java b/clients/src/test/java/org/apache/kafka/clients/admin/KafkaAdminClientTest.java
index b0b48e33c67ff..44f6e1f5a8891 100644
--- a/clients/src/test/java/org/apache/kafka/clients/admin/KafkaAdminClientTest.java
+++ b/clients/src/test/java/org/apache/kafka/clients/admin/KafkaAdminClientTest.java
@@ -4057,6 +4057,7 @@ public void testDescribeOldAndNewConsumerGroups() throws Exception {
.setTopicName("foo")
.setPartitions(singletonList(1))
)))
+ .setMemberType((byte) 1)
)),
new ConsumerGroupDescribeResponseData.DescribedGroup()
.setGroupId("grp2")
@@ -4110,14 +4111,18 @@ public void testDescribeOldAndNewConsumerGroups() throws Exception {
),
Optional.of(new MemberAssignment(
Collections.singleton(new TopicPartition("foo", 1))
- ))
+ )),
+ Optional.of(10),
+ Optional.of(true)
)
),
"range",
GroupType.CONSUMER,
GroupState.STABLE,
env.cluster().controller(),
- Collections.emptySet()
+ Collections.emptySet(),
+ Optional.of(10),
+ Optional.of(10)
));
expectedResult.put("grp2", new ConsumerGroupDescription(
"grp2",
@@ -4130,14 +4135,19 @@ public void testDescribeOldAndNewConsumerGroups() throws Exception {
"clientHost",
new MemberAssignment(
Collections.singleton(new TopicPartition("bar", 0))
- )
+ ),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty()
)
),
"range",
GroupType.CLASSIC,
GroupState.STABLE,
env.cluster().controller(),
- Collections.emptySet()
+ Collections.emptySet(),
+ Optional.empty(),
+ Optional.empty()
));
assertEquals(expectedResult, result.all().get());
@@ -8674,7 +8684,10 @@ private static MemberDescription convertToMemberDescriptions(DescribedGroupMembe
Optional.ofNullable(member.groupInstanceId()),
member.clientId(),
member.clientHost(),
- assignment);
+ assignment,
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty());
}
private static ShareMemberDescription convertToShareMemberDescriptions(ShareGroupDescribeResponseData.Member member,
diff --git a/clients/src/test/java/org/apache/kafka/clients/admin/MemberDescriptionTest.java b/clients/src/test/java/org/apache/kafka/clients/admin/MemberDescriptionTest.java
index 0bddc618cfc03..16ce11d7361e5 100644
--- a/clients/src/test/java/org/apache/kafka/clients/admin/MemberDescriptionTest.java
+++ b/clients/src/test/java/org/apache/kafka/clients/admin/MemberDescriptionTest.java
@@ -99,5 +99,38 @@ public void testNonEqual() {
assertNotEquals(STATIC_MEMBER_DESCRIPTION, newInstanceDescription);
assertNotEquals(STATIC_MEMBER_DESCRIPTION.hashCode(), newInstanceDescription.hashCode());
+
+ MemberDescription newTargetAssignmentDescription = new MemberDescription(MEMBER_ID,
+ INSTANCE_ID,
+ CLIENT_ID,
+ HOST,
+ ASSIGNMENT,
+ Optional.of(ASSIGNMENT),
+ Optional.empty(),
+ Optional.empty());
+ assertNotEquals(STATIC_MEMBER_DESCRIPTION, newTargetAssignmentDescription);
+ assertNotEquals(STATIC_MEMBER_DESCRIPTION.hashCode(), newTargetAssignmentDescription.hashCode());
+
+ MemberDescription newMemberEpochDescription = new MemberDescription(MEMBER_ID,
+ INSTANCE_ID,
+ CLIENT_ID,
+ HOST,
+ ASSIGNMENT,
+ Optional.empty(),
+ Optional.of(1),
+ Optional.empty());
+ assertNotEquals(STATIC_MEMBER_DESCRIPTION, newMemberEpochDescription);
+ assertNotEquals(STATIC_MEMBER_DESCRIPTION.hashCode(), newMemberEpochDescription.hashCode());
+
+ MemberDescription newIsClassicDescription = new MemberDescription(MEMBER_ID,
+ INSTANCE_ID,
+ CLIENT_ID,
+ HOST,
+ ASSIGNMENT,
+ Optional.empty(),
+ Optional.empty(),
+ Optional.of(false));
+ assertNotEquals(STATIC_MEMBER_DESCRIPTION, newIsClassicDescription);
+ assertNotEquals(STATIC_MEMBER_DESCRIPTION.hashCode(), newIsClassicDescription.hashCode());
}
}
diff --git a/clients/src/test/java/org/apache/kafka/clients/admin/internals/DescribeConsumerGroupsHandlerTest.java b/clients/src/test/java/org/apache/kafka/clients/admin/internals/DescribeConsumerGroupsHandlerTest.java
index cfbf67e2090d8..20cf0b761e641 100644
--- a/clients/src/test/java/org/apache/kafka/clients/admin/internals/DescribeConsumerGroupsHandlerTest.java
+++ b/clients/src/test/java/org/apache/kafka/clients/admin/internals/DescribeConsumerGroupsHandlerTest.java
@@ -22,6 +22,7 @@
import org.apache.kafka.clients.consumer.ConsumerPartitionAssignor.Assignment;
import org.apache.kafka.clients.consumer.internals.ConsumerProtocol;
import org.apache.kafka.common.ConsumerGroupState;
+import org.apache.kafka.common.GroupState;
import org.apache.kafka.common.GroupType;
import org.apache.kafka.common.Node;
import org.apache.kafka.common.TopicPartition;
@@ -54,6 +55,7 @@
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashSet;
+import java.util.List;
import java.util.Optional;
import java.util.Set;
@@ -152,29 +154,46 @@ public void testInvalidBuildRequest() {
@Test
public void testSuccessfulHandleConsumerGroupResponse() {
DescribeConsumerGroupsHandler handler = new DescribeConsumerGroupsHandler(false, logContext);
- Collection members = singletonList(new MemberDescription(
- "memberId",
- Optional.of("instanceId"),
- "clientId",
- "host",
- new MemberAssignment(Set.of(
- new TopicPartition("foo", 0),
- new TopicPartition("bar", 1))
+ Collection members = List.of(
+ new MemberDescription(
+ "memberId",
+ Optional.of("instanceId"),
+ "clientId",
+ "host",
+ new MemberAssignment(Set.of(
+ new TopicPartition("foo", 0)
+ )),
+ Optional.of(new MemberAssignment(Set.of(
+ new TopicPartition("foo", 1)
+ ))),
+ Optional.of(10),
+ Optional.of(true)
),
- Optional.of(new MemberAssignment(Set.of(
- new TopicPartition("foo", 1),
- new TopicPartition("bar", 2)
- )))
- ));
+ new MemberDescription(
+ "memberId-classic",
+ Optional.of("instanceId-classic"),
+ "clientId-classic",
+ "host",
+ new MemberAssignment(Set.of(
+ new TopicPartition("bar", 0)
+ )),
+ Optional.of(new MemberAssignment(Set.of(
+ new TopicPartition("bar", 1)
+ ))),
+ Optional.of(9),
+ Optional.of(false)
+ ));
ConsumerGroupDescription expected = new ConsumerGroupDescription(
groupId1,
false,
members,
"range",
GroupType.CONSUMER,
- ConsumerGroupState.STABLE,
+ GroupState.STABLE,
coordinator,
- Collections.emptySet()
+ Collections.emptySet(),
+ Optional.of(10),
+ Optional.of(10)
);
AdminApiHandler.ApiResult result = handler.handleResponse(
coordinator,
@@ -189,7 +208,7 @@ public void testSuccessfulHandleConsumerGroupResponse() {
.setAssignmentEpoch(10)
.setAssignorName("range")
.setAuthorizedOperations(Utils.to32BitField(emptySet()))
- .setMembers(singletonList(
+ .setMembers(List.of(
new ConsumerGroupDescribeResponseData.Member()
.setMemberId("memberId")
.setInstanceId("instanceId")
@@ -200,27 +219,44 @@ public void testSuccessfulHandleConsumerGroupResponse() {
.setSubscribedTopicNames(singletonList("foo"))
.setSubscribedTopicRegex("regex")
.setAssignment(new ConsumerGroupDescribeResponseData.Assignment()
- .setTopicPartitions(Arrays.asList(
+ .setTopicPartitions(List.of(
new ConsumerGroupDescribeResponseData.TopicPartitions()
.setTopicId(Uuid.randomUuid())
.setTopicName("foo")
- .setPartitions(Collections.singletonList(0)),
+ .setPartitions(Collections.singletonList(0))
+ )))
+ .setTargetAssignment(new ConsumerGroupDescribeResponseData.Assignment()
+ .setTopicPartitions(List.of(
new ConsumerGroupDescribeResponseData.TopicPartitions()
.setTopicId(Uuid.randomUuid())
- .setTopicName("bar")
+ .setTopicName("foo")
.setPartitions(Collections.singletonList(1))
)))
- .setTargetAssignment(new ConsumerGroupDescribeResponseData.Assignment()
- .setTopicPartitions(Arrays.asList(
+ .setMemberType((byte) 1),
+ new ConsumerGroupDescribeResponseData.Member()
+ .setMemberId("memberId-classic")
+ .setInstanceId("instanceId-classic")
+ .setClientHost("host")
+ .setClientId("clientId-classic")
+ .setMemberEpoch(9)
+ .setRackId("rackid")
+ .setSubscribedTopicNames(singletonList("bar"))
+ .setSubscribedTopicRegex("regex")
+ .setAssignment(new ConsumerGroupDescribeResponseData.Assignment()
+ .setTopicPartitions(List.of(
new ConsumerGroupDescribeResponseData.TopicPartitions()
.setTopicId(Uuid.randomUuid())
- .setTopicName("foo")
- .setPartitions(Collections.singletonList(1)),
+ .setTopicName("bar")
+ .setPartitions(Collections.singletonList(0))
+ )))
+ .setTargetAssignment(new ConsumerGroupDescribeResponseData.Assignment()
+ .setTopicPartitions(List.of(
new ConsumerGroupDescribeResponseData.TopicPartitions()
.setTopicId(Uuid.randomUuid())
.setTopicName("bar")
- .setPartitions(Collections.singletonList(2))
+ .setPartitions(Collections.singletonList(1))
)))
+ .setMemberType((byte) 0)
))
))
)
@@ -232,9 +268,13 @@ public void testSuccessfulHandleConsumerGroupResponse() {
public void testSuccessfulHandleClassicGroupResponse() {
Collection members = singletonList(new MemberDescription(
"memberId",
+ Optional.empty(),
"clientId",
"host",
- new MemberAssignment(tps)));
+ new MemberAssignment(tps),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty()));
ConsumerGroupDescription expected = new ConsumerGroupDescription(
groupId1,
true,
diff --git a/core/src/test/scala/integration/kafka/api/PlaintextAdminIntegrationTest.scala b/core/src/test/scala/integration/kafka/api/PlaintextAdminIntegrationTest.scala
index 64d9cc94c2dda..bd381f0306ecc 100644
--- a/core/src/test/scala/integration/kafka/api/PlaintextAdminIntegrationTest.scala
+++ b/core/src/test/scala/integration/kafka/api/PlaintextAdminIntegrationTest.scala
@@ -1921,12 +1921,17 @@ class PlaintextAdminIntegrationTest extends BaseAdminIntegrationTest {
// Test that we can get information about the test consumer group.
assertTrue(describeWithFakeGroupResult.describedGroups().containsKey(testGroupId))
var testGroupDescription = describeWithFakeGroupResult.describedGroups().get(testGroupId).get()
+ assertEquals(groupType == GroupType.CLASSIC, testGroupDescription.groupEpoch.isEmpty)
+ assertEquals(groupType == GroupType.CLASSIC, testGroupDescription.targetAssignmentEpoch.isEmpty)
assertEquals(testGroupId, testGroupDescription.groupId())
assertFalse(testGroupDescription.isSimpleConsumerGroup)
assertEquals(groupInstanceSet.size, testGroupDescription.members().size())
val members = testGroupDescription.members()
- members.asScala.foreach(member => assertEquals(testClientId, member.clientId()))
+ members.asScala.foreach { member =>
+ assertEquals(testClientId, member.clientId)
+ assertEquals(if (groupType == GroupType.CLASSIC) Optional.empty else Optional.of(true), member.upgraded)
+ }
val topicPartitionsByTopic = members.asScala.flatMap(_.assignment().topicPartitions().asScala).groupBy(_.topic())
topicSet.foreach { topic =>
val topicPartitions = topicPartitionsByTopic.getOrElse(topic, List.empty)
@@ -2058,6 +2063,89 @@ class PlaintextAdminIntegrationTest extends BaseAdminIntegrationTest {
}
}
+ /**
+ * Test the consumer group APIs.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = Array("kraft"))
+ def testConsumerGroupWithMemberMigration(quorum: String): Unit = {
+ val config = createConfig
+ client = Admin.create(config)
+ var classicConsumer: Consumer[Array[Byte], Array[Byte]] = null
+ var consumerConsumer: Consumer[Array[Byte], Array[Byte]] = null
+ try {
+ // Verify that initially there are no consumer groups to list.
+ val list1 = client.listConsumerGroups
+ assertEquals(0, list1.all.get.size)
+ assertEquals(0, list1.errors.get.size)
+ assertEquals(0, list1.valid.get.size)
+ val testTopicName = "test_topic"
+ val testNumPartitions = 2
+
+ client.createTopics(util.Arrays.asList(
+ new NewTopic(testTopicName, testNumPartitions, 1.toShort),
+ )).all.get
+ waitForTopics(client, List(testTopicName), List())
+
+ val producer = createProducer()
+ try {
+ producer.send(new ProducerRecord(testTopicName, 0, null, null))
+ producer.send(new ProducerRecord(testTopicName, 1, null, null))
+ producer.flush()
+ } finally {
+ Utils.closeQuietly(producer, "producer")
+ }
+
+ val testGroupId = "test_group_id"
+ val testClassicClientId = "test_classic_client_id"
+ val testConsumerClientId = "test_consumer_client_id"
+
+ val newConsumerConfig = new Properties(consumerConfig)
+ newConsumerConfig.put(ConsumerConfig.GROUP_ID_CONFIG, testGroupId)
+ newConsumerConfig.put(ConsumerConfig.CLIENT_ID_CONFIG, testClassicClientId)
+ consumerConfig.put(ConsumerConfig.GROUP_PROTOCOL_CONFIG, GroupProtocol.CLASSIC.name)
+
+ classicConsumer = createConsumer(configOverrides = newConsumerConfig)
+ classicConsumer.subscribe(List(testTopicName).asJava)
+ classicConsumer.poll(JDuration.ofMillis(1000))
+
+ newConsumerConfig.put(ConsumerConfig.CLIENT_ID_CONFIG, testConsumerClientId)
+ consumerConfig.put(ConsumerConfig.GROUP_PROTOCOL_CONFIG, GroupProtocol.CONSUMER.name)
+ consumerConsumer = createConsumer(configOverrides = newConsumerConfig)
+ consumerConsumer.subscribe(List(testTopicName).asJava)
+ consumerConsumer.poll(JDuration.ofMillis(1000))
+
+ TestUtils.waitUntilTrue(() => {
+ classicConsumer.poll(JDuration.ofMillis(100))
+ consumerConsumer.poll(JDuration.ofMillis(100))
+ val describeConsumerGroupResult = client.describeConsumerGroups(Seq(testGroupId).asJava).all.get
+ describeConsumerGroupResult.containsKey(testGroupId) &&
+ describeConsumerGroupResult.get(testGroupId).groupState == GroupState.STABLE &&
+ describeConsumerGroupResult.get(testGroupId).members.size == 2
+ }, s"Expected to find 2 members in a stable group $testGroupId")
+
+ val describeConsumerGroupResult = client.describeConsumerGroups(Seq(testGroupId).asJava).all.get
+ val group = describeConsumerGroupResult.get(testGroupId)
+ assertNotNull(group)
+ assertEquals(Optional.of(2), group.groupEpoch)
+ assertEquals(Optional.of(2), group.targetAssignmentEpoch)
+
+ val classicMember = group.members.asScala.find(_.clientId == testClassicClientId)
+ assertTrue(classicMember.isDefined)
+ assertEquals(Optional.of(2), classicMember.get.memberEpoch)
+ assertEquals(Optional.of(false), classicMember.get.upgraded)
+
+ val consumerMember = group.members.asScala.find(_.clientId == testConsumerClientId)
+ assertTrue(consumerMember.isDefined)
+ assertEquals(Optional.of(2), consumerMember.get.memberEpoch)
+ assertEquals(Optional.of(true), consumerMember.get.upgraded)
+ } finally {
+ Utils.closeQuietly(classicConsumer, "classicConsumer")
+ Utils.closeQuietly(consumerConsumer, "consumerConsumer")
+ Utils.closeQuietly(client, "adminClient")
+ }
+ }
+
/**
* Test the consumer group APIs.
*/
@@ -2546,9 +2634,12 @@ class PlaintextAdminIntegrationTest extends BaseAdminIntegrationTest {
}, "Expected to find all groups")
val classicConsumers = client.describeClassicGroups(groupIds.asJavaCollection).all().get()
- assertNotNull(classicConsumers.get(classicGroupId))
- assertEquals(classicGroupId, classicConsumers.get(classicGroupId).groupId())
- assertEquals("consumer", classicConsumers.get(classicGroupId).protocol())
+ val classicConsumer = classicConsumers.get(classicGroupId)
+ assertNotNull(classicConsumer)
+ assertEquals(classicGroupId, classicConsumer.groupId)
+ assertEquals("consumer", classicConsumer.protocol)
+ assertFalse(classicConsumer.members.isEmpty)
+ classicConsumer.members.forEach(member => assertTrue(member.upgraded.isEmpty))
assertNotNull(classicConsumers.get(simpleGroupId))
assertEquals(simpleGroupId, classicConsumers.get(simpleGroupId).groupId())
diff --git a/tools/src/test/java/org/apache/kafka/tools/consumer/group/ConsumerGroupServiceTest.java b/tools/src/test/java/org/apache/kafka/tools/consumer/group/ConsumerGroupServiceTest.java
index dcb8fd054812d..7a134ac0c9610 100644
--- a/tools/src/test/java/org/apache/kafka/tools/consumer/group/ConsumerGroupServiceTest.java
+++ b/tools/src/test/java/org/apache/kafka/tools/consumer/group/ConsumerGroupServiceTest.java
@@ -32,6 +32,7 @@
import org.apache.kafka.clients.consumer.OffsetAndMetadata;
import org.apache.kafka.clients.consumer.RangeAssignor;
import org.apache.kafka.common.GroupState;
+import org.apache.kafka.common.GroupType;
import org.apache.kafka.common.KafkaFuture;
import org.apache.kafka.common.Node;
import org.apache.kafka.common.TopicPartition;
@@ -139,8 +140,12 @@ public void testAdminRequestsForDescribeNegativeOffsets() throws Exception {
true,
Collections.singleton(new MemberDescription("member1", Optional.of("instance1"), "client1", "host1", new MemberAssignment(assignedTopicPartitions))),
RangeAssignor.class.getName(),
+ GroupType.CLASSIC,
GroupState.STABLE,
- new Node(1, "localhost", 9092));
+ new Node(1, "localhost", 9092),
+ Set.of(),
+ Optional.empty(),
+ Optional.empty());
Function, ArgumentMatcher
+ *
+ * Important: This method should not be used within the callback provided to
+ * {@link #send(ProducerRecord, Callback)}. Invoking flush() in this context will cause a deadlock.
+ *