Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ protected Struct toStruct() {
@Override
public AbstractResponse getErrorResponse(int throttleTimeMs, Throwable e) {
Errors error = Errors.forException(e);
Map<String, Errors> groupErrors = new HashMap<>();
Map<String, Errors> groupErrors = new HashMap<>(groups.size());
for (String group : groups)
groupErrors.put(group, error);

Expand All @@ -102,7 +102,7 @@ public AbstractResponse getErrorResponse(int throttleTimeMs, Throwable e) {
return new DeleteGroupsResponse(throttleTimeMs, groupErrors);
default:
throw new IllegalArgumentException(String.format("Version %d is not valid. Valid versions for %s are 0 to %d",
version(), this.getClass().getSimpleName(), ApiKeys.DELETE_GROUPS.latestVersion()));
version(), ApiKeys.DELETE_GROUPS.name, ApiKeys.DELETE_GROUPS.latestVersion()));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ public static Schema[] schemaVersions() {
/**
* Possible error codes:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think NOT_COORDINATOR should also be possible?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. Also COORDINATOR_LOAD_IN_PROGRESS if I'm not mistaken?

*
* COORDINATOR_LOAD_IN_PROGRESS (14)
* COORDINATOR_NOT_AVAILABLE(15)
* NOT_COORDINATOR (16)
* INVALID_GROUP_ID(24)
* GROUP_AUTHORIZATION_FAILED(30)
* NON_EMPTY_GROUP(68)
Expand Down
41 changes: 28 additions & 13 deletions core/src/main/scala/kafka/admin/AdminClient.scala
Original file line number Diff line number Diff line change
Expand Up @@ -370,23 +370,38 @@ class AdminClient(val time: Time,
}

def deleteConsumerGroups(groups: List[String]): Map[String, Errors] = {
var errors: Map[String, Errors] = Map()
val groupsPerCoordinator = groups.map { group =>

def coordinatorLookup(group: String): Either[Node, Errors] = {
try {
(group, findCoordinator(group))
Left(findCoordinator(group))
} catch {
case e: Throwable =>
errors += (group -> {
if (e.isInstanceOf[TimeoutException])
Errors.COORDINATOR_NOT_AVAILABLE
else
Errors.forException(e)
})
(group, null)
if (e.isInstanceOf[TimeoutException])
Right(Errors.COORDINATOR_NOT_AVAILABLE)
else
Right(Errors.forException(e))
}
}.groupBy(_._2).map {
case (coordinator, groupCoordinator) => (coordinator, groupCoordinator.unzip._1.toArray)
}.filter(_._1 != null)
}

val groupCoordinator = groups.map(group => (group -> coordinatorLookup(group)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems this is unused

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unused?


var errors: Map[String, Errors] = Map()
var groupsPerCoordinator: Map[Node, List[String]] = Map()

groups.foreach { group =>
coordinatorLookup(group) match {
case Right(error) =>
errors += (group -> error)
case Left(coordinator) =>
groupsPerCoordinator.get(coordinator) match {
case Some(gList: List[String]) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I don't think you need the type.

val gListNew = group :: gList
groupsPerCoordinator += (coordinator -> gListNew)
case None =>
groupsPerCoordinator += (coordinator -> List(group))
}
}
}

groupsPerCoordinator.foreach { case (coordinator, groups) =>
val responseBody = send(coordinator, ApiKeys.DELETE_GROUPS, new DeleteGroupsRequest.Builder(groups.toSet.asJava))
Expand Down
3 changes: 2 additions & 1 deletion core/src/main/scala/kafka/admin/AdminUtils.scala
Original file line number Diff line number Diff line change
Expand Up @@ -429,9 +429,10 @@ object AdminUtils extends Logging with AdminUtilities {
* @param topic Topic of the consumer group information we wish to delete
*/
@deprecated("This method has been deprecated and will be removed in a future release.", "0.11.0.0")
def deleteAllConsumerGroupInfoForTopicInZK(zkUtils: ZkUtils, topic: String) {
def deleteAllConsumerGroupInfoForTopicInZK(zkUtils: ZkUtils, topic: String): Set[String] = {
val groups = zkUtils.getAllConsumerGroupsForTopic(topic)
groups.foreach(group => deleteConsumerGroupInfoForTopicInZK(zkUtils, group, topic))
groups
}

def topicExists(zkUtils: ZkUtils, topic: String): Boolean =
Expand Down
44 changes: 28 additions & 16 deletions core/src/main/scala/kafka/admin/ConsumerGroupCommand.scala
Original file line number Diff line number Diff line change
Expand Up @@ -362,11 +362,11 @@ object ConsumerGroupCommand extends Logging {

def deleteGroups(): Map[String, Errors] = {
if (opts.options.has(opts.groupOpt) && opts.options.has(opts.topicOpt))
deleteForTopic()
deleteGroupsInfoForTopic()
else if (opts.options.has(opts.groupOpt))
deleteForGroup()
deleteGroupsInfo()
else if (opts.options.has(opts.topicOpt))
deleteAllForTopic()
deleteAllGroupsInfoForTopic()

Map()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a little odd. Maybe to follow the interface, we should try to catch exceptions and return an error?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. I updated this in the new commit.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like you were intending to use the results of the deleteGroupsInfo and such. We should probably have a test case (could be done in a follow-up).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I'll submit a separate PR with proper test(s) after this is merged.

}
Expand Down Expand Up @@ -474,45 +474,57 @@ object ConsumerGroupCommand extends Logging {
}.toMap
}

private def deleteForGroup() {
private def deleteGroupsInfo(): Map[String, Errors] = {
val groups = opts.options.valuesOf(opts.groupOpt)
groups.asScala.foreach { group =>
groups.asScala.map { group =>
try {
if (AdminUtils.deleteConsumerGroupInZK(zkUtils, group))
if (AdminUtils.deleteConsumerGroupInZK(zkUtils, group)) {
println(s"Deleted all consumer group information for group '$group' in zookeeper.")
else
(group -> Errors.NONE)
}
else {
printError(s"Delete for group '$group' failed because its consumers are still active.")
(group -> Errors.NON_EMPTY_GROUP)
}
}
catch {
case e: ZkNoNodeException =>
printError(s"Delete for group '$group' failed because group does not exist.", Some(e))
(group -> Errors.forException(e))
}
}
}.toMap
}

private def deleteForTopic() {
private def deleteGroupsInfoForTopic(): Map[String, Errors] = {
val groups = opts.options.valuesOf(opts.groupOpt)
val topic = opts.options.valueOf(opts.topicOpt)
Topic.validate(topic)
groups.asScala.foreach { group =>
groups.asScala.map { group =>
try {
if (AdminUtils.deleteConsumerGroupInfoForTopicInZK(zkUtils, group, topic))
if (AdminUtils.deleteConsumerGroupInfoForTopicInZK(zkUtils, group, topic)) {
println(s"Deleted consumer group information for group '$group' topic '$topic' in zookeeper.")
else
(group -> Errors.NONE)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: unneeded parenthesis. A few more like this.

}
else {
printError(s"Delete for group '$group' topic '$topic' failed because its consumers are still active.")
(group -> Errors.NON_EMPTY_GROUP)
}
}
catch {
case e: ZkNoNodeException =>
printError(s"Delete for group '$group' topic '$topic' failed because group does not exist.", Some(e))
(group -> Errors.forException(e))
}
}
}.toMap
}

private def deleteAllForTopic() {
private def deleteAllGroupsInfoForTopic(): Map[String, Errors] = {
val topic = opts.options.valueOf(opts.topicOpt)
Topic.validate(topic)
AdminUtils.deleteAllConsumerGroupInfoForTopicInZK(zkUtils, topic)
val deletedGroups = AdminUtils.deleteAllConsumerGroupInfoForTopicInZK(zkUtils, topic)
println(s"Deleted consumer group information for all inactive consumer groups for topic '$topic' in zookeeper.")
deletedGroups.map((_, Errors.NONE)).toMap

}

private def getZkConsumer(brokerId: Int): Option[SimpleConsumer] = {
Expand Down Expand Up @@ -807,7 +819,7 @@ object ConsumerGroupCommand extends Logging {
val groupsToDelete = opts.options.valuesOf(opts.groupOpt).asScala.toList

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm.. It's a little weird that we allow multiple groups to be passed when using the new consumer, but we expect a single group for the old consumer. If we're to stay consistent, do you think it would be restrictive in practice to only support deletion of a single group at a time?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the meantime I'll look at your other feedback (thanks btw) regarding this one, it seems the old consumer also supports deleting multiple groups, i.e. ... --delete --group group1 --group group2 works and attempts to remove both groups.

I originally wanted to support single group deletion only, but after considering the existing behavior for old consumer decided otherwise.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, you are right. The name deleteForGroup is kind of misleading. Maybe it just needs to be pluralized. I wouldn't hate it if we came up with better names for all of these deleteForXXX APIs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I gave this a quick try. Let me know if you have better suggestions.

val result = adminClient.deleteConsumerGroups(groupsToDelete)
val successfullyDeleted = result.filter {
case (group, error) => error == Errors.NONE
case (_, error) => error == Errors.NONE
}.keySet

if (successfullyDeleted.size == result.size)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -543,7 +543,7 @@ class GroupCoordinator(val brokerId: Int,
}

def handleDeletedPartitions(topicPartitions: Seq[TopicPartition]) {
groupManager.cleanupGroupMetadata(Some(topicPartitions))
groupManager.cleanupGroupMetadata(Some(topicPartitions), groupManager.currentGroups, time.milliseconds())
}

private def validateGroup(groupId: String): Option[Errors] = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,19 +192,22 @@ class GroupMetadataManager(brokerId: Int,
var result: Map[String, Errors] = Map()

groupIds.foreach { groupId =>
if (!groupMetadataCache.contains(groupId))
result += (groupId -> Errors.GROUP_ID_NOT_FOUND)
else {
val group = groupMetadataCache.get(groupId)
if (!group.is(Empty))
result += (groupId -> Errors.NON_EMPTY_GROUP)
else
eligibleGroups ++= Seq(group)
getGroup(groupId) match {
case None =>
result += (groupId -> Errors.GROUP_ID_NOT_FOUND)
case Some(group) =>
group.inLock {
if (!group.is(Empty))
result += (groupId -> Errors.NON_EMPTY_GROUP)
else {
eligibleGroups :+= group
cleanupGroupMetadata(None, Seq(group), Long.MaxValue)
}
}
}
}

if (eligibleGroups.nonEmpty) {
cleanupGroupMetadata(None, eligibleGroups, Long.MaxValue)
result ++= eligibleGroups.map(_.groupId -> Errors.NONE).toMap
info(s"The following groups were deleted: ${eligibleGroups.map(_.groupId).mkString(", ")}")
}
Expand Down Expand Up @@ -734,12 +737,12 @@ class GroupMetadataManager(brokerId: Int,

// visible for testing
private[group] def cleanupGroupMetadata(): Unit = {
cleanupGroupMetadata(None)
cleanupGroupMetadata(None, groupMetadataCache.values, time.milliseconds())
}

def cleanupGroupMetadata(deletedTopicPartitions: Option[Seq[TopicPartition]],
groups: Iterable[GroupMetadata] = groupMetadataCache.values,
startMs: Long = time.milliseconds()) {
groups: Iterable[GroupMetadata],
startMs: Long) {
var offsetsRemoved = 0

groups.foreach { group =>
Expand Down
13 changes: 5 additions & 8 deletions core/src/main/scala/kafka/server/KafkaApis.scala
Original file line number Diff line number Diff line change
Expand Up @@ -1224,17 +1224,14 @@ class KafkaApis(val requestChannel: RequestChannel,
def handleDeleteGroupsRequest(request: RequestChannel.Request): Unit = {
val deleteGroupsRequest = request.body[DeleteGroupsRequest]
var groups = deleteGroupsRequest.groups.asScala.toSet
var unauthorizedGroupsDeletionResult: Map[String, Errors] = Map()

groups.foreach { group =>
if (!authorize(request.session, Delete, new Resource(Group, group))) {
unauthorizedGroupsDeletionResult += (group -> Errors.GROUP_AUTHORIZATION_FAILED)
groups -= group
}
val (authorizedGroups, unauthorizedGroups) = groups.partition { group =>
authorize(request.session, Delete, new Resource(Group, group))
}

val authorizedGroupsDeletionResult = groupCoordinator.handleDeleteGroups(groups)
val groupDeletionResult = authorizedGroupsDeletionResult._2 ++ unauthorizedGroupsDeletionResult
val groupDeletionResult = groupCoordinator.handleDeleteGroups(authorizedGroups)._2 ++

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like we are ignoring groupCoordinator.handleDeleteGroups(authorizedGroups)._1 error here.
handleDeleteGroups(authorizedGroups) can return Errors.COORDINATOR_NOT_AVAILABLE.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching this. I missed updating this with the recent change to the protocol. Will update it in the next commit.

unauthorizedGroups.map((_ -> Errors.GROUP_AUTHORIZATION_FAILED))

sendResponseMaybeThrottle(request, requestThrottleMs =>
new DeleteGroupsResponse(requestThrottleMs, groupDeletionResult.asJava))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1319,6 +1319,50 @@ class GroupCoordinatorTest extends JUnitSuite {
assert(result.size == 1 && result.contains(groupId) && result.get(groupId).contains(Errors.NONE))
}

@Test
def testDeleteEmptyGroupWithStoredOffsets() {
val memberId = JoinGroupRequest.UNKNOWN_MEMBER_ID

val joinGroupResult = joinGroup(groupId, memberId, protocolType, protocols)
val assignedMemberId = joinGroupResult.memberId
val joinGroupError = joinGroupResult.error
assertEquals(Errors.NONE, joinGroupError)

EasyMock.reset(replicaManager)
val syncGroupResult = syncGroupLeader(groupId, joinGroupResult.generationId, assignedMemberId, Map(assignedMemberId -> Array[Byte]()))
val syncGroupError = syncGroupResult._2
assertEquals(Errors.NONE, syncGroupError)

EasyMock.reset(replicaManager)
val tp = new TopicPartition("topic", 0)
val offset = OffsetAndMetadata(0)
val commitOffsetResult = commitOffsets(groupId, assignedMemberId, joinGroupResult.generationId, immutable.Map(tp -> offset))
assertEquals(Errors.NONE, commitOffsetResult(tp))

val describeGroupResult = groupCoordinator.handleDescribeGroup(groupId)
assertEquals(Stable.toString, describeGroupResult._2.state)
assertEquals(assignedMemberId, describeGroupResult._2.members.head.memberId)

EasyMock.reset(replicaManager)
val leaveGroupResult = leaveGroup(groupId, assignedMemberId)
assertEquals(Errors.NONE, leaveGroupResult)

val groupTopicPartition = new TopicPartition(Topic.GROUP_METADATA_TOPIC_NAME, groupPartitionId)
val partition = EasyMock.niceMock(classOf[Partition])

EasyMock.reset(replicaManager)
EasyMock.expect(replicaManager.getMagic(EasyMock.anyObject())).andStubReturn(Some(RecordBatch.CURRENT_MAGIC_VALUE))
EasyMock.expect(replicaManager.getPartition(groupTopicPartition)).andStubReturn(Some(partition))
EasyMock.expect(replicaManager.nonOfflinePartition(groupTopicPartition)).andStubReturn(Some(partition))
EasyMock.replay(replicaManager, partition)

val (error, result) = groupCoordinator.handleDeleteGroups(Set(groupId).toSet)
assertEquals(Errors.NONE, error)
assert(result.size == 1 && result.contains(groupId) && result.get(groupId).contains(Errors.NONE))

assertEquals(Dead.toString, groupCoordinator.handleDescribeGroup(groupId)._2.state)
}

@Test
def shouldDelayInitialRebalanceByGroupInitialRebalanceDelayOnEmptyGroup() {
val firstJoinFuture = sendJoinGroup(groupId, JoinGroupRequest.UNKNOWN_MEMBER_ID, protocolType, protocols)
Expand Down