This is an automated email from the ASF dual-hosted git repository.
mjsax 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 359763b2865 KAFKA-20790: Add group.streams.assignors broker config +
loader (#22920)
359763b2865 is described below
commit 359763b2865848ddd1645ec644595adc5fab08fb
Author: gabriellafu <[email protected]>
AuthorDate: Wed Jul 29 23:49:20 2026 -0400
KAFKA-20790: Add group.streams.assignors broker config + loader (#22920)
Part of KIP-1357.
Adds the new broker config, allowing to register custom assignors, plus
corresponding runtime logic to load customer assignors. Also add the
corresponding group config, so each group can select the assignor it
want to use.
Reviewers: Matthias J. Sax <[email protected]>, Sean Quah
<[email protected]>
---
.../clients/admin/StreamsGroupDescription.java | 27 ++-
.../internals/DescribeStreamsGroupsHandler.java | 3 +-
.../message/StreamsGroupDescribeResponse.json | 7 +-
.../kafka/clients/admin/MockAdminClientTest.java | 6 +-
.../kafka/clients/admin/MockAdminClient.java | 3 +-
.../src/main/scala/kafka/server/ConfigHelper.scala | 2 +-
.../scala/unit/kafka/server/KafkaApisTest.scala | 3 +-
.../scala/unit/kafka/server/KafkaConfigTest.scala | 1 +
.../server/StreamsGroupHeartbeatRequestTest.scala | 162 +++++++++++++++++
docs/getting-started/upgrade.md | 1 +
.../developer-guide/streams-rebalance-protocol.md | 12 +-
docs/streams/upgrade-guide.md | 2 +
.../kafka/coordinator/group/GroupConfig.java | 31 ++++
.../coordinator/group/GroupCoordinatorConfig.java | 101 +++++++++++
.../coordinator/group/GroupCoordinatorShard.java | 1 +
.../coordinator/group/GroupMetadataManager.java | 39 +++-
.../coordinator/group/streams/StreamsGroup.java | 9 +-
.../kafka/coordinator/group/GroupConfigTest.java | 70 +++++++-
.../group/GroupCoordinatorConfigTest.java | 197 +++++++++++++++++++++
.../group/GroupMetadataManagerTest.java | 112 +++++++++++-
.../group/streams/StreamsGroupTest.java | 10 +-
.../kafka/server/config/AbstractKafkaConfig.java | 10 +-
.../server/config/AbstractKafkaConfigTest.java | 53 +++++-
.../kafka/tools/streams/StreamsGroupCommand.java | 14 +-
.../tools/streams/DescribeStreamsGroupTest.java | 10 +-
.../tools/streams/StreamsGroupCommandTest.java | 34 +++-
26 files changed, 867 insertions(+), 53 deletions(-)
diff --git
a/clients/src/main/java/org/apache/kafka/clients/admin/StreamsGroupDescription.java
b/clients/src/main/java/org/apache/kafka/clients/admin/StreamsGroupDescription.java
index 4955c588936..6674f69b7b5 100644
---
a/clients/src/main/java/org/apache/kafka/clients/admin/StreamsGroupDescription.java
+++
b/clients/src/main/java/org/apache/kafka/clients/admin/StreamsGroupDescription.java
@@ -47,7 +47,12 @@ public class StreamsGroupDescription {
private final Set<AclOperation> authorizedOperations;
private final Optional<StreamsGroupTopologyDescription>
topologyDescription;
private final StreamsGroupTopologyDescriptionStatus
topologyDescriptionStatus;
+ private final Optional<String> assignorName;
+ /**
+ * @deprecated Since 4.4. Use {@link #StreamsGroupDescription(String, int,
int, int, Collection, Collection, GroupState, Node, Set, Optional,
StreamsGroupTopologyDescriptionStatus, Optional)} instead.
+ */
+ @Deprecated(since = "4.4", forRemoval = true)
public StreamsGroupDescription(
final String groupId,
final int groupEpoch,
@@ -70,7 +75,8 @@ public class StreamsGroupDescription {
coordinator,
authorizedOperations,
Optional.empty(),
- StreamsGroupTopologyDescriptionStatus.NOT_REQUESTED
+ StreamsGroupTopologyDescriptionStatus.NOT_REQUESTED,
+ Optional.empty()
);
}
@@ -85,7 +91,8 @@ public class StreamsGroupDescription {
final Node coordinator,
final Set<AclOperation> authorizedOperations,
final Optional<StreamsGroupTopologyDescription>
topologyDescription,
- final StreamsGroupTopologyDescriptionStatus
topologyDescriptionStatus
+ final StreamsGroupTopologyDescriptionStatus
topologyDescriptionStatus,
+ final Optional<String> assignorName
) {
this.groupId = Objects.requireNonNull(groupId, "groupId must be
non-null");
this.groupEpoch = groupEpoch;
@@ -98,6 +105,7 @@ public class StreamsGroupDescription {
this.authorizedOperations = authorizedOperations;
this.topologyDescription = Objects.requireNonNull(topologyDescription,
"topologyDescription must be non-null");
this.topologyDescriptionStatus =
Objects.requireNonNull(topologyDescriptionStatus, "topologyDescriptionStatus
must be non-null");
+ this.assignorName = Objects.requireNonNull(assignorName, "assignorName
must be non-null");
}
/**
@@ -179,6 +187,14 @@ public class StreamsGroupDescription {
return topologyDescriptionStatus;
}
+ /**
+ * The task assignor the coordinator will use for the next assignment
computation. May differ from the assignor
+ * that computed the current assignment. Empty if the broker is too old to
report it.
+ */
+ public Optional<String> assignorName() {
+ return assignorName;
+ }
+
@Override
public boolean equals(final Object o) {
if (this == o) {
@@ -198,7 +214,8 @@ public class StreamsGroupDescription {
&& Objects.equals(coordinator, that.coordinator)
&& Objects.equals(authorizedOperations, that.authorizedOperations)
&& Objects.equals(topologyDescription, that.topologyDescription)
- && topologyDescriptionStatus == that.topologyDescriptionStatus;
+ && topologyDescriptionStatus == that.topologyDescriptionStatus
+ && Objects.equals(assignorName, that.assignorName);
}
@Override
@@ -214,7 +231,8 @@ public class StreamsGroupDescription {
coordinator,
authorizedOperations,
topologyDescription,
- topologyDescriptionStatus
+ topologyDescriptionStatus,
+ assignorName
);
}
@@ -232,6 +250,7 @@ public class StreamsGroupDescription {
", authorizedOperations=" +
authorizedOperations.stream().map(AclOperation::toString).collect(Collectors.joining(","))
+
", topologyDescription=" +
topologyDescription.map(Object::toString).orElse("") +
", topologyDescriptionStatus=" + topologyDescriptionStatus +
+ ", assignorName=" + assignorName.orElse("") +
')';
}
}
diff --git
a/clients/src/main/java/org/apache/kafka/clients/admin/internals/DescribeStreamsGroupsHandler.java
b/clients/src/main/java/org/apache/kafka/clients/admin/internals/DescribeStreamsGroupsHandler.java
index 11d734e5e39..af664afd88c 100644
---
a/clients/src/main/java/org/apache/kafka/clients/admin/internals/DescribeStreamsGroupsHandler.java
+++
b/clients/src/main/java/org/apache/kafka/clients/admin/internals/DescribeStreamsGroupsHandler.java
@@ -164,7 +164,8 @@ public class DescribeStreamsGroupsHandler extends
AdminApiHandler.Batched<Coordi
coordinator,
authorizedOperations,
topologyDescription,
- topologyDescriptionStatus
+ topologyDescriptionStatus,
+ Optional.ofNullable(describedGroup.assignorName())
);
completed.put(groupIdKey, streamsGroupDescription);
}
diff --git
a/clients/src/main/resources/common/message/StreamsGroupDescribeResponse.json
b/clients/src/main/resources/common/message/StreamsGroupDescribeResponse.json
index 8b6eb165e18..acc35647619 100644
---
a/clients/src/main/resources/common/message/StreamsGroupDescribeResponse.json
+++
b/clients/src/main/resources/common/message/StreamsGroupDescribeResponse.json
@@ -17,7 +17,8 @@
"apiKey": 89,
"type": "response",
"name": "StreamsGroupDescribeResponse",
- // Version 1 adds TopologyDescription and TopologyDescriptionStatus
(KIP-1331).
+ // Version 1 adds TopologyDescription and TopologyDescriptionStatus
(KIP-1331),
+ // and AssignorName (KIP-1357).
"validVersions": "0-1",
"flexibleVersions": "0+",
// Supported errors:
@@ -112,7 +113,9 @@
"nullableVersions": "1+", "default": "null",
"about": "The full topology description for this group. Non-null if
and only if TopologyDescriptionStatus is AVAILABLE (3); null otherwise." },
{ "name": "TopologyDescriptionStatus", "type": "int8", "versions":
"1+", "default": "0",
- "about": "The status of the topology description for this group,
paired with TopologyDescription: 0=NOT_REQUESTED (client did not set
IncludeTopologyDescription; TopologyDescription is null); 1=NOT_STORED (no
description recorded for this group; TopologyDescription is null); 2=ERROR
(broker failed to fetch the description, see broker logs; TopologyDescription
is null); 3=AVAILABLE (TopologyDescription is non-null and carries the
description)." }
+ "about": "The status of the topology description for this group,
paired with TopologyDescription: 0=NOT_REQUESTED (client did not set
IncludeTopologyDescription; TopologyDescription is null); 1=NOT_STORED (no
description recorded for this group; TopologyDescription is null); 2=ERROR
(broker failed to fetch the description, see broker logs; TopologyDescription
is null); 3=AVAILABLE (TopologyDescription is non-null and carries the
description)." },
+ { "name": "AssignorName", "type": "string", "versions": "1+",
"nullableVersions": "1+", "default": "null", "ignorable": true,
+ "about": "The task assignor that the group coordinator will use for
the next assignment computation. This may differ from the assignor that
computed the current assignment, because changing the assignor configuration
does not trigger a rebalance. Null in case of a describe error." }
]
}
],
diff --git
a/clients/src/test/java/org/apache/kafka/clients/admin/MockAdminClientTest.java
b/clients/src/test/java/org/apache/kafka/clients/admin/MockAdminClientTest.java
index a9df351c87f..3409e79a8a5 100644
---
a/clients/src/test/java/org/apache/kafka/clients/admin/MockAdminClientTest.java
+++
b/clients/src/test/java/org/apache/kafka/clients/admin/MockAdminClientTest.java
@@ -111,7 +111,8 @@ public class MockAdminClientTest {
new Node(0, "host", 0),
Set.of(),
Optional.empty(),
- StreamsGroupTopologyDescriptionStatus.NOT_REQUESTED);
+ StreamsGroupTopologyDescriptionStatus.NOT_REQUESTED,
+ Optional.empty());
}
private StreamsGroupDescription
newStreamsGroupDescriptionWithTopology(String groupId) {
@@ -127,6 +128,7 @@ public class MockAdminClientTest {
new Node(0, "host", 0),
Set.of(),
Optional.of(topology),
- StreamsGroupTopologyDescriptionStatus.AVAILABLE);
+ StreamsGroupTopologyDescriptionStatus.AVAILABLE,
+ Optional.empty());
}
}
diff --git
a/clients/src/testFixtures/java/org/apache/kafka/clients/admin/MockAdminClient.java
b/clients/src/testFixtures/java/org/apache/kafka/clients/admin/MockAdminClient.java
index f2f6e4a769b..cc2a37b7aa9 100644
---
a/clients/src/testFixtures/java/org/apache/kafka/clients/admin/MockAdminClient.java
+++
b/clients/src/testFixtures/java/org/apache/kafka/clients/admin/MockAdminClient.java
@@ -1510,7 +1510,8 @@ public class MockAdminClient extends AdminClient {
description.coordinator(),
description.authorizedOperations(),
Optional.empty(),
- StreamsGroupTopologyDescriptionStatus.NOT_REQUESTED
+ StreamsGroupTopologyDescriptionStatus.NOT_REQUESTED,
+ description.assignorName()
);
}
diff --git a/core/src/main/scala/kafka/server/ConfigHelper.scala
b/core/src/main/scala/kafka/server/ConfigHelper.scala
index 7e4de8eb6fc..a1223218514 100644
--- a/core/src/main/scala/kafka/server/ConfigHelper.scala
+++ b/core/src/main/scala/kafka/server/ConfigHelper.scala
@@ -133,7 +133,7 @@ class ConfigHelper(metadataCache: MetadataCache, config:
KafkaConfig, configRepo
throw new InvalidRequestException("Group name must not be empty")
} else {
val groupProps = configRepository.groupConfig(group)
- val groupConfig =
GroupConfig.fromProps(config.extractGroupConfigMap, groupProps)
+ val groupConfig =
GroupConfig.fromProps(config.extractGroupConfigMap(config.groupCoordinatorConfig),
groupProps)
createResponseConfig(resource, groupConfig,
createGroupConfigEntry(groupConfig, groupProps, includeSynonyms,
includeDocumentation)(_, _))
}
diff --git a/core/src/test/scala/unit/kafka/server/KafkaApisTest.scala
b/core/src/test/scala/unit/kafka/server/KafkaApisTest.scala
index 1bed860dfdd..db7dbd8cab6 100644
--- a/core/src/test/scala/unit/kafka/server/KafkaApisTest.scala
+++ b/core/src/test/scala/unit/kafka/server/KafkaApisTest.scala
@@ -80,7 +80,7 @@ import org.apache.kafka.common.utils.Utils
import org.apache.kafka.common.utils.internals.ImplicitLinkedHashCollection
import org.apache.kafka.common.utils.internals.ProducerIdAndEpoch
import org.apache.kafka.common.utils.internals.SecurityUtils
-import
org.apache.kafka.coordinator.group.GroupConfig.{CONSUMER_ASSIGNMENT_INTERVAL_MS_CONFIG,
CONSUMER_ASSIGNOR_OFFLOAD_ENABLE_CONFIG,
CONSUMER_HEARTBEAT_INTERVAL_MS_CONFIG, CONSUMER_SESSION_TIMEOUT_MS_CONFIG,
SHARE_ASSIGNMENT_INTERVAL_MS_CONFIG, SHARE_ASSIGNOR_OFFLOAD_ENABLE_CONFIG,
SHARE_AUTO_OFFSET_RESET_CONFIG, SHARE_DELIVERY_COUNT_LIMIT_CONFIG,
SHARE_HEARTBEAT_INTERVAL_MS_CONFIG, SHARE_ISOLATION_LEVEL_CONFIG,
SHARE_PARTITION_MAX_RECORD_LOCKS_CONFIG, SHARE_RECORD_LOCK_DURATION_MS_CO [...]
+import
org.apache.kafka.coordinator.group.GroupConfig.{CONSUMER_ASSIGNMENT_INTERVAL_MS_CONFIG,
CONSUMER_ASSIGNOR_OFFLOAD_ENABLE_CONFIG,
CONSUMER_HEARTBEAT_INTERVAL_MS_CONFIG, CONSUMER_SESSION_TIMEOUT_MS_CONFIG,
SHARE_ASSIGNMENT_INTERVAL_MS_CONFIG, SHARE_ASSIGNOR_OFFLOAD_ENABLE_CONFIG,
SHARE_AUTO_OFFSET_RESET_CONFIG, SHARE_DELIVERY_COUNT_LIMIT_CONFIG,
SHARE_HEARTBEAT_INTERVAL_MS_CONFIG, SHARE_ISOLATION_LEVEL_CONFIG,
SHARE_PARTITION_MAX_RECORD_LOCKS_CONFIG, SHARE_RECORD_LOCK_DURATION_MS_CO [...]
import org.apache.kafka.coordinator.group.modern.share.ShareGroupConfig
import org.apache.kafka.coordinator.group.{GroupConfig, GroupConfigManager,
GroupCoordinator, GroupCoordinatorConfig}
import org.apache.kafka.coordinator.group.streams.StreamsGroupHeartbeatResult
@@ -385,6 +385,7 @@ class KafkaApisTest extends Logging {
cgConfigs.put(STREAMS_NUM_WARMUP_REPLICAS_CONFIG,
GroupCoordinatorConfig.STREAMS_GROUP_NUM_WARMUP_REPLICAS_DEFAULT.toString)
cgConfigs.put(STREAMS_RACK_AWARE_ASSIGNMENT_TAGS_CONFIG,
GroupCoordinatorConfig.STREAMS_GROUP_RACK_AWARE_ASSIGNMENT_TAGS_DEFAULT)
cgConfigs.put(STREAMS_ACCEPTABLE_RECOVERY_LAG_CONFIG,
GroupCoordinatorConfig.STREAMS_GROUP_ACCEPTABLE_RECOVERY_LAG_DEFAULT.toString)
+ cgConfigs.put(STREAMS_ASSIGNOR_NAME_CONFIG, "sticky")
cgConfigs.put(ERRORS_DEADLETTERQUEUE_TOPIC_NAME_CONFIG, "")
cgConfigs.put(ERRORS_DEADLETTERQUEUE_COPY_RECORD_ENABLE_CONFIG, "false")
when(configRepository.groupConfig(consumerGroupId)).thenReturn(cgConfigs)
diff --git a/core/src/test/scala/unit/kafka/server/KafkaConfigTest.scala
b/core/src/test/scala/unit/kafka/server/KafkaConfigTest.scala
index 5cdb38c79af..34f48635581 100755
--- a/core/src/test/scala/unit/kafka/server/KafkaConfigTest.scala
+++ b/core/src/test/scala/unit/kafka/server/KafkaConfigTest.scala
@@ -1112,6 +1112,7 @@ class KafkaConfigTest {
case
GroupCoordinatorConfig.STREAMS_GROUP_MIN_TASK_OFFSET_INTERVAL_MS_CONFIG =>
assertPropertyInvalid(baseProperties, name, "not_a_number", -1)
case
GroupCoordinatorConfig.STREAMS_GROUP_RACK_AWARE_ASSIGNMENT_TAGS_CONFIG => //
ignore list
case
GroupCoordinatorConfig.STREAMS_GROUP_ACCEPTABLE_RECOVERY_LAG_CONFIG =>
assertPropertyInvalid(baseProperties, name, "not_a_number", -1)
+ case GroupCoordinatorConfig.STREAMS_GROUP_ASSIGNORS_CONFIG => //
ignore list
/** Share coordinator configs */
case ShareCoordinatorConfig.APPEND_LINGER_MS_CONFIG =>
assertPropertyInvalid(baseProperties, name, "not_a_number", -2, -0.5)
diff --git
a/core/src/test/scala/unit/kafka/server/StreamsGroupHeartbeatRequestTest.scala
b/core/src/test/scala/unit/kafka/server/StreamsGroupHeartbeatRequestTest.scala
index 281abf59e88..23240726a9d 100644
---
a/core/src/test/scala/unit/kafka/server/StreamsGroupHeartbeatRequestTest.scala
+++
b/core/src/test/scala/unit/kafka/server/StreamsGroupHeartbeatRequestTest.scala
@@ -24,6 +24,7 @@ import org.apache.kafka.common.protocol.Errors
import org.apache.kafka.common.test.ClusterInstance
import org.apache.kafka.common.test.api.{ClusterConfigProperty,
ClusterFeature, ClusterTest, ClusterTestDefaults, Type}
import org.apache.kafka.coordinator.group.{GroupConfig, GroupCoordinatorConfig}
+import
org.apache.kafka.coordinator.group.api.streams.assignor.{GroupAssignment,
GroupSpec, MemberAssignment, TaskAssignor, TopologyDescriber}
import org.apache.kafka.common.errors.{InvalidConfigurationException,
UnsupportedVersionException}
import org.apache.kafka.server.common.Feature
import org.junit.jupiter.api.Assertions.{assertEquals, assertNotNull,
assertNull, assertThrows, assertTrue}
@@ -926,6 +927,153 @@ class StreamsGroupHeartbeatRequestTest(cluster:
ClusterInstance) extends GroupCo
}
}
+ @ClusterTest
+ def testAlterStreamsAssignorNameGroupConfig(): Unit = {
+ val admin = cluster.admin()
+ val groupId = "test-group"
+
+ try {
+ TestUtils.createOffsetsTopicWithAdmin(
+ admin = admin,
+ brokers = cluster.brokers.values().asScala.toSeq,
+ controllers = cluster.controllers().values().asScala.toSeq
+ )
+
+ val groupConfigResource = new ConfigResource(ConfigResource.Type.GROUP,
groupId)
+
+ // The config is unset by default, so it falls back to the broker's
default assignor, which is
+ // the name of the first assignor of group.streams.assignors.
+ assertEquals("sticky",
admin.describeConfigs(List(groupConfigResource).asJava).all().get()
+
.get(groupConfigResource).get(GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG).value())
+
+ // A name that is not registered on the broker is rejected with
INVALID_CONFIG.
+ val invalidAlterOp = new AlterConfigOp(
+ new ConfigEntry(GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG,
"does-not-exist"),
+ AlterConfigOp.OpType.SET
+ )
+ val executionException = assertThrows(classOf[ExecutionException], () =>
+ admin.incrementalAlterConfigs(
+ Map(groupConfigResource ->
List(invalidAlterOp).asJavaCollection).asJava
+ ).all().get()
+ )
+
assertTrue(executionException.getCause.isInstanceOf[InvalidConfigurationException],
+ s"Expected InvalidConfigurationException but got
${executionException.getCause}")
+
assertTrue(executionException.getCause.getMessage.contains("'does-not-exist' is
not a registered task assignor"),
+ s"Unexpected error message: ${executionException.getCause.getMessage}")
+ } finally {
+ admin.close()
+ }
+ }
+
+ @ClusterTest(
+ serverProperties = Array(
+ // The class name has to be spelled out because annotation values must
be compile-time constants.
+ new ClusterConfigProperty(
+ key = GroupCoordinatorConfig.STREAMS_GROUP_ASSIGNORS_CONFIG,
+ value = "sticky,kafka.server.CustomStreamsTaskAssignor"
+ )
+ )
+ )
+ def testAlterStreamsAssignorNameGroupConfigWithCustomAssignor(): Unit = {
+ val admin = cluster.admin()
+ val groupId = "test-group"
+
+ try {
+ TestUtils.createOffsetsTopicWithAdmin(
+ admin = admin,
+ brokers = cluster.brokers.values().asScala.toSeq,
+ controllers = cluster.controllers().values().asScala.toSeq
+ )
+
+ val groupConfigResource = new ConfigResource(ConfigResource.Type.GROUP,
groupId)
+
+ // The config is unset, so it falls back to the broker's default
assignor, which is the first
+ // entry of group.streams.assignors.
+ assertEquals("sticky",
admin.describeConfigs(List(groupConfigResource).asJava).all().get()
+
.get(groupConfigResource).get(GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG).value())
+
+ // A custom assignor registered on the broker is selected by the name it
reports, moving the group
+ // off the default. Group config propagation is asynchronous, so wait
for it.
+ val validAlterOp = new AlterConfigOp(
+ new ConfigEntry(GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG,
CustomStreamsTaskAssignor.NAME),
+ AlterConfigOp.OpType.SET
+ )
+ admin.incrementalAlterConfigs(
+ Map(groupConfigResource -> List(validAlterOp).asJavaCollection).asJava
+ ).all().get()
+
+ TestUtils.waitUntilTrue(() => {
+ val describedConfigs =
admin.describeConfigs(List(groupConfigResource).asJava).all().get()
+ val assignorName =
describedConfigs.get(groupConfigResource).get(GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG)
+ assignorName != null && assignorName.value() ==
CustomStreamsTaskAssignor.NAME
+ }, s"${GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG} was not updated to the
expected value within the timeout period.")
+
+ // The custom assignor's class name is not a valid selector; only its
name() is.
+ val classNameAlterOp = new AlterConfigOp(
+ new ConfigEntry(GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG,
classOf[CustomStreamsTaskAssignor].getName),
+ AlterConfigOp.OpType.SET
+ )
+ val executionException = assertThrows(classOf[ExecutionException], () =>
+ admin.incrementalAlterConfigs(
+ Map(groupConfigResource ->
List(classNameAlterOp).asJavaCollection).asJava
+ ).all().get()
+ )
+
assertTrue(executionException.getCause.isInstanceOf[InvalidConfigurationException],
+ s"Expected InvalidConfigurationException but got
${executionException.getCause}")
+
+ // The alter is rejected at validation time, so the group keeps the
assignor it had selected.
+ assertEquals(CustomStreamsTaskAssignor.NAME,
admin.describeConfigs(List(groupConfigResource).asJava).all().get()
+
.get(groupConfigResource).get(GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG).value())
+
+ // Deleting the config returns the group to the broker's default
assignor.
+ val deleteAlterOp = new AlterConfigOp(
+ new ConfigEntry(GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG, ""),
+ AlterConfigOp.OpType.DELETE
+ )
+ admin.incrementalAlterConfigs(
+ Map(groupConfigResource -> List(deleteAlterOp).asJavaCollection).asJava
+ ).all().get()
+
+ TestUtils.waitUntilTrue(() => {
+ val describedConfigs =
admin.describeConfigs(List(groupConfigResource).asJava).all().get()
+
describedConfigs.get(groupConfigResource).get(GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG).value()
== "sticky"
+ }, s"${GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG} was not unset within
the timeout period.")
+ } finally {
+ admin.close()
+ }
+ }
+
+ @ClusterTest(
+ serverProperties = Array(
+ // The class name has to be spelled out because annotation values must
be compile-time constants.
+ new ClusterConfigProperty(
+ key = GroupCoordinatorConfig.STREAMS_GROUP_ASSIGNORS_CONFIG,
+ value = "kafka.server.CustomStreamsTaskAssignor"
+ )
+ )
+ )
+ def testDescribeStreamsAssignorNameGroupConfigFallsBackToAssignorName():
Unit = {
+ val admin = cluster.admin()
+ val groupId = "test-group"
+
+ try {
+ TestUtils.createOffsetsTopicWithAdmin(
+ admin = admin,
+ brokers = cluster.brokers.values().asScala.toSeq,
+ controllers = cluster.controllers().values().asScala.toSeq
+ )
+
+ val groupConfigResource = new ConfigResource(ConfigResource.Type.GROUP,
groupId)
+
+ // The default assignor is registered by class name, but the group
config selects assignors by
+ // name, so the fallback is the assignor's name and not the configured
class name.
+ assertEquals(CustomStreamsTaskAssignor.NAME,
admin.describeConfigs(List(groupConfigResource).asJava).all().get()
+
.get(groupConfigResource).get(GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG).value())
+ } finally {
+ admin.close()
+ }
+ }
+
@ClusterTest(
types = Array(Type.KRAFT),
serverProperties = Array(
@@ -1258,4 +1406,18 @@ class StreamsGroupHeartbeatRequestTest(cluster:
ClusterInstance) extends GroupCo
).asJava)
).asJava)
}
+}
+
+object CustomStreamsTaskAssignor {
+ val NAME = "custom"
+}
+
+/**
+ * Registered on the broker only so that a group can select it by name; it
never computes an assignment.
+ */
+class CustomStreamsTaskAssignor extends TaskAssignor {
+ override def name(): String = CustomStreamsTaskAssignor.NAME
+
+ override def assign(groupSpec: GroupSpec, topologyDescriber:
TopologyDescriber): GroupAssignment =
+ new GroupAssignment(java.util.Map.of[String, MemberAssignment]())
}
\ No newline at end of file
diff --git a/docs/getting-started/upgrade.md b/docs/getting-started/upgrade.md
index 034d157a68a..857e0397dcb 100644
--- a/docs/getting-started/upgrade.md
+++ b/docs/getting-started/upgrade.md
@@ -50,6 +50,7 @@ type: docs
* The `kafka-producer-perf-test.sh` tool now supports `--record-key-range`,
`--key-distribution`, and `--random-seed` options to control the distribution
of record keys. Use `--key-distribution range` for sequential key assignment
(round-robin over the key range) or `--key-distribution random` for random key
selection. The `--random-seed` option allows reproducible benchmark runs when
using random key distribution. For further details, please refer to
[KIP-1299](https://cwiki.apache.or [...]
* Share groups now support dead-letter queue functionality as outlined in
[KIP-1191](https://cwiki.apache.org/confluence/x/fApJFg). Any records which are
released (beyond max delivery count) or rejected by the share consumer become
eligible for DLQ. Share group DLQ gets enabled when the Kafka feature
`share.version` is upgraded to 2. The user can configure a DLQ topic on a share
group by setting the dynamic config `errors.deadletterqueue.topic.name`
(default `""`) to the name of the DL [...]
* Kafka Connect distributed workers now support the
`internal.topics.automatic.creation.enable` configuration (default: `true`).
When set to `false`, Connect will not automatically create internal topics
(offset, config, status, and connector-specific offset topics) and will instead
fail at startup if any of these topics are missing. A new
`connect-internal-topics.sh` tool is also available for manually creating these
topics. For further details, please refer to [KIP-1209](https://cwik [...]
+ * Streams groups now support broker-side custom task assignors, registered
via the new broker configuration `group.streams.assignors` and selected per
group with the new group configuration `streams.assignor.name`. For further
details, please refer to
[KIP-1357](https://cwiki.apache.org/confluence/x/NoSnGQ).
## Upgrading to 4.3.0
diff --git a/docs/streams/developer-guide/streams-rebalance-protocol.md
b/docs/streams/developer-guide/streams-rebalance-protocol.md
index 5f9cc280d58..fb1b07d2fb1 100644
--- a/docs/streams/developer-guide/streams-rebalance-protocol.md
+++ b/docs/streams/developer-guide/streams-rebalance-protocol.md
@@ -39,7 +39,9 @@ The following features are available in the current release:
* **Core Streams Group Rebalance Protocol**: The `group.protocol=streams`
configuration enables the dedicated streams rebalance protocol. This separates
streams groups from consumer groups and provides a streams-specific group
membership lifecycle and metadata management on the broker.
-* **Sticky Task Assignor**: A basic task assignment strategy that minimizes
task movement during rebalances is included.
+* **Sticky Task Assignor**: A basic task assignment strategy that minimizes
task movement during rebalances is included. It is registered under the name
`sticky` and is the default assignor.
+
+* **Custom Task Assignors**: Brokers can be configured with custom task
assignors via `group.streams.assignors`, which takes a list of built-in
assignor names and fully qualified class names of custom `TaskAssignor`
implementations. The first entry is the default assignor. An individual group
selects one of the registered assignors by name with the group configuration
`streams.assignor.name`; when unset, the group uses the first entry of
`group.streams.assignors`. Custom implementations [...]
* **Interactive Query Support**: IQ operations are compatible with the new
streams protocol.
@@ -59,7 +61,9 @@ The following features are not yet available and should be
avoided when using th
* **Topology Updates**: If a topology is changed significantly (e.g., by
adding new source topics or changing the number of subtopologies), a new
streams group must be created.
-* **High Availability Assignor**: Only the sticky assignor is supported. This
implies that "warmup tasks" and rack aware assignment are not supported yet.
+* **High Availability Assignor**: The sticky assignor is the only built-in
assignor and it does not support rack aware assignment, but a custom assignor
registered via `group.streams.assignors` can implement it.
+
+* **Warmup Tasks**: In contrast to the "classic" rebalance protocol, warmup
tasks are not an assignor feature, but the group coordinator would inject
warmup tasks into an assignment. The benefit is, that warmup tasks can be used
independent of the configured assignor. However, warmup task support is not
implemented yet.
* **Regular Expressions**: Pattern-based topic subscription is not supported.
@@ -120,6 +124,7 @@ The following broker configurations control the behavior of
streams groups. For
*
[`group.streams.max.standby.replicas`](/{version}/configuration/broker-configs#brokerconfigs_group.streams.max.standby.replicas):
Maximum for dynamic configurations of the standby replica configuration.
*
[`group.streams.initial.rebalance.delay.ms`](/{version}/configuration/broker-configs#brokerconfigs_group.streams.initial.rebalance.delay.ms):
The first rebalance of a new (ie, previously empty) group is delayed by this
amount to allow more members to join the group.
*
[`group.streams.topology.description.plugin.class`](/{version}/configuration/broker-configs#brokerconfigs_group.streams.topology.description.plugin.class):
The fully qualified class name of a `StreamsGroupTopologyDescriptionPlugin`
implementation. When not set, the [topology description
feature](/{version}/streams/developer-guide/topology-description-plugin/) is
disabled.
+*
[`group.streams.assignors`](/{version}/configuration/broker-configs#brokerconfigs_group.streams.assignors):
The task assignors available to streams groups, as a list of built-in assignor
names and fully qualified class names of custom `TaskAssignor` implementations.
The first entry is the default assignor for groups that do not select one with
`streams.assignor.name`.
## Group Configuration
@@ -133,6 +138,7 @@ The following group-level configurations are available for
streams groups:
*
[`streams.heartbeat.interval.ms`](/{version}/configuration/group-configs#groupconfigs_streams.heartbeat.interval.ms):
The heartbeat interval given to the members.
*
[`streams.num.standby.replicas`](/{version}/configuration/group-configs#groupconfigs_streams.num.standby.replicas):
The number of standby replicas for each task.
*
[`streams.initial.rebalance.delay.ms`](/{version}/configuration/group-configs#groupconfigs_streams.initial.rebalance.delay.ms):
The first rebalance of a group is delayed by this amount to allow more members
to join the group.
+*
[`streams.assignor.name`](/{version}/configuration/group-configs#groupconfigs_streams.assignor.name):
The name of the task assignor to use for this group, which must be one of the
assignors registered on the broker via `group.streams.assignors`. When unset,
the group uses the first entry of `group.streams.assignors`.
### Example: Setting Group-Level Configuration
```
@@ -162,7 +168,7 @@ The following configurations are ignored when the streams
rebalance protocol is
*
[`rack.aware.assignment.strategy`](/{version}/configuration/kafka-streams-configs#streamsconfigs_rack.aware.assignment.strategy)
*
[`rack.aware.assignment.traffic_cost`](/{version}/configuration/kafka-streams-configs#streamsconfigs_rack.aware.assignment.traffic_cost)
*
[`rack.aware.assignment.non_overlap_cost`](/{version}/configuration/kafka-streams-configs#streamsconfigs_rack.aware.assignment.non_overlap_cost)
-*
[`task.assignor.class`](/{version}/configuration/kafka-streams-configs#streamsconfigs_task.assignor.class)
+*
[`task.assignor.class`](/{version}/configuration/kafka-streams-configs#streamsconfigs_task.assignor.class)
(assignment happens on the broker; use `group.streams.assignors` and
`streams.assignor.name` instead)
*
[`session.timeout.ms`](/{version}/configuration/kafka-streams-configs#streamsconfigs_session.timeout.ms)
(use group-level configuration instead)
*
[`heartbeat.interval.ms`](/{version}/configuration/kafka-streams-configs#streamsconfigs_heartbeat.interval.ms)
(use group-level configuration instead)
diff --git a/docs/streams/upgrade-guide.md b/docs/streams/upgrade-guide.md
index 1e1fd54aa21..fa6222fc55d 100644
--- a/docs/streams/upgrade-guide.md
+++ b/docs/streams/upgrade-guide.md
@@ -75,6 +75,8 @@ Kafka Streams now exposes the mapped join key alongside the
`KStream` record key
For applications using the Streams Rebalance Protocol
(`group.protocol=streams`), brokers can now record a human-readable description
of the group's processing topology via a pluggable backend
([KIP-1331](https://cwiki.apache.org/confluence/display/KAFKA/KIP-1331%3A+Streams+Group+Topology+Description+Plugin)).
When the broker configuration
`group.streams.topology.description.plugin.class` is set, Kafka Streams clients
automatically push a description equivalent to `Topology#describe()` t [...]
+For applications using the Streams Rebalance Protocol
(`group.protocol=streams`), the broker-side task assignor is now pluggable
([KIP-1357](https://cwiki.apache.org/confluence/x/NoSnGQ)). Operators register
assignors with the new broker configuration `group.streams.assignors`, which
accepts a list of built-in assignor names and fully qualified class names of
custom `TaskAssignor` implementations; the first entry is the default for
groups that do not select one. An individual group picks [...]
+
## Streams API changes in 4.3.0
**Note:** Kafka Streams 4.3.0 contains a critical native memory leak in the
RocksDB state store layer
([KAFKA-20616](https://issues.apache.org/jira/browse/KAFKA-20616)). The
`ColumnFamilyOptions` for the offsets column family is not closed, and column
family handles can leak on close-path exceptions, which under cascading task
closes (e.g., rebalances or error-triggered recoveries) leads to unbounded
off-heap memory growth and eventual OOM. Users running Kafka Streams should
consider upg [...]
diff --git
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupConfig.java
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupConfig.java
index 24b547a633b..f9e678f2005 100644
---
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupConfig.java
+++
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupConfig.java
@@ -115,6 +115,11 @@ public final class GroupConfig extends AbstractConfig {
public static final String STREAMS_ACCEPTABLE_RECOVERY_LAG_CONFIG =
"streams.acceptable.recovery.lag";
+ public static final String STREAMS_ASSIGNOR_NAME_CONFIG =
"streams.assignor.name";
+ public static final String STREAMS_ASSIGNOR_NAME_DOC = "The task assignor
to use for this streams group, selected by short name from the assignors
registered via the broker configuration " +
+ GroupCoordinatorConfig.STREAMS_GROUP_ASSIGNORS_CONFIG + ". When unset,
the group defaults to the first assignor configured in the broker's " +
+ GroupCoordinatorConfig.STREAMS_GROUP_ASSIGNORS_CONFIG + " setting.
Changing the assignor does not trigger a rebalance for the group; the new
assignor takes effect on the next rebalance.";
+
public static final String ERRORS_DEADLETTERQUEUE_TOPIC_NAME_CONFIG =
"errors.deadletterqueue.topic.name";
public static final String ERRORS_DEADLETTERQUEUE_TOPIC_NAME_DEFAULT = "";
public static final String ERRORS_DEADLETTERQUEUE_TOPIC_NAME_DOC = "The
name of the topic to be used as the dead-letter queue (DLQ) topic for this
share group. If blank (the default), the group does not have a DLQ topic.";
@@ -167,6 +172,8 @@ public final class GroupConfig extends AbstractConfig {
private final Optional<Long> streamsAcceptableRecoveryLag;
+ private final Optional<String> streamsAssignorName;
+
private final Optional<IsolationLevel> shareIsolationLevel;
private final Optional<Boolean> shareRenewAcknowledgeEnable;
@@ -316,6 +323,11 @@ public final class GroupConfig extends AbstractConfig {
atLeast(0),
MEDIUM,
GroupCoordinatorConfig.STREAMS_GROUP_ACCEPTABLE_RECOVERY_LAG_DOC)
+ .define(STREAMS_ASSIGNOR_NAME_CONFIG,
+ STRING,
+ null,
+ MEDIUM,
+ STREAMS_ASSIGNOR_NAME_DOC)
// DLQ configurations (KIP-1191)
.define(ERRORS_DEADLETTERQUEUE_TOPIC_NAME_CONFIG,
@@ -363,6 +375,7 @@ public final class GroupConfig extends AbstractConfig {
Map.entry(STREAMS_NUM_WARMUP_REPLICAS_CONFIG,
Optional.of(GroupCoordinatorConfig.STREAMS_GROUP_NUM_WARMUP_REPLICAS_CONFIG)),
Map.entry(STREAMS_RACK_AWARE_ASSIGNMENT_TAGS_CONFIG,
Optional.of(GroupCoordinatorConfig.STREAMS_GROUP_RACK_AWARE_ASSIGNMENT_TAGS_CONFIG)),
Map.entry(STREAMS_ACCEPTABLE_RECOVERY_LAG_CONFIG,
Optional.of(GroupCoordinatorConfig.STREAMS_GROUP_ACCEPTABLE_RECOVERY_LAG_CONFIG)),
+ Map.entry(STREAMS_ASSIGNOR_NAME_CONFIG,
Optional.of(GroupCoordinatorConfig.STREAMS_GROUP_ASSIGNORS_CONFIG)),
// DLQ configs
Map.entry(ERRORS_DEADLETTERQUEUE_TOPIC_NAME_CONFIG, Optional.empty()),
@@ -414,6 +427,7 @@ public final class GroupConfig extends AbstractConfig {
this.streamsTaskOffsetIntervalMs =
optionalInt(STREAMS_TASK_OFFSET_INTERVAL_MS_CONFIG);
this.streamsNumWarmupReplicas =
optionalInt(STREAMS_NUM_WARMUP_REPLICAS_CONFIG);
this.streamsAcceptableRecoveryLag =
optionalLong(STREAMS_ACCEPTABLE_RECOVERY_LAG_CONFIG);
+ this.streamsAssignorName =
optionalString(STREAMS_ASSIGNOR_NAME_CONFIG);
this.shareIsolationLevel = optionalString(SHARE_ISOLATION_LEVEL_CONFIG)
.map(s -> IsolationLevel.valueOf(s.toUpperCase(Locale.ROOT)));
this.shareRenewAcknowledgeEnable =
optionalBoolean(SHARE_RENEW_ACKNOWLEDGE_ENABLE_CONFIG);
@@ -614,6 +628,16 @@ public final class GroupConfig extends AbstractConfig {
groupCoordinatorConfig.streamsGroupMaxWarmupReplicas()
);
+ // The selected streams assignor must be one of the assignors
registered on the broker.
+ if (parsed.containsKey(STREAMS_ASSIGNOR_NAME_CONFIG)) {
+ String assignorName = (String)
parsed.get(STREAMS_ASSIGNOR_NAME_CONFIG);
+ List<String> registeredAssignors =
groupCoordinatorConfig.streamsGroupAssignorNames();
+ if (!registeredAssignors.contains(assignorName)) {
+ throw new
InvalidConfigurationException(STREAMS_ASSIGNOR_NAME_CONFIG + " '" +
assignorName +
+ "' is not a registered task assignor. Registered assignors
are: " + registeredAssignors + ".");
+ }
+ }
+
// Cross-field validations: session timeout must be greater than
heartbeat interval.
validateSessionExceedsHeartbeat(
parsed,
@@ -1214,6 +1238,13 @@ public final class GroupConfig extends AbstractConfig {
return streamsAcceptableRecoveryLag;
}
+ /**
+ * The task assignor selected for streams groups.
+ */
+ public Optional<String> streamsAssignorName() {
+ return streamsAssignorName;
+ }
+
/**
* The share group isolation level.
*/
diff --git
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorConfig.java
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorConfig.java
index 012ca319878..f88c63d9bea 100644
---
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorConfig.java
+++
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorConfig.java
@@ -27,9 +27,11 @@ import org.apache.kafka.common.utils.Utils;
import
org.apache.kafka.coordinator.group.api.assignor.ConsumerGroupPartitionAssignor;
import
org.apache.kafka.coordinator.group.api.assignor.ShareGroupPartitionAssignor;
import
org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescriptionPlugin;
+import org.apache.kafka.coordinator.group.api.streams.assignor.TaskAssignor;
import org.apache.kafka.coordinator.group.assignor.RangeAssignor;
import org.apache.kafka.coordinator.group.assignor.SimpleAssignor;
import org.apache.kafka.coordinator.group.assignor.UniformAssignor;
+import org.apache.kafka.coordinator.group.streams.assignor.StickyTaskAssignor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -388,6 +390,21 @@ public class GroupCoordinatorConfig {
public static final String STREAMS_GROUP_MAX_ASSIGNMENT_INTERVAL_MS_DOC =
"The maximum interval between assignment updates for a streams group.";
public static final int STREAMS_GROUP_MAX_ASSIGNMENT_INTERVAL_MS_DEFAULT =
15000;
+ // The first entry is the default assignor for groups that do not select
one. New built-in
+ // assignors must be appended so that the default does not change for
existing groups.
+ private static final List<TaskAssignor> STREAMS_GROUP_BUILTIN_ASSIGNORS =
List.of(
+ new StickyTaskAssignor()
+ );
+ public static final String STREAMS_GROUP_ASSIGNORS_CONFIG =
"group.streams.assignors";
+ public static final String STREAMS_GROUP_ASSIGNORS_DOC = "The server side
task assignors for streams groups as a list of either names for built-in
assignors or fully qualified class names for custom assignors. " +
+ "The first one in the list is considered as the default assignor to be
used in the case where the streams group does not specify an assignor. " +
+ "Changing the default assignor does not trigger a rebalance for
existing groups; the new default takes effect on the next rebalance. " +
+ "The supported built-in assignors are: " +
STREAMS_GROUP_BUILTIN_ASSIGNORS.stream().map(TaskAssignor::name).collect(Collectors.joining(",
")) + ".";
+ public static final List<String> STREAMS_GROUP_ASSIGNORS_DEFAULT =
STREAMS_GROUP_BUILTIN_ASSIGNORS
+ .stream()
+ .map(TaskAssignor::name)
+ .toList();
+
public static final String STREAMS_GROUP_RACK_AWARE_ASSIGNMENT_TAGS_CONFIG
= "group.streams.rack.aware.assignment.tags";
public static final String
STREAMS_GROUP_RACK_AWARE_ASSIGNMENT_TAGS_DEFAULT = "";
public static final String STREAMS_GROUP_RACK_AWARE_ASSIGNMENT_TAGS_DOC =
"List of client tag keys used to distribute standby replicas across Kafka
Streams instances. When configured, and the used broker-side assignor supports
it, it will make a best-effort to distribute standby tasks over each client tag
dimension.";
@@ -510,6 +527,7 @@ public class GroupCoordinatorConfig {
.define(STREAMS_GROUP_ASSIGNMENT_INTERVAL_MS_CONFIG, INT,
STREAMS_GROUP_ASSIGNMENT_INTERVAL_MS_DEFAULT, atLeast(0), MEDIUM,
STREAMS_GROUP_ASSIGNMENT_INTERVAL_MS_DOC)
.define(STREAMS_GROUP_MIN_ASSIGNMENT_INTERVAL_MS_CONFIG, INT,
STREAMS_GROUP_MIN_ASSIGNMENT_INTERVAL_MS_DEFAULT, atLeast(0), MEDIUM,
STREAMS_GROUP_MIN_ASSIGNMENT_INTERVAL_MS_DOC)
.define(STREAMS_GROUP_MAX_ASSIGNMENT_INTERVAL_MS_CONFIG, INT,
STREAMS_GROUP_MAX_ASSIGNMENT_INTERVAL_MS_DEFAULT, atLeast(0), MEDIUM,
STREAMS_GROUP_MAX_ASSIGNMENT_INTERVAL_MS_DOC)
+ .define(STREAMS_GROUP_ASSIGNORS_CONFIG, LIST,
STREAMS_GROUP_ASSIGNORS_DEFAULT,
ConfigDef.ValidList.anyNonDuplicateValues(false, false), MEDIUM,
STREAMS_GROUP_ASSIGNORS_DOC)
.define(STREAMS_GROUP_ASSIGNOR_OFFLOAD_ENABLE_CONFIG, BOOLEAN,
STREAMS_GROUP_ASSIGNOR_OFFLOAD_ENABLE_DEFAULT, MEDIUM,
STREAMS_GROUP_ASSIGNOR_OFFLOAD_ENABLE_DOC)
.define(STREAMS_GROUP_TASK_OFFSET_INTERVAL_MS_CONFIG, INT,
STREAMS_GROUP_TASK_OFFSET_INTERVAL_MS_DEFAULT, atLeast(1), MEDIUM,
STREAMS_GROUP_TASK_OFFSET_INTERVAL_MS_DOC)
.define(STREAMS_GROUP_MIN_TASK_OFFSET_INTERVAL_MS_CONFIG, INT,
STREAMS_GROUP_MIN_TASK_OFFSET_INTERVAL_MS_DEFAULT, atLeast(1), MEDIUM,
STREAMS_GROUP_MIN_TASK_OFFSET_INTERVAL_MS_DOC)
@@ -584,6 +602,8 @@ public class GroupCoordinatorConfig {
private final int streamsGroupMaxWarmupReplicas;
private final List<String> streamsGroupRackAwareAssignmentTags;
private final long streamsGroupAcceptableRecoveryLag;
+ private final List<TaskAssignor> streamsGroupAssignors;
+ private final List<String> streamsGroupAssignorNames;
private final AbstractConfig config;
@@ -652,6 +672,8 @@ public class GroupCoordinatorConfig {
this.streamsGroupNumWarmupReplicas =
config.getInt(GroupCoordinatorConfig.STREAMS_GROUP_NUM_WARMUP_REPLICAS_CONFIG);
this.streamsGroupMaxWarmupReplicas =
config.getInt(GroupCoordinatorConfig.STREAMS_GROUP_MAX_WARMUP_REPLICAS_CONFIG);
this.streamsGroupAcceptableRecoveryLag =
config.getLong(GroupCoordinatorConfig.STREAMS_GROUP_ACCEPTABLE_RECOVERY_LAG_CONFIG);
+ this.streamsGroupAssignors = streamsGroupAssignors(config);
+ this.streamsGroupAssignorNames =
this.streamsGroupAssignors.stream().map(TaskAssignor::name).toList();
this.config = config;
checkConstraints();
@@ -916,6 +938,71 @@ public class GroupCoordinatorConfig {
return assignors;
}
+ protected List<TaskAssignor> streamsGroupAssignors(
+ AbstractConfig config
+ ) {
+ Map<String, TaskAssignor> builtInAssignors =
STREAMS_GROUP_BUILTIN_ASSIGNORS
+ .stream()
+ .collect(Collectors.toMap(TaskAssignor::name,
Function.identity()));
+ // A built-in may be configured either by its name or by its class
name, so it is recognised
+ // by class rather than by how it was resolved below.
+ Set<Class<? extends TaskAssignor>> builtInAssignorClasses =
STREAMS_GROUP_BUILTIN_ASSIGNORS
+ .stream()
+ .map(TaskAssignor::getClass)
+ .collect(Collectors.toSet());
+
+ List<TaskAssignor> assignors = new ArrayList<>();
+ Set<String> assignorNames = new HashSet<>();
+
+ try {
+ // `configuredAssignor` is either the name of a built-in assignor,
+ // or a fully qualified class name of a custom assignor
+ for (String configuredAssignor :
config.getList(GroupCoordinatorConfig.STREAMS_GROUP_ASSIGNORS_CONFIG)) {
+ TaskAssignor assignor =
builtInAssignors.get(configuredAssignor);
+ if (assignor == null) {
+ try {
+ assignor = Utils.newInstance(configuredAssignor,
TaskAssignor.class);
+ } catch (ClassNotFoundException e) {
+ throw new
ConfigException(STREAMS_GROUP_ASSIGNORS_CONFIG, configuredAssignor,
+ "Class cannot be found");
+ } catch (ClassCastException e) {
+ throw new
ConfigException(STREAMS_GROUP_ASSIGNORS_CONFIG, configuredAssignor,
+ "Class is not an instance of " +
TaskAssignor.class.getName());
+ } catch (KafkaException e) {
+ // Utils#newInstance reports instantiation failures,
for example a missing
+ // public no-argument constructor, without naming the
config that caused them.
+ throw new
ConfigException(STREAMS_GROUP_ASSIGNORS_CONFIG, configuredAssignor,
e.getMessage());
+ }
+ }
+
+ assignors.add(assignor);
+
+ if (!builtInAssignorClasses.contains(assignor.getClass()) &&
builtInAssignors.containsKey(assignor.name())) {
+ throw new ConfigException(STREAMS_GROUP_ASSIGNORS_CONFIG,
configuredAssignor,
+ "Assignor name '" + assignor.name() + "' is reserved
by a built-in assignor. " +
+ "A custom assignor must not reuse the name of a
built-in assignor");
+ }
+
+ if (!assignorNames.add(assignor.name())) {
+ throw new ConfigException(STREAMS_GROUP_ASSIGNORS_CONFIG,
configuredAssignor,
+ "Assignor name '" + assignor.name() + "' is already
registered by another configured assignor. " +
+ "Assignor names, whether built-in or custom, must
be unique");
+ }
+
+ if (assignor instanceof Configurable configurable) {
+ configurable.configure(config.originals());
+ }
+ }
+ } catch (Exception e) {
+ for (TaskAssignor assignor : assignors) {
+ maybeCloseQuietly(assignor, "AutoCloseable object constructed
and configured during failed call to streamsGroupAssignors");
+ }
+ throw e;
+ }
+
+ return assignors;
+ }
+
protected List<ShareGroupPartitionAssignor> shareGroupAssignors(
AbstractConfig config
) {
@@ -1468,4 +1555,18 @@ public class GroupCoordinatorConfig {
public long streamsGroupAcceptableRecoveryLag() {
return streamsGroupAcceptableRecoveryLag;
}
+
+ /**
+ * The streams group task assignors.
+ */
+ public List<TaskAssignor> streamsGroupAssignors() {
+ return streamsGroupAssignors;
+ }
+
+ /**
+ * The names of the registered streams group task assignors, in configured
order.
+ */
+ public List<String> streamsGroupAssignorNames() {
+ return streamsGroupAssignorNames;
+ }
}
diff --git
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorShard.java
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorShard.java
index 58639a20b70..42279c644d3 100644
---
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorShard.java
+++
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorShard.java
@@ -274,6 +274,7 @@ public class GroupCoordinatorShard implements
CoordinatorShard<CoordinatorRecord
.withGroupConfigManager(groupConfigManager)
.withGroupCoordinatorMetricsShard(metricsShard)
.withShareGroupAssignor(config.shareGroupAssignors().get(0))
+ .withStreamsGroupAssignors(config.streamsGroupAssignors())
.withAuthorizerPlugin(authorizerPlugin)
.build();
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 e801efc8722..a437e4d10d4 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
@@ -510,6 +510,11 @@ public class GroupMetadataManager {
*/
private final Map<String, TaskAssignor> streamsGroupAssignors;
+ /**
+ * The default streams group task assignor used.
+ */
+ private final TaskAssignor defaultStreamsGroupAssignor;
+
/**
* The metadata image.
*/
@@ -580,6 +585,7 @@ public class GroupMetadataManager {
this.shareGroupStatePartitionMetadata = new
TimelineHashMap<>(snapshotRegistry, 0);
this.groupConfigManager = groupConfigManager;
this.shareGroupAssignor = shareGroupAssignor;
+ this.defaultStreamsGroupAssignor = streamsGroupAssignors.get(0);
this.streamsGroupAssignors =
streamsGroupAssignors.stream().collect(Collectors.toMap(TaskAssignor::name,
Function.identity()));
this.topicRegexResolver = new TopicRegexResolver(() ->
authorizerPlugin, this.time);
this.topicHashCache = new HashMap<>();
@@ -752,7 +758,10 @@ public class GroupMetadataManager {
groupIds.forEach(groupId -> {
try {
StreamsGroup group = streamsGroup(groupId, committedOffset);
- describedGroups.add(group.asDescribedGroup(committedOffset));
+ describedGroups.add(group.asDescribedGroup(
+ committedOffset,
+ streamsGroupAssignor(groupId, false).name()
+ ));
groupIdToStoredDescriptionTopologyEpochs.put(groupId,
group.storedDescriptionTopologyEpoch(committedOffset));
} catch (GroupIdNotFoundException exception) {
describedGroups.add(new
StreamsGroupDescribeResponseData.DescribedGroup()
@@ -4456,7 +4465,7 @@ public class GroupMetadataManager {
return new UpdateTargetAssignmentResult<>(group.assignmentEpoch(),
updatedMembersAndTargetAssignment.targetAssignment());
}
- TaskAssignor assignor = streamsGroupAssignor(group.groupId());
+ TaskAssignor assignor = streamsGroupAssignor(group.groupId(), true);
try {
org.apache.kafka.coordinator.group.streams.TargetAssignmentBuilder
assignmentResultBuilder =
new
org.apache.kafka.coordinator.group.streams.TargetAssignmentBuilder(
@@ -9806,9 +9815,31 @@ public class GroupMetadataManager {
/**
* Get the assignor of the provided streams group.
+ *
+ * <p>The assignor is selected by the group-level {@link
GroupConfig#STREAMS_ASSIGNOR_NAME_CONFIG}
+ * configuration. When the group does not select an assignor, the broker's
default assignor
+ * (the first entry of {@code group.streams.assignors}) is used. If the
selected assignor is no
+ * longer registered on the broker, the coordinator falls back to the
default.
+ *
+ * @param maybeLogWarning Whether to warn about the fallback. Set to false
on read-only paths such as
+ * describe, which would otherwise log on every
request.
*/
- private TaskAssignor streamsGroupAssignor(String groupId) {
- return streamsGroupAssignors.get("sticky");
+ // Visible for testing
+ TaskAssignor streamsGroupAssignor(String groupId, boolean maybeLogWarning)
{
+ Optional<String> configuredName =
groupConfigManager.groupConfig(groupId)
+ .flatMap(GroupConfig::streamsAssignorName);
+ if (configuredName.isPresent()) {
+ TaskAssignor assignor =
streamsGroupAssignors.get(configuredName.get());
+ if (assignor != null) {
+ return assignor;
+ }
+ if (maybeLogWarning) {
+ log.warn("[GroupId {}] The configured task assignor '{}' is
not available; " +
+ "falling back to the default assignor '{}'.",
+ groupId, configuredName.get(),
defaultStreamsGroupAssignor.name());
+ }
+ }
+ return defaultStreamsGroupAssignor;
}
/**
diff --git
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroup.java
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroup.java
index 8e9116c0f83..3a481459f1c 100644
---
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroup.java
+++
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroup.java
@@ -1312,14 +1312,21 @@ public class StreamsGroup implements Group {
});
}
+ /**
+ * @param committedOffset The last committed offset of this shard.
+ * @param assignorName The name of the assignor that the coordinator
will use for the next assignment
+ * computation, already resolved against the
assignors registered on this broker.
+ */
public StreamsGroupDescribeResponseData.DescribedGroup asDescribedGroup(
- long committedOffset
+ long committedOffset,
+ String assignorName
) {
StreamsGroupDescribeResponseData.DescribedGroup describedGroup = new
StreamsGroupDescribeResponseData.DescribedGroup()
.setGroupId(groupId)
.setGroupEpoch(groupEpoch.get(committedOffset))
.setGroupState(state.get(committedOffset).toString())
.setAssignmentEpoch(targetAssignmentMetadata.get(committedOffset).assignmentEpoch())
+ .setAssignorName(assignorName)
.setTopology(
configuredTopology.get(committedOffset)
.filter(ConfiguredTopology::isReady)
diff --git
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupConfigTest.java
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupConfigTest.java
index 5436c7c4466..db64d0945b6 100644
---
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupConfigTest.java
+++
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupConfigTest.java
@@ -137,7 +137,10 @@ public class GroupConfigTest {
// This is a free-form list of tag keys, so values like
"not_a_number" are valid. Only an
// empty tag key (an empty element between commas) is rejected.
assertPropertyInvalid(name, "tag1,,tag2");
- } else if
(!GroupConfig.ERRORS_DEADLETTERQUEUE_TOPIC_NAME_CONFIG.equals(name)) {
+ } else if
(!GroupConfig.ERRORS_DEADLETTERQUEUE_TOPIC_NAME_CONFIG.equals(name)
+ && !GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG.equals(name))
{
+ // Free-form string configs (no ConfigDef validator) accept
any value at construction
+ // time; their values are validated separately in
GroupConfig.validate.
assertPropertyInvalid(name, "not_a_number", "-0.1");
}
});
@@ -379,6 +382,55 @@ public class GroupConfigTest {
new GroupConfig(whitespaceProps).streamsRackAwareAssignmentTags());
}
+ @Test
+ public void testStreamsAssignorNameValidation() {
+ // A registered assignor name is accepted.
+ Map<String, String> props = createValidGroupConfig();
+ props.put(GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG, "sticky");
+ doTestValidProps(props);
+
+ // An unknown assignor name is rejected with INVALID_CONFIG.
+ props = createValidGroupConfig();
+ props.put(GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG, "does-not-exist");
+ doTestInvalidProps(props, InvalidConfigurationException.class);
+ }
+
+ @Test
+ public void testStreamsAssignorNameSelectsCustomAssignor() {
+ // A custom assignor registered on the broker can be selected by its
name.
+ GroupCoordinatorConfig groupCoordinatorConfig =
createGroupCoordinatorConfig(Map.of(
+ GroupCoordinatorConfig.STREAMS_GROUP_ASSIGNORS_CONFIG,
+ "sticky," +
GroupCoordinatorConfigTest.CustomTaskAssignor.class.getName()
+ ));
+
+ Map<String, String> props = createValidGroupConfig();
+ props.put(GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG,
"CustomTaskAssignor");
+ assertDoesNotThrow(() -> GroupConfig.validate(props,
groupCoordinatorConfig, createShareGroupConfig()));
+
+ // The built-in assignor is still selectable alongside it.
+ props.put(GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG, "sticky");
+ assertDoesNotThrow(() -> GroupConfig.validate(props,
groupCoordinatorConfig, createShareGroupConfig()));
+
+ // The custom assignor's class name is not a valid selector; only its
name() is.
+ props.put(GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG,
GroupCoordinatorConfigTest.CustomTaskAssignor.class.getName());
+ assertThrows(InvalidConfigurationException.class,
+ () -> GroupConfig.validate(props, groupCoordinatorConfig,
createShareGroupConfig()));
+ }
+
+ @Test
+ public void testStreamsAssignorNameEvaluateIsLenient() {
+ // The Admin path (validate) rejects an unknown assignor name...
+ Map<String, String> props = createValidGroupConfig();
+ props.put(GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG, "does-not-exist");
+ doTestInvalidProps(props, InvalidConfigurationException.class);
+
+ // ...but the metadata-replay path (evaluate) accepts it, so a value
that was valid when set
+ // survives a broker restart even if the assignor was later removed
from the broker config.
+ Properties replayed = new Properties();
+ replayed.setProperty(GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG,
"does-not-exist");
+ assertDoesNotThrow(() -> GroupConfig.evaluate(replayed, "group",
createGroupCoordinatorConfig(), createShareGroupConfig()));
+ }
+
private void doTestInvalidProps(Map<String, String> props, Class<? extends
Exception> exceptionClassName) {
assertThrows(exceptionClassName, () -> GroupConfig.validate(props,
createGroupCoordinatorConfig(), createShareGroupConfig()));
}
@@ -894,15 +946,21 @@ public class GroupConfigTest {
}
private GroupCoordinatorConfig createGroupCoordinatorConfig() {
+ return createGroupCoordinatorConfig(Map.of());
+ }
+
+ private GroupCoordinatorConfig createGroupCoordinatorConfig(Map<String,
Object> overrides) {
+ Map<String, Object> configs = new HashMap<>(Map.of(
+
GroupCoordinatorConfig.CONSUMER_GROUP_MIN_ASSIGNMENT_INTERVAL_MS_CONFIG, 1000,
+
GroupCoordinatorConfig.SHARE_GROUP_MIN_ASSIGNMENT_INTERVAL_MS_CONFIG, 1000,
+
GroupCoordinatorConfig.STREAMS_GROUP_MIN_ASSIGNMENT_INTERVAL_MS_CONFIG, 1000
+ ));
+ configs.putAll(overrides);
return GroupCoordinatorConfigTest.createGroupCoordinatorConfig(
OFFSET_METADATA_MAX_SIZE,
OFFSETS_RETENTION_CHECK_INTERVAL_MS,
OFFSETS_RETENTION_MINUTES,
- Map.of(
-
GroupCoordinatorConfig.CONSUMER_GROUP_MIN_ASSIGNMENT_INTERVAL_MS_CONFIG, 1000,
-
GroupCoordinatorConfig.SHARE_GROUP_MIN_ASSIGNMENT_INTERVAL_MS_CONFIG, 1000,
-
GroupCoordinatorConfig.STREAMS_GROUP_MIN_ASSIGNMENT_INTERVAL_MS_CONFIG, 1000
- )
+ configs
);
}
diff --git
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorConfigTest.java
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorConfigTest.java
index 8443624f83c..5d6eaefe804 100644
---
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorConfigTest.java
+++
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorConfigTest.java
@@ -28,9 +28,12 @@ import
org.apache.kafka.coordinator.group.api.assignor.ShareGroupPartitionAssign
import
org.apache.kafka.coordinator.group.api.assignor.SubscribedTopicDescriber;
import
org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescription;
import
org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescriptionPlugin;
+import org.apache.kafka.coordinator.group.api.streams.assignor.TaskAssignor;
+import
org.apache.kafka.coordinator.group.api.streams.assignor.TopologyDescriber;
import org.apache.kafka.coordinator.group.assignor.RangeAssignor;
import org.apache.kafka.coordinator.group.assignor.SimpleAssignor;
import org.apache.kafka.coordinator.group.assignor.UniformAssignor;
+import org.apache.kafka.coordinator.group.streams.assignor.StickyTaskAssignor;
import org.junit.jupiter.api.Test;
@@ -288,6 +291,200 @@ public class GroupCoordinatorConfigTest {
assertThrows(ConfigException.class, () ->
createConfig(configs)).getMessage());
}
+ public static class CustomTaskAssignor implements TaskAssignor,
Configurable {
+ public Map<String, ?> configs;
+
+ @Override
+ public void configure(Map<String, ?> configs) {
+ this.configs = configs;
+ }
+
+ @Override
+ public String name() {
+ return "CustomTaskAssignor";
+ }
+
+ @Override
+ public
org.apache.kafka.coordinator.group.api.streams.assignor.GroupAssignment assign(
+ org.apache.kafka.coordinator.group.api.streams.assignor.GroupSpec
groupSpec,
+ TopologyDescriber topologyDescriber
+ ) {
+ return null;
+ }
+ }
+
+ public static class NoDefaultConstructorTaskAssignor implements
TaskAssignor {
+ public NoDefaultConstructorTaskAssignor(String unused) {
+ }
+
+ @Override
+ public String name() {
+ return "NoDefaultConstructorTaskAssignor";
+ }
+
+ @Override
+ public
org.apache.kafka.coordinator.group.api.streams.assignor.GroupAssignment assign(
+ org.apache.kafka.coordinator.group.api.streams.assignor.GroupSpec
groupSpec,
+ TopologyDescriber topologyDescriber
+ ) {
+ return null;
+ }
+ }
+
+ public static class DuplicateNameTaskAssignor implements TaskAssignor {
+ @Override
+ public String name() {
+ // Collides with CustomTaskAssignor.
+ return "CustomTaskAssignor";
+ }
+
+ @Override
+ public
org.apache.kafka.coordinator.group.api.streams.assignor.GroupAssignment assign(
+ org.apache.kafka.coordinator.group.api.streams.assignor.GroupSpec
groupSpec,
+ TopologyDescriber topologyDescriber
+ ) {
+ return null;
+ }
+ }
+
+ public static class StickyNamedTaskAssignor implements TaskAssignor {
+ @Override
+ public String name() {
+ // Collides with the built-in "sticky" assignor.
+ return "sticky";
+ }
+
+ @Override
+ public
org.apache.kafka.coordinator.group.api.streams.assignor.GroupAssignment assign(
+ org.apache.kafka.coordinator.group.api.streams.assignor.GroupSpec
groupSpec,
+ TopologyDescriber topologyDescriber
+ ) {
+ return null;
+ }
+ }
+
+ @Test
+ public void testStreamsGroupAssignorFullClassNames() {
+ // The full class name of the assignors is part of our public api.
Hence,
+ // we should ensure that they are not changed by mistake.
+ assertEquals(
+
"org.apache.kafka.coordinator.group.streams.assignor.StickyTaskAssignor",
+ StickyTaskAssignor.class.getName()
+ );
+ }
+
+ @Test
+ public void testStreamsGroupAssignors() {
+ Map<String, Object> configs = new HashMap<>();
+ GroupCoordinatorConfig config;
+ List<TaskAssignor> assignors;
+
+ // Test default config. The default is every built-in assignor, in
declaration order.
+ assertEquals(List.of("sticky"),
GroupCoordinatorConfig.STREAMS_GROUP_ASSIGNORS_DEFAULT);
+ config = createConfig(configs);
+ assignors = config.streamsGroupAssignors();
+ assertEquals(1, assignors.size());
+ assertInstanceOf(StickyTaskAssignor.class, assignors.get(0));
+ assertEquals(List.of("sticky"), config.streamsGroupAssignorNames());
+
+ // Test custom assignor.
+ configs.put(GroupCoordinatorConfig.STREAMS_GROUP_ASSIGNORS_CONFIG,
CustomTaskAssignor.class.getName());
+ config = createConfig(configs);
+ assignors = config.streamsGroupAssignors();
+ assertEquals(1, assignors.size());
+ assertInstanceOf(CustomTaskAssignor.class, assignors.get(0));
+ assertNotNull(((CustomTaskAssignor) assignors.get(0)).configs);
+
+ // Test a combination (built-in short name and custom class name)
supplied as a programmatic
+ // list of strings.
+ configs.put(GroupCoordinatorConfig.STREAMS_GROUP_ASSIGNORS_CONFIG,
List.of("sticky", CustomTaskAssignor.class.getName()));
+ config = createConfig(configs);
+ assignors = config.streamsGroupAssignors();
+ assertEquals(2, assignors.size());
+ assertInstanceOf(StickyTaskAssignor.class, assignors.get(0));
+ assertInstanceOf(CustomTaskAssignor.class, assignors.get(1));
+ // The names are reported in configured order, so the first one is the
default.
+ assertEquals(List.of("sticky", "CustomTaskAssignor"),
config.streamsGroupAssignorNames());
+
+ // Test the same combination supplied as a comma-separated string (the
form the broker
+ // config is always delivered in).
+ configs.put(GroupCoordinatorConfig.STREAMS_GROUP_ASSIGNORS_CONFIG,
"sticky, " + CustomTaskAssignor.class.getName());
+ config = createConfig(configs);
+ assignors = config.streamsGroupAssignors();
+ assertEquals(2, assignors.size());
+ assertInstanceOf(StickyTaskAssignor.class, assignors.get(0));
+ assertInstanceOf(CustomTaskAssignor.class, assignors.get(1));
+ }
+
+ @Test
+ public void testStreamsGroupAssignorsWithDuplicateNamesFails() {
+ // Two custom assignors resolving to the same name must fail startup.
+ Map<String, Object> configs = new HashMap<>();
+ configs.put(GroupCoordinatorConfig.STREAMS_GROUP_ASSIGNORS_CONFIG,
+ List.of(CustomTaskAssignor.class.getName(),
DuplicateNameTaskAssignor.class.getName()));
+ assertEquals("Invalid value " +
DuplicateNameTaskAssignor.class.getName() +
+ " for configuration group.streams.assignors: Assignor name
'CustomTaskAssignor' is already " +
+ "registered by another configured assignor. Assignor names,
whether built-in or custom, must be unique",
+ assertThrows(ConfigException.class, () ->
createConfig(configs)).getMessage());
+
+ // Configuring the same built-in twice, once by name and once by class
name, must fail startup.
+ configs.put(GroupCoordinatorConfig.STREAMS_GROUP_ASSIGNORS_CONFIG,
+ List.of("sticky", StickyTaskAssignor.class.getName()));
+ assertEquals("Invalid value " + StickyTaskAssignor.class.getName() +
+ " for configuration group.streams.assignors: Assignor name
'sticky' is already " +
+ "registered by another configured assignor. Assignor names,
whether built-in or custom, must be unique",
+ assertThrows(ConfigException.class, () ->
createConfig(configs)).getMessage());
+ }
+
+ @Test
+ public void testStreamsGroupAssignorsWithReservedBuiltinNameFails() {
+ // A custom assignor must not take the name of a built-in, whether or
not the built-in is
+ // itself configured: a group selecting that name would otherwise
silently get the custom one.
+ Map<String, Object> configs = new HashMap<>();
+ configs.put(GroupCoordinatorConfig.STREAMS_GROUP_ASSIGNORS_CONFIG,
StickyNamedTaskAssignor.class.getName());
+ assertEquals("Invalid value " +
StickyNamedTaskAssignor.class.getName() +
+ " for configuration group.streams.assignors: Assignor name
'sticky' is reserved by a " +
+ "built-in assignor. A custom assignor must not reuse the name
of a built-in assignor",
+ assertThrows(ConfigException.class, () ->
createConfig(configs)).getMessage());
+ }
+
+ @Test
+ public void testStreamsGroupAssignorsBuiltinByClassName() {
+ // A built-in configured by its class name is recognised as the
built-in, not as a custom assignor
+ // reusing the reserved name. The name it is registered under is the
built-in short name.
+ Map<String, Object> configs = new HashMap<>();
+ configs.put(GroupCoordinatorConfig.STREAMS_GROUP_ASSIGNORS_CONFIG,
StickyTaskAssignor.class.getName());
+ GroupCoordinatorConfig config = createConfig(configs);
+ List<TaskAssignor> assignors = config.streamsGroupAssignors();
+ assertEquals(1, assignors.size());
+ assertInstanceOf(StickyTaskAssignor.class, assignors.get(0));
+ assertEquals(List.of("sticky"), config.streamsGroupAssignorNames());
+ }
+
+ @Test
+ public void testStreamsGroupAssignorsWithInvalidClassFails() {
+ Map<String, Object> configs = new HashMap<>();
+
+ // An entry that is neither a built-in short name nor a loadable class
name must fail startup.
+ configs.put(GroupCoordinatorConfig.STREAMS_GROUP_ASSIGNORS_CONFIG,
"org.apache.kafka.NonExistentAssignor");
+ assertEquals("Invalid value org.apache.kafka.NonExistentAssignor for
configuration " +
+ "group.streams.assignors: Class cannot be found",
+ assertThrows(ConfigException.class, () ->
createConfig(configs)).getMessage());
+
+ // Test class that is not an assignor.
+ configs.put(GroupCoordinatorConfig.STREAMS_GROUP_ASSIGNORS_CONFIG,
Object.class.getName());
+ assertEquals("Invalid value java.lang.Object for configuration
group.streams.assignors: " +
+ "Class is not an instance of
org.apache.kafka.coordinator.group.api.streams.assignor.TaskAssignor",
+ assertThrows(ConfigException.class, () ->
createConfig(configs)).getMessage());
+
+ // Test class that cannot be instantiated.
+ configs.put(GroupCoordinatorConfig.STREAMS_GROUP_ASSIGNORS_CONFIG,
NoDefaultConstructorTaskAssignor.class.getName());
+ assertEquals("Invalid value " +
NoDefaultConstructorTaskAssignor.class.getName() +
+ " for configuration group.streams.assignors: Could not find a
public no-argument constructor for " +
+ NoDefaultConstructorTaskAssignor.class.getName(),
+ assertThrows(ConfigException.class, () ->
createConfig(configs)).getMessage());
+ }
+
@Test
public void testConfigs() {
Map<String, Object> configs = new HashMap<>();
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 b5dfd129c16..7636eb7a202 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
@@ -80,6 +80,7 @@ import org.apache.kafka.common.protocol.ByteBufferAccessor;
import org.apache.kafka.common.protocol.Errors;
import org.apache.kafka.common.protocol.MessageUtil;
import org.apache.kafka.common.requests.StreamsGroupHeartbeatResponse.Status;
+import org.apache.kafka.common.utils.LogCaptureAppender;
import org.apache.kafka.common.utils.MockTime;
import org.apache.kafka.common.utils.Utils;
import org.apache.kafka.common.utils.internals.LogContext;
@@ -248,6 +249,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
@@ -11061,6 +11063,7 @@ public class GroupMetadataManagerTest {
.setGroupId(streamsGroupIds.get(0))
.setGroupState(StreamsGroupState.EMPTY.toString())
.setAssignmentEpoch(1)
+ .setAssignorName("mock")
.setTopology(expectedTopology),
new StreamsGroupDescribeResponseData.DescribedGroup()
.setGroupEpoch(epoch)
@@ -11074,6 +11077,7 @@ public class GroupMetadataManagerTest {
.setTopology(expectedTopology)
.setGroupState(StreamsGroupState.NOT_READY.toString())
.setAssignmentEpoch(1)
+ .setAssignorName("mock")
);
List<StreamsGroupDescribeResponseData.DescribedGroup> actual =
context.sendStreamsGroupDescribe(streamsGroupIds);
@@ -11229,11 +11233,41 @@ public class GroupMetadataManagerTest {
)
.setGroupState(StreamsGroup.StreamsGroupState.ASSIGNING.toString())
.setGroupEpoch(epoch + 2)
- .setAssignmentEpoch(epoch + 1);
+ .setAssignmentEpoch(epoch + 1)
+ .setAssignorName("mock");
assertEquals(1, actual.size());
assertEquals(describedGroup, actual.get(0));
}
+ @Test
+ public void testStreamsGroupDescribeReportsAssignorSelectedByGroupConfig()
{
+ String groupId = "group-id";
+ String subtopology1 = "subtopology1";
+ StreamsTopology topology = new StreamsTopology(
+ 0,
+ Map.of(subtopology1,
+ new StreamsGroupTopologyValue.Subtopology()
+ .setSubtopologyId(subtopology1)
+ .setSourceTopics(List.of("foo"))
+ )
+ );
+
+ // The broker registers two assignors; the first ("sticky") is the
default.
+ GroupMetadataManagerTestContext context = new
GroupMetadataManagerTestContext.Builder()
+ .withStreamsGroupTaskAssignors(List.of(new
MockTaskAssignor("sticky"), new MockTaskAssignor("custom")))
+ .withStreamsGroup(new StreamsGroupBuilder(groupId,
10).withTopology(topology))
+ .build();
+
+ Properties groupConfig = new Properties();
+ groupConfig.setProperty(GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG,
"custom");
+ context.updateGroupConfig(groupId, groupConfig);
+
+ List<StreamsGroupDescribeResponseData.DescribedGroup> described =
context.sendStreamsGroupDescribe(List.of(groupId));
+
+ assertEquals(1, described.size());
+ assertEquals("custom", described.get(0).assignorName());
+ }
+
@Test
public void testFinalizeAfterDeleteClearsUncertainToNone() {
// The plugin.deleteTopology completed and stored is still UNCERTAIN:
no push raced, the
@@ -18738,6 +18772,79 @@ public class GroupMetadataManagerTest {
assertEquals(String.format("Member %s is not a member of group %s.",
memberId2, groupId), e.getMessage());
}
+ @Test
+ public void testStreamsGroupUsesBrokerDefaultAssignorWhenGroupUnset() {
+ // The broker registers two assignors; the first ("sticky") is the
default.
+ MockTaskAssignor defaultAssignor = new MockTaskAssignor("sticky");
+ GroupMetadataManagerTestContext context = new
GroupMetadataManagerTestContext.Builder()
+ .withStreamsGroupTaskAssignors(List.of(defaultAssignor, new
MockTaskAssignor("custom")))
+ .build();
+
+ // The group does not select an assignor (streams.assignor.name is
unset).
+ assertSame(defaultAssignor,
context.groupMetadataManager.streamsGroupAssignor("fooup", true));
+ }
+
+ @Test
+ public void
testStreamsGroupDefaultsToFirstAssignorWhenNoStickyConfigured() {
+ // The broker registers only a custom assignor (no built-in "sticky");
it is the default.
+ MockTaskAssignor customAssignor = new MockTaskAssignor("custom");
+ GroupMetadataManagerTestContext context = new
GroupMetadataManagerTestContext.Builder()
+ .withStreamsGroupTaskAssignors(List.of(customAssignor))
+ .build();
+
+ // Resolution must not assume that a "sticky" assignor is registered.
+ assertSame(customAssignor,
context.groupMetadataManager.streamsGroupAssignor("fooup", true));
+ }
+
+ @Test
+ public void testStreamsGroupAssignorSelectedByGroupConfig() {
+ String groupId = "fooup";
+
+ MockTaskAssignor customAssignor = new MockTaskAssignor("custom");
+ GroupMetadataManagerTestContext context = new
GroupMetadataManagerTestContext.Builder()
+ .withStreamsGroupTaskAssignors(List.of(new
MockTaskAssignor("sticky"), customAssignor))
+ .build();
+
+ // The group selects the custom assignor by name.
+ Properties groupConfig = new Properties();
+ groupConfig.setProperty(GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG,
"custom");
+ context.updateGroupConfig(groupId, groupConfig);
+
+ assertSame(customAssignor,
context.groupMetadataManager.streamsGroupAssignor(groupId, true));
+ }
+
+ @Test
+ public void testStreamsGroupAssignorFallsBackToDefaultWhenUnavailable() {
+ String groupId = "fooup";
+
+ MockTaskAssignor defaultAssignor = new MockTaskAssignor("sticky");
+ GroupMetadataManagerTestContext context = new
GroupMetadataManagerTestContext.Builder()
+ .withStreamsGroupTaskAssignors(List.of(defaultAssignor, new
MockTaskAssignor("custom")))
+ .build();
+
+ // The group selects an assignor that is not registered on the broker.
+ Properties groupConfig = new Properties();
+ groupConfig.setProperty(GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG,
"does-not-exist");
+ context.updateGroupConfig(groupId, groupConfig);
+
+ try (LogCaptureAppender appender =
LogCaptureAppender.createAndRegister(GroupMetadataManager.class)) {
+ // The coordinator falls back to the default (first) assignor.
+ assertSame(defaultAssignor,
context.groupMetadataManager.streamsGroupAssignor(groupId, true));
+
+ // A warning names the unavailable assignor and the fallback.
+ assertEquals(1, appender.getMessages("WARN").stream()
+ .filter(msg -> msg.contains("The configured task assignor
'does-not-exist' is not available"))
+ .count());
+ }
+
+ try (LogCaptureAppender appender =
LogCaptureAppender.createAndRegister(GroupMetadataManager.class)) {
+ // Read-only paths such as describe resolve the same fallback
without warning.
+ assertSame(defaultAssignor,
context.groupMetadataManager.streamsGroupAssignor(groupId, false));
+
+ assertEquals(List.of(), appender.getMessages("WARN"));
+ }
+ }
+
@Test
public void testStreamsGroupMemberEpochValidation() {
String groupId = "fooup";
@@ -19725,7 +19832,8 @@ public class GroupMetadataManagerTest {
TaskAssignmentTestUtil.mkTasks(subtopology1, 0, 1, 2, 3,
4, 5)), MemberTaskOffsets.EMPTY)
))
.setGroupState(StreamsGroupState.STABLE.toString())
- .setGroupEpoch(2);
+ .setGroupEpoch(2)
+ .setAssignorName("sticky");
assertEquals(1, actualDescribedGroups.size());
assertEquals(expectedDescribedGroup, actualDescribedGroups.get(0));
}
diff --git
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTest.java
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTest.java
index 82a5e8b7765..a4bc5bdad8f 100644
---
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTest.java
+++
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTest.java
@@ -1029,6 +1029,7 @@ public class StreamsGroupTest {
.setGroupEpoch(1)
.setTopology(new
StreamsGroupDescribeResponseData.Topology().setEpoch(1).setSubtopologies(List.of()))
.setAssignmentEpoch(1)
+ .setAssignorName("sticky")
.setMembers(Arrays.asList(
new StreamsGroupDescribeResponseData.Member()
.setMemberId("member1")
@@ -1057,7 +1058,7 @@ public class StreamsGroupTest {
.setAssignment(new
StreamsGroupDescribeResponseData.Assignment())
.setTargetAssignment(new
StreamsGroupDescribeResponseData.Assignment())
));
- StreamsGroupDescribeResponseData.DescribedGroup actual =
group.asDescribedGroup(1);
+ StreamsGroupDescribeResponseData.DescribedGroup actual =
group.asDescribedGroup(1, "sticky");
assertEquals(expected, actual);
}
@@ -1237,12 +1238,13 @@ public class StreamsGroupTest {
));
snapshotRegistry.idempotentCreateSnapshot(1);
- StreamsGroupDescribeResponseData.DescribedGroup describedGroup =
group.asDescribedGroup(1);
+ StreamsGroupDescribeResponseData.DescribedGroup describedGroup =
group.asDescribedGroup(1, "sticky");
assertEquals("group-id-with-topology", describedGroup.groupId());
assertEquals(StreamsGroup.StreamsGroupState.NOT_READY.toString(),
describedGroup.groupState());
assertEquals(2, describedGroup.groupEpoch());
assertEquals(2, describedGroup.assignmentEpoch());
+ assertEquals("sticky", describedGroup.assignorName());
// Verify topology is correctly described
assertNotNull(describedGroup.topology());
@@ -1288,7 +1290,7 @@ public class StreamsGroupTest {
group.setTargetAssignmentMetadata(3, 12345L);
snapshotRegistry.idempotentCreateSnapshot(1);
- StreamsGroupDescribeResponseData.DescribedGroup describedGroup =
group.asDescribedGroup(1);
+ StreamsGroupDescribeResponseData.DescribedGroup describedGroup =
group.asDescribedGroup(1, "sticky");
// Should prefer ConfiguredTopology over StreamsTopology
assertNotNull(describedGroup.topology());
@@ -1315,7 +1317,7 @@ public class StreamsGroupTest {
group.setTargetAssignmentMetadata(4, 12345L);
snapshotRegistry.idempotentCreateSnapshot(1);
- StreamsGroupDescribeResponseData.DescribedGroup describedGroup =
group.asDescribedGroup(1);
+ StreamsGroupDescribeResponseData.DescribedGroup describedGroup =
group.asDescribedGroup(1, "sticky");
// Should use StreamsTopology when ConfiguredTopology is not available
assertNotNull(describedGroup.topology());
diff --git
a/server/src/main/java/org/apache/kafka/server/config/AbstractKafkaConfig.java
b/server/src/main/java/org/apache/kafka/server/config/AbstractKafkaConfig.java
index d60543b2edd..6220651ed03 100644
---
a/server/src/main/java/org/apache/kafka/server/config/AbstractKafkaConfig.java
+++
b/server/src/main/java/org/apache/kafka/server/config/AbstractKafkaConfig.java
@@ -654,9 +654,11 @@ public abstract class AbstractKafkaConfig extends
AbstractConfig {
* defaults when building a {@link GroupConfig} for {@code
DescribeConfigs}.
* Internal group configs are excluded unless their broker synonym was
explicitly configured.
*
+ * @param groupCoordinatorConfig The group coordinator config, used to
resolve defaults that are
+ * not the plain value of the broker synonym.
* @return a map of group config names to their corresponding broker-level
values
*/
- public Map<String, Object> extractGroupConfigMap() {
+ public Map<String, Object> extractGroupConfigMap(GroupCoordinatorConfig
groupCoordinatorConfig) {
Map<String, Object> defaults = new HashMap<>();
Map<String, Object> brokerOriginals = originals();
GroupConfig.configNames().forEach(groupConfigName ->
@@ -667,6 +669,12 @@ public abstract class AbstractKafkaConfig extends
AbstractConfig {
}
})
);
+ // The group config holds a single assignor name, whereas the broker
config is a list that may also
+ // use class names, so the default is the name of the first registered
assignor.
+ defaults.computeIfPresent(
+ GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG,
+ (groupConfigName, brokerValue) ->
groupCoordinatorConfig.streamsGroupAssignorNames().get(0)
+ );
return defaults;
}
}
diff --git
a/server/src/test/java/org/apache/kafka/server/config/AbstractKafkaConfigTest.java
b/server/src/test/java/org/apache/kafka/server/config/AbstractKafkaConfigTest.java
index 821ab40e2cc..fae45548fa8 100644
---
a/server/src/test/java/org/apache/kafka/server/config/AbstractKafkaConfigTest.java
+++
b/server/src/test/java/org/apache/kafka/server/config/AbstractKafkaConfigTest.java
@@ -16,8 +16,14 @@
*/
package org.apache.kafka.server.config;
+import org.apache.kafka.common.config.AbstractConfig;
import org.apache.kafka.common.config.ConfigDef;
import org.apache.kafka.coordinator.group.GroupConfig;
+import org.apache.kafka.coordinator.group.GroupCoordinatorConfig;
+import org.apache.kafka.coordinator.group.api.streams.assignor.GroupAssignment;
+import org.apache.kafka.coordinator.group.api.streams.assignor.GroupSpec;
+import org.apache.kafka.coordinator.group.api.streams.assignor.TaskAssignor;
+import
org.apache.kafka.coordinator.group.api.streams.assignor.TopologyDescriber;
import org.apache.kafka.raft.KRaftConfigs;
import org.junit.jupiter.api.Test;
@@ -33,6 +39,7 @@ import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
public class AbstractKafkaConfigTest {
@@ -88,6 +95,49 @@ public class AbstractKafkaConfigTest {
assertEquals("default-value", config.get(TEST_INTERNAL_GROUP_CONFIG));
}
+ @Test
+ public void testExtractGroupConfigMapReturnsStreamsAssignor() {
+ try (MockedStatic<GroupConfig> mocked = mockStatic(GroupConfig.class,
Mockito.CALLS_REAL_METHODS)) {
+
mocked.when(GroupConfig::configNames).thenReturn(Set.of(GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG));
+
+ // The broker synonym of the group config is read from the broker
config, so it must be defined.
+ AbstractKafkaConfig kafkaConfig =
+ new AbstractKafkaConfig(GroupCoordinatorConfig.CONFIG_DEF,
Map.of(), Map.of(), false) { };
+
+ // The group config holds a single assignor name, so the default
is the first entry of the
+ // broker's list rather than the whole list.
+ assertEquals("sticky",
+
kafkaConfig.extractGroupConfigMap(groupCoordinatorConfigWithStreamsAssignors("sticky,"
+ CustomTaskAssignor.class.getName()))
+ .get(GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG));
+
+ // A custom assignor is configured by class name, but a group
selects it by name(), so the
+ // default must be the name and not the class name.
+ assertEquals("CustomTaskAssignor",
+
kafkaConfig.extractGroupConfigMap(groupCoordinatorConfigWithStreamsAssignors(CustomTaskAssignor.class.getName()
+ ",sticky"))
+ .get(GroupConfig.STREAMS_ASSIGNOR_NAME_CONFIG));
+ }
+ }
+
+ private static GroupCoordinatorConfig
groupCoordinatorConfigWithStreamsAssignors(String assignors) {
+ return new GroupCoordinatorConfig(new AbstractConfig(
+ GroupCoordinatorConfig.CONFIG_DEF,
+ Map.of(GroupCoordinatorConfig.STREAMS_GROUP_ASSIGNORS_CONFIG,
assignors),
+ false
+ ));
+ }
+
+ public static class CustomTaskAssignor implements TaskAssignor {
+ @Override
+ public String name() {
+ return "CustomTaskAssignor";
+ }
+
+ @Override
+ public GroupAssignment assign(GroupSpec groupSpec, TopologyDescriber
topologyDescriber) {
+ return null;
+ }
+ }
+
private static Map<String, Object> extractGroupConfigMap(Map<String,
Object> brokerProps, boolean isInternal) {
try (MockedStatic<GroupConfig> mocked = mockStatic(GroupConfig.class,
Mockito.CALLS_REAL_METHODS)) {
@@ -102,7 +152,8 @@ public class AbstractKafkaConfigTest {
AbstractKafkaConfig kafkaConfig = new
AbstractKafkaConfig(configDef, new HashMap<>(brokerProps), Map.of(), false) { };
- return kafkaConfig.extractGroupConfigMap();
+ // The test config is not the streams assignors config, so the
coordinator config is never consulted.
+ return
kafkaConfig.extractGroupConfigMap(mock(GroupCoordinatorConfig.class));
}
}
}
diff --git
a/tools/src/main/java/org/apache/kafka/tools/streams/StreamsGroupCommand.java
b/tools/src/main/java/org/apache/kafka/tools/streams/StreamsGroupCommand.java
index 083e71e3019..3bd2b50ede0 100644
---
a/tools/src/main/java/org/apache/kafka/tools/streams/StreamsGroupCommand.java
+++
b/tools/src/main/java/org/apache/kafka/tools/streams/StreamsGroupCommand.java
@@ -414,15 +414,17 @@ public class StreamsGroupCommand {
final int coordinatorLen = Math.max(25, coordinator.length());
final int stateLen = 25;
+ String assignor = description.assignorName().orElse("");
+ final int assignorLen = Math.max(15, assignor.length());
if (!verbose) {
- String fmt = "%" + -groupLen + "s %" + -coordinatorLen + "s %"
+ -stateLen + "s %s\n";
- System.out.printf(fmt, "GROUP", "COORDINATOR (ID)", "STATE",
"#MEMBERS");
- System.out.printf(fmt, description.groupId(), coordinator,
description.groupState().toString(), description.members().size());
+ String fmt = "%" + -groupLen + "s %" + -coordinatorLen + "s %"
+ -assignorLen + "s %" + -stateLen + "s %s\n";
+ System.out.printf(fmt, "GROUP", "COORDINATOR (ID)",
"ASSIGNOR", "STATE", "#MEMBERS");
+ System.out.printf(fmt, description.groupId(), coordinator,
assignor, description.groupState().toString(), description.members().size());
} else {
final int groupEpochLen = 15, targetAssignmentEpochLen = 25;
- String fmt = "%" + -groupLen + "s %" + -coordinatorLen + "s %"
+ -stateLen + "s %" + -groupEpochLen + "s %" + -targetAssignmentEpochLen + "s
%s\n";
- System.out.printf(fmt, "GROUP", "COORDINATOR (ID)", "STATE",
"GROUP-EPOCH", "TARGET-ASSIGNMENT-EPOCH", "#MEMBERS");
- System.out.printf(fmt, description.groupId(), coordinator,
description.groupState().toString(), description.groupEpoch(),
description.targetAssignmentEpoch(), description.members().size());
+ String fmt = "%" + -groupLen + "s %" + -coordinatorLen + "s %"
+ -assignorLen + "s %" + -stateLen + "s %" + -groupEpochLen + "s %" +
-targetAssignmentEpochLen + "s %s\n";
+ System.out.printf(fmt, "GROUP", "COORDINATOR (ID)",
"ASSIGNOR", "STATE", "GROUP-EPOCH", "TARGET-ASSIGNMENT-EPOCH", "#MEMBERS");
+ System.out.printf(fmt, description.groupId(), coordinator,
assignor, description.groupState().toString(), description.groupEpoch(),
description.targetAssignmentEpoch(), description.members().size());
}
}
diff --git
a/tools/src/test/java/org/apache/kafka/tools/streams/DescribeStreamsGroupTest.java
b/tools/src/test/java/org/apache/kafka/tools/streams/DescribeStreamsGroupTest.java
index 393c468cb62..c2f3878cc7b 100644
---
a/tools/src/test/java/org/apache/kafka/tools/streams/DescribeStreamsGroupTest.java
+++
b/tools/src/test/java/org/apache/kafka/tools/streams/DescribeStreamsGroupTest.java
@@ -160,8 +160,8 @@ public class DescribeStreamsGroupTest {
}
private static void assertDescribeStreamsGroupWithStateOption(String
clusterBootstrapServers) throws Exception {
- final List<String> expectedHeader = List.of("GROUP", "COORDINATOR",
"(ID)", "STATE", "#MEMBERS");
- final Set<List<String>> expectedRows = Set.of(List.of(APP_ID, "", "",
"Stable", "2"));
+ final List<String> expectedHeader = List.of("GROUP", "COORDINATOR",
"(ID)", "ASSIGNOR", "STATE", "#MEMBERS");
+ final Set<List<String>> expectedRows = Set.of(List.of(APP_ID, "", "",
"sticky", "Stable", "2"));
// The coordinator is not deterministic, so we don't care about it.
final List<Integer> dontCares = List.of(1, 2);
@@ -170,11 +170,11 @@ public class DescribeStreamsGroupTest {
}
private static void
assertDescribeStreamsGroupWithStateAndVerboseOptions(String
clusterBootstrapServers) throws Exception {
- final List<String> expectedHeader = List.of("GROUP", "COORDINATOR",
"(ID)", "STATE", "GROUP-EPOCH", "TARGET-ASSIGNMENT-EPOCH", "#MEMBERS");
- final Set<List<String>> expectedRows = Set.of(List.of(APP_ID, "", "",
"Stable", "", "", "2"));
+ final List<String> expectedHeader = List.of("GROUP", "COORDINATOR",
"(ID)", "ASSIGNOR", "STATE", "GROUP-EPOCH", "TARGET-ASSIGNMENT-EPOCH",
"#MEMBERS");
+ final Set<List<String>> expectedRows = Set.of(List.of(APP_ID, "", "",
"sticky", "Stable", "", "", "2"));
// The coordinator is not deterministic, so we don't care about it.
// The GROUP-EPOCH and TARGET-ASSIGNMENT-EPOCH can vary due to
rebalance timing, so we don't care about them either.
- final List<Integer> dontCares = List.of(1, 2, 4, 5);
+ final List<Integer> dontCares = List.of(1, 2, 5, 6);
validateDescribeOutput(
List.of("--bootstrap-server", clusterBootstrapServers,
"--describe", "--state", "--verbose", "--group", APP_ID), expectedHeader,
expectedRows, dontCares);
diff --git
a/tools/src/test/java/org/apache/kafka/tools/streams/StreamsGroupCommandTest.java
b/tools/src/test/java/org/apache/kafka/tools/streams/StreamsGroupCommandTest.java
index 55e2dde048e..9393e841fad 100644
---
a/tools/src/test/java/org/apache/kafka/tools/streams/StreamsGroupCommandTest.java
+++
b/tools/src/test/java/org/apache/kafka/tools/streams/StreamsGroupCommandTest.java
@@ -180,7 +180,10 @@ public class StreamsGroupCommandTest {
List.of(),
GroupState.STABLE,
new Node(0, "bar", 0),
- null);
+ null,
+ Optional.empty(),
+ StreamsGroupTopologyDescriptionStatus.NOT_REQUESTED,
+ Optional.empty());
resultMap.put(firstGroup, exp);
when(result.all()).thenReturn(KafkaFuture.completedFuture(resultMap));
when(ADMIN_CLIENT.describeStreamsGroups(anyCollection(),
any(DescribeStreamsGroupsOptions.class))).thenReturn(result);
@@ -204,7 +207,7 @@ public class StreamsGroupCommandTest {
List.of());
StreamsGroupDescription exp = new StreamsGroupDescription(
group, 0, 0, 0, List.of(), List.of(), GroupState.STABLE, new
Node(0, "bar", 0), null,
- Optional.of(topology),
StreamsGroupTopologyDescriptionStatus.AVAILABLE);
+ Optional.of(topology),
StreamsGroupTopologyDescriptionStatus.AVAILABLE, Optional.empty());
Admin admin = mock(KafkaAdminClient.class);
DescribeStreamsGroupsResult result =
mock(DescribeStreamsGroupsResult.class);
@@ -236,7 +239,7 @@ public class StreamsGroupCommandTest {
String group = "foo-group";
StreamsGroupDescription exp = new StreamsGroupDescription(
group, 0, 0, 0, List.of(), List.of(), GroupState.STABLE, new
Node(0, "bar", 0), null,
- Optional.empty(),
StreamsGroupTopologyDescriptionStatus.NOT_STORED);
+ Optional.empty(),
StreamsGroupTopologyDescriptionStatus.NOT_STORED, Optional.empty());
Admin admin = mock(KafkaAdminClient.class);
DescribeStreamsGroupsResult result =
mock(DescribeStreamsGroupsResult.class);
@@ -298,7 +301,10 @@ public class StreamsGroupCommandTest {
List.of(description),
GroupState.STABLE,
new Node(0, "host", 0),
- null);
+ null,
+ Optional.empty(),
+ StreamsGroupTopologyDescriptionStatus.NOT_REQUESTED,
+ Optional.empty());
StreamsGroupCommandOptions streamsGroupCommandOptions = new
StreamsGroupCommandOptions(
new String[]{"--bootstrap-server", BOOTSTRAP_SERVERS, "--group",
groupId, "--describe"});
@@ -428,7 +434,10 @@ public class StreamsGroupCommandTest {
List.of(),
GroupState.DEAD,
new Node(0, "localhost", 9092),
- null));
+ null,
+ Optional.empty(),
+ StreamsGroupTopologyDescriptionStatus.NOT_REQUESTED,
+ Optional.empty()));
DescribeStreamsGroupsResult result =
mock(DescribeStreamsGroupsResult.class);
when(result.all()).thenReturn(KafkaFuture.completedFuture(resultMap));
when(ADMIN_CLIENT.describeStreamsGroups(anyCollection(),
any(DescribeStreamsGroupsOptions.class))).thenReturn(result);
@@ -602,7 +611,10 @@ public class StreamsGroupCommandTest {
List.of(),
GroupState.STABLE,
new Node(0, "localhost", 9092),
- null
+ null,
+ Optional.empty(),
+ StreamsGroupTopologyDescriptionStatus.NOT_REQUESTED,
+ Optional.empty()
);
when(describeResult.all()).thenReturn(KafkaFuture.completedFuture(Map.of(groupId,
groupDescription)));
when(ADMIN_CLIENT.describeStreamsGroups(eq(List.of(groupId)),
any(DescribeStreamsGroupsOptions.class)))
@@ -673,7 +685,10 @@ public class StreamsGroupCommandTest {
List.of(),
GroupState.STABLE,
new Node(0, "localhost", 9092),
- null
+ null,
+ Optional.empty(),
+ StreamsGroupTopologyDescriptionStatus.NOT_REQUESTED,
+ Optional.empty()
);
when(describeResult.all()).thenReturn(KafkaFuture.completedFuture(Map.of(groupId,
groupDescription)));
when(ADMIN_CLIENT.describeStreamsGroups(eq(List.of(groupId)),
any(DescribeStreamsGroupsOptions.class)))
@@ -753,7 +768,10 @@ public class StreamsGroupCommandTest {
List.of(memberDescription),
groupState,
new Node(1, "localhost", 9092),
- Set.of());
+ Set.of(),
+ Optional.empty(),
+ StreamsGroupTopologyDescriptionStatus.NOT_REQUESTED,
+ Optional.empty());
KafkaFutureImpl<StreamsGroupDescription> future = new
KafkaFutureImpl<>();
future.complete(description);
return new DescribeStreamsGroupsResult(Map.of(groupId, future));