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

lucasbru 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 5b70498523d KAFKA-20623: DeleteGroups extension for streams group 
topology description plugin (3/3) (#22554)
5b70498523d is described below

commit 5b70498523d95932383969d921d71d324e91bbd1
Author: TengYao Chi <[email protected]>
AuthorDate: Thu Jun 18 07:36:09 2026 +0100

    KAFKA-20623: DeleteGroups extension for streams group topology description 
plugin (3/3) (#22554)
    
    JIRA: KAFKA-20623
    This is a part of KIP-1331
    **This PR shouldn't be merge before #22552**
    
    Final of three stacked sub-tasks. The `deleteGroups` flow now calls
    `plugin.deleteTopology` for every streams group with a stored topology
    description before tombstoning. Plugin failure surfaces as
    `GROUP_DELETION_FAILED`  (downgraded to `UNKNOWN_SERVER_ERROR` for
    DeleteGroups v2) and the group is held  back from tombstoning; mixed
    batches are honoured per-group.
    
    ### Pre-tombstone deletion hook on the manager
    `TopologyDescriptionManager.deleteBeforeGroupDelete(topicPartition,
    groupIds)` calls
    `plugin.deleteTopology` in parallel for every group id in the batch that
    is a streams
    group with a non-default `StoredDescriptionTopologyEpoch`. The
    eligibility filter is
    resolved through `scheduleReadOperation` so it sees the same shard
    snapshot used by
    the subsequent tombstone write.
    
    Returns a per-group failure map keyed by group id. Groups absent from
    the map either  had no plugin state to clean up or the plugin call
    succeeded; groups present in the  map carry an `ApiError` describing the
    failure. A read failure (NOT_COORDINATOR etc.)  on the eligibility query
    is treated the same as a plugin failure: every retained  group in the
    batch is held back from tombstoning so the caller can retry.
    
    The manager re-gains the `LogContext` constructor parameter so it can
    emit a  broker-side `WARN` for each plugin failure alongside the
    per-group response error.
    
    ### Service-layer chain extension
    `GroupCoordinatorService.deleteGroups` now splices the deletion hook
    between the
    share-group pre-step and the underlying `delete-groups` write:
    
    1. `deleteShareGroups` filters share-group failures (unchanged).
    2. `topologyDescriptionManager.deleteBeforeGroupDelete` runs for the
    retained ids.
    3. `filterStreamsTopologyErrors` moves per-group plugin failures into
    the response
       collection and returns the subset that should still proceed to
    tombstoning.
    4. `handleDeleteGroups` writes tombstones for the retained subset.
    
    If every retained group fails the plugin call, the underlying
    `delete-groups` write  is skipped entirely. Mixed batches are honoured
    per-group — the successful ids reach  the tombstone write, the failed
    ids carry `GROUP_DELETION_FAILED` in the response,  and an idempotent
    retry of `DeleteGroups` converges once the plugin recovers.
    
    ### v2 downgrade
    Per KIP-1331, `GROUP_DELETION_FAILED` and the per-group `ErrorMessage`
    field were
    introduced at `DeleteGroups` v3. For older clients the broker downgrades
    the error
    code to `UNKNOWN_SERVER_ERROR` with no message — matching the convention
    used by
    KIP-1043 for new error codes that pre-existing request versions cannot
    interpret.
    Errors that predate this KIP (e.g. `NOT_COORDINATOR` surfaced from the
    runtime)
    pass through unchanged on all versions.
    
    ### Coordinator shard + GMM
    
    `GroupMetadataManager.streamsGroupsWithStoredTopologyDescription(groupIds,
    committedOffset)`
    returns the subset of the input that is (a) a streams group on this
    shard and
    (b) carries a non-default `StoredDescriptionTopologyEpoch`. Non-existent
    groups and
    non-streams groups are silently skipped — they have no plugin state to
    clean up.
    `GroupCoordinatorShard` exposes the method to the runtime read-operation
    scheduler.
    
    Reviewers: Lucas Brutschy <[email protected]>
---
 core/src/main/scala/kafka/server/KafkaApis.scala   |  11 +
 .../scala/unit/kafka/server/KafkaApisTest.scala    |  66 ++++++
 .../coordinator/group/GroupCoordinatorService.java | 141 +++++++++++-
 .../coordinator/group/GroupCoordinatorShard.java   |  13 ++
 .../coordinator/group/GroupMetadataManager.java    |  30 +++
 .../StreamsGroupTopologyDescriptionManager.java    |  61 +++++
 ...pCoordinatorServiceTopologyDescriptionTest.java | 255 +++++++++++++++++++++
 7 files changed, 573 insertions(+), 4 deletions(-)

diff --git a/core/src/main/scala/kafka/server/KafkaApis.scala 
b/core/src/main/scala/kafka/server/KafkaApis.scala
index 959e7c76dc3..a3058af66c4 100644
--- a/core/src/main/scala/kafka/server/KafkaApis.scala
+++ b/core/src/main/scala/kafka/server/KafkaApis.scala
@@ -1456,6 +1456,17 @@ class KafkaApis(val requestChannel: RequestChannel,
             .setErrorCode(error.code))
         }
       } else {
+        // GROUP_DELETION_FAILED was introduced for DeleteGroups v3 
(KIP-1331). Older
+        // clients cannot interpret the new code, so downgrade it to 
UNKNOWN_SERVER_ERROR.
+        // ErrorMessage is "versions": "3+", "ignorable": true and is stripped 
at the
+        // serialization layer for v<3, so only the error code needs gating 
here.
+        if (request.context.apiVersion < 3) {
+          results.forEach { result =>
+            if (result.errorCode == Errors.GROUP_DELETION_FAILED.code) {
+              result.setErrorCode(Errors.UNKNOWN_SERVER_ERROR.code)
+            }
+          }
+        }
         response.setResults(results)
       }
 
diff --git a/core/src/test/scala/unit/kafka/server/KafkaApisTest.scala 
b/core/src/test/scala/unit/kafka/server/KafkaApisTest.scala
index 701f2db0df0..e9a196118f2 100644
--- a/core/src/test/scala/unit/kafka/server/KafkaApisTest.scala
+++ b/core/src/test/scala/unit/kafka/server/KafkaApisTest.scala
@@ -3633,6 +3633,72 @@ class KafkaApisTest extends Logging {
     assertEquals(expectedDeleteGroupsResponse, response.data)
   }
 
+  @Test
+  def testHandleDeleteGroupsDowngradesGroupDeletionFailedForOldClients(): Unit 
= {
+    // GROUP_DELETION_FAILED was introduced for DeleteGroups v3 (KIP-1331). 
For v<3 clients
+    // KafkaApis must downgrade it to UNKNOWN_SERVER_ERROR so the client can 
interpret the
+    // code; the ErrorMessage field is "versions": "3+", "ignorable": true and 
is stripped
+    // by the serialization layer for v<3, so this layer does not need to 
clear it.
+    val deleteGroupsRequest = new 
DeleteGroupsRequestData().setGroupsNames(util.List.of("group-1"))
+    val requestChannelRequest = buildRequest(new 
DeleteGroupsRequest.Builder(deleteGroupsRequest).build(2))
+
+    val future = new 
CompletableFuture[DeleteGroupsResponseData.DeletableGroupResultCollection]()
+    when(groupCoordinator.deleteGroups(
+      requestChannelRequest.context,
+      util.List.of("group-1"),
+      RequestLocal.noCaching.bufferSupplier
+    )).thenReturn(future)
+    kafkaApis = createKafkaApis()
+    kafkaApis.handleDeleteGroupsRequest(
+      requestChannelRequest,
+      RequestLocal.noCaching
+    )
+
+    future.complete(new 
DeleteGroupsResponseData.DeletableGroupResultCollection(util.List.of(
+      new DeleteGroupsResponseData.DeletableGroupResult()
+        .setGroupId("group-1")
+        .setErrorCode(Errors.GROUP_DELETION_FAILED.code)
+        .setErrorMessage("plugin offline")
+    ).iterator))
+
+    val response = 
verifyNoThrottling[DeleteGroupsResponse](requestChannelRequest)
+    val result = response.data.results.find("group-1")
+    assertNotNull(result)
+    assertEquals(Errors.UNKNOWN_SERVER_ERROR.code, result.errorCode)
+  }
+
+  @Test
+  def testHandleDeleteGroupsKeepsGroupDeletionFailedForV3Clients(): Unit = {
+    // v3+ clients understand GROUP_DELETION_FAILED, so no downgrade.
+    val deleteGroupsRequest = new 
DeleteGroupsRequestData().setGroupsNames(util.List.of("group-1"))
+    val requestChannelRequest = buildRequest(new 
DeleteGroupsRequest.Builder(deleteGroupsRequest).build(3))
+
+    val future = new 
CompletableFuture[DeleteGroupsResponseData.DeletableGroupResultCollection]()
+    when(groupCoordinator.deleteGroups(
+      requestChannelRequest.context,
+      util.List.of("group-1"),
+      RequestLocal.noCaching.bufferSupplier
+    )).thenReturn(future)
+    kafkaApis = createKafkaApis()
+    kafkaApis.handleDeleteGroupsRequest(
+      requestChannelRequest,
+      RequestLocal.noCaching
+    )
+
+    future.complete(new 
DeleteGroupsResponseData.DeletableGroupResultCollection(util.List.of(
+      new DeleteGroupsResponseData.DeletableGroupResult()
+        .setGroupId("group-1")
+        .setErrorCode(Errors.GROUP_DELETION_FAILED.code)
+        .setErrorMessage("plugin offline")
+    ).iterator))
+
+    val response = 
verifyNoThrottling[DeleteGroupsResponse](requestChannelRequest)
+    val result = response.data.results.find("group-1")
+    assertNotNull(result)
+    assertEquals(Errors.GROUP_DELETION_FAILED.code, result.errorCode)
+    assertEquals("plugin offline", result.errorMessage)
+  }
+
   @Test
   def testHandleDeleteGroupsFutureFailed(): Unit = {
     val deleteGroupsRequest = new 
DeleteGroupsRequestData().setGroupsNames(util.List.of(
diff --git 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorService.java
 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorService.java
index 1f78c0b401f..2708b7b261b 100644
--- 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorService.java
+++ 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorService.java
@@ -20,6 +20,8 @@ import org.apache.kafka.common.TopicPartition;
 import org.apache.kafka.common.Uuid;
 import org.apache.kafka.common.compress.Compression;
 import org.apache.kafka.common.config.TopicConfig;
+import org.apache.kafka.common.errors.CoordinatorLoadInProgressException;
+import org.apache.kafka.common.errors.CoordinatorNotAvailableException;
 import org.apache.kafka.common.errors.GroupIdNotFoundException;
 import org.apache.kafka.common.errors.InvalidRequestException;
 import org.apache.kafka.common.errors.NotCoordinatorException;
@@ -795,12 +797,24 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
         if (throwable == null) {
             return new SetTopologyOutcome(responseOnSuccess, 
BackoffAction.CLEAR, null);
         }
+        Throwable cause = Errors.maybeUnwrapException(throwable);
         // The group was deleted between the plugin call and the bookkeeping 
write — the
         // push already took effect at the plugin and no live group remains to 
throttle,
         // so drop the orphaned back-off entry instead of arming one nobody 
will clear.
-        if (Errors.maybeUnwrapException(throwable) instanceof 
GroupIdNotFoundException) {
+        if (cause instanceof GroupIdNotFoundException) {
             return new SetTopologyOutcome(null, BackoffAction.CLEAR_GROUP, 
throwable);
         }
+        // This broker stopped being the coordinator between the plugin call 
and the
+        // bookkeeping write. The client will retry against the new 
coordinator, which
+        // holds no back-off entry of its own; arming a broker-wide entry here 
would
+        // leak until expiry and could suppress a legitimate solicitation if 
the group
+        // ever migrates back. The retry that lands on the new coordinator is 
what
+        // re-establishes convergence, not a back-off window on the broker 
that bowed out.
+        if (cause instanceof NotCoordinatorException
+            || cause instanceof CoordinatorLoadInProgressException
+            || cause instanceof CoordinatorNotAvailableException) {
+            return new SetTopologyOutcome(null, BackoffAction.NOOP, throwable);
+        }
         return new SetTopologyOutcome(null, BackoffAction.ARM, throwable);
     }
 
@@ -1627,9 +1641,42 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
                     return 
CompletableFuture.completedFuture(deletableGroupResults);
                 }
 
-                return handleDeleteGroups(context, topicPartition, 
retainedGroupIds)
-                    .whenComplete((resp, __) -> resp.forEach(result -> 
deletableGroupResults.add(result.duplicate())))
-                    .thenApply(__ -> deletableGroupResults);
+                return runPrePluginDelete(topicPartition, retainedGroupIds)
+                    .thenCompose(streamsErrMap -> {
+                        List<String> afterStreams = 
filterStreamsTopologyErrors(
+                            streamsErrMap, retainedGroupIds, 
deletableGroupResults);
+                        if (afterStreams.isEmpty()) {
+                            return 
CompletableFuture.completedFuture(deletableGroupResults);
+                        }
+                        return handleDeleteGroups(context, topicPartition, 
afterStreams)
+                            .whenComplete((resp, __) -> resp.forEach(result -> 
deletableGroupResults.add(result.duplicate())))
+                            .thenApply(__ -> deletableGroupResults);
+                    })
+                    .exceptionally(exception -> {
+                        // Defensive net for any uncaught synchronous throw in 
the
+                        // post-runPrePluginDelete stage. Without this, the 
exception would
+                        // propagate through FutureUtils.combineFutures.join() 
and fail the
+                        // whole cross-partition DeleteGroups response — 
including groups on
+                        // other partitions that already succeeded. Runtime 
read failures
+                        // inside runPrePluginDelete are absorbed there, so 
they never reach
+                        // this branch; what we are catching here is the 
synchronous stages
+                        // (filterStreamsTopologyErrors etc.). Fold the 
exception into
+                        // per-group failures for any retainedGroupIds not yet 
recorded.
+                        ApiError apiError = ApiError.fromThrowable(exception);
+                        Set<String> recorded = new HashSet<>();
+                        deletableGroupResults.forEach(result -> 
recorded.add(result.groupId()));
+                        for (String groupId : retainedGroupIds) {
+                            if (!recorded.contains(groupId)) {
+                                deletableGroupResults.add(
+                                    new 
DeleteGroupsResponseData.DeletableGroupResult()
+                                        .setGroupId(groupId)
+                                        .setErrorCode(apiError.error().code())
+                                        .setErrorMessage(apiError.message())
+                                );
+                            }
+                        }
+                        return deletableGroupResults;
+                    });
             });
             // deleteShareGroups has its own exceptionally block, so we don't 
need one here.
 
@@ -1688,6 +1735,92 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
         return groupSet.stream().toList();
     }
 
+    /**
+     * Drive the topology-description plugin's pre-delete flow: identify which 
of the
+     * supplied group ids carry a stored topology description, call
+     * {@code plugin.deleteTopology} for each, and drop the corresponding 
back-off entries.
+     *
+     * <p>Short-circuits with an empty failure map when no plugin is 
configured, so a
+     * broker with the feature disabled does not hit the runtime for a 
per-partition read.
+     * The chain mirrors the structure of {@code 
streamsGroupTopologyDescriptionUpdate}:
+     * the manager exposes pure plugin invocation ({@code 
invokeDeleteTopologies}) plus
+     * a back-off mutation ({@code clearBackoffGroup}), and this service 
helper assembles
+     * the runtime read, the plugin call, and the back-off cleanup into one 
future.
+     *
+     * <p>Runtime read failures (e.g. {@code NOT_COORDINATOR}) are folded back 
into the
+     * same per-group failure map so the caller can report them uniformly; we 
deliberately
+     * pass through the more specific runtime error rather than collapsing 
everything to
+     * {@code GROUP_DELETION_FAILED}.
+     */
+    private CompletableFuture<Map<String, ApiError>> runPrePluginDelete(
+        TopicPartition topicPartition,
+        List<String> groupIds
+    ) {
+        if (!streamsGroupTopologyDescriptionManager.isPluginConfigured()) {
+            return CompletableFuture.completedFuture(Map.of());
+        }
+        return runtime.scheduleReadOperation(
+                "streams-group-topology-pre-delete",
+                topicPartition,
+                (coordinator, lastCommittedOffset) ->
+                    
coordinator.streamsGroupsWithStoredTopologyDescription(groupIds, 
lastCommittedOffset))
+            .thenCompose(groupsWithStored ->
+                
streamsGroupTopologyDescriptionManager.invokeDeleteTopologies(groupsWithStored)
+                    .thenApply(failures -> {
+                        // Clear back-off entries for every group whose plugin 
state we
+                        // attempted to delete (regardless of plugin outcome): 
the group is
+                        // about to be tombstoned on success and re-evaluated 
by the next
+                        // heartbeat on failure, so any in-flight back-off 
entry at the old
+                        // epoch is no longer load-bearing.
+                        
groupsWithStored.forEach(streamsGroupTopologyDescriptionManager::clearBackoffGroup);
+                        return failures;
+                    }))
+            .exceptionally(exception -> {
+                // Use ApiError.fromThrowable so the error reported to the 
client matches what
+                // the sibling DeleteGroups paths return: it unwraps 
CompletionException so the
+                // ErrorMessage is the cause's own message rather than the 
FQCN-prefixed
+                // toString(), and it suppresses the message for 
UNKNOWN_SERVER_ERROR so we do
+                // not leak plugin internals.
+                ApiError apiError = ApiError.fromThrowable(exception);
+                Map<String, ApiError> failures = new HashMap<>();
+                groupIds.forEach(id -> failures.put(id, apiError));
+                return failures;
+            });
+    }
+
+    /**
+     * Move plugin failures into {@code deletableGroupResults} and return the 
group ids
+     * that should still proceed to tombstoning. Version-agnostic: the raw 
{@link ApiError}
+     * is added as-is; any per-version translation of new error codes (e.g. 
downgrading
+     * {@code GROUP_DELETION_FAILED} for {@code DeleteGroups} v&lt;3) happens 
at the
+     * {@code KafkaApis} layer where {@code request.context.apiVersion()} is 
in scope and
+     * matches how other new error codes are version-gated.
+     */
+    private static List<String> filterStreamsTopologyErrors(
+        Map<String, ApiError> streamsErrMap,
+        List<String> groupIds,
+        DeleteGroupsResponseData.DeletableGroupResultCollection 
deletableGroupResults
+    ) {
+        if (streamsErrMap.isEmpty()) {
+            return groupIds;
+        }
+        List<String> retained = new ArrayList<>();
+        for (String groupId : groupIds) {
+            ApiError err = streamsErrMap.get(groupId);
+            if (err == null) {
+                retained.add(groupId);
+            } else {
+                deletableGroupResults.add(
+                    new DeleteGroupsResponseData.DeletableGroupResult()
+                        .setGroupId(groupId)
+                        .setErrorCode(err.error().code())
+                        .setErrorMessage(err.message())
+                );
+            }
+        }
+        return retained;
+    }
+
     private 
CompletableFuture<DeleteGroupsResponseData.DeletableGroupResultCollection> 
handleDeleteGroups(
         AuthorizableRequestContext context,
         TopicPartition topicPartition,
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 ed25f77c158..d449f4b18f2 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
@@ -132,6 +132,7 @@ import org.apache.kafka.timeline.SnapshotRegistry;
 import org.slf4j.Logger;
 
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -958,6 +959,18 @@ public class GroupCoordinatorShard implements 
CoordinatorShard<CoordinatorRecord
         return 
groupMetadataManager.streamsGroupSetTopologyDescriptionEpoch(groupId, 
pushedEpoch, permanentFailure);
     }
 
+    /**
+     * Return the subset of {@code groupIds} that are streams groups with a 
stored topology
+     * description. Used during {@code DeleteGroups} to identify which groups 
need a
+     * {@code plugin.deleteTopology} call before being tombstoned.
+     */
+    public Set<String> streamsGroupsWithStoredTopologyDescription(
+        Collection<String> groupIds,
+        long committedOffset
+    ) {
+        return 
groupMetadataManager.streamsGroupsWithStoredTopologyDescription(groupIds, 
committedOffset);
+    }
+
     /**
      * Handles a ShareGroupDescribe request.
      *
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 0c42f200529..5a32fa8f1cb 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
@@ -182,6 +182,7 @@ import org.slf4j.Logger;
 
 import java.nio.ByteBuffer;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -8390,6 +8391,35 @@ public class GroupMetadataManager {
         return member;
     }
 
+    /**
+     * Filter the given group ids down to those that are streams groups with a 
non-default
+     * {@code StoredDescriptionTopologyEpoch}. The result is used by {@code 
DeleteGroups}
+     * to decide which groups warrant a {@code plugin.deleteTopology} call 
before the
+     * group is tombstoned. Non-existent groups and non-streams groups are 
silently
+     * skipped — they have no plugin state to clean up.
+     *
+     * @param groupIds        Candidate group ids on this shard.
+     * @param committedOffset A committed offset corresponding to the desired 
snapshot.
+     * @return The subset of {@code groupIds} that are streams groups with a 
stored topology.
+     */
+    public Set<String> streamsGroupsWithStoredTopologyDescription(
+        Collection<String> groupIds,
+        long committedOffset
+    ) {
+        Set<String> withStored = new HashSet<>();
+        for (String groupId : groupIds) {
+            try {
+                StreamsGroup group = streamsGroup(groupId, committedOffset);
+                if (group.storedDescriptionTopologyEpoch(committedOffset) != 
-1) {
+                    withStored.add(groupId);
+                }
+            } catch (GroupIdNotFoundException ignored) {
+                // Not a streams group on this shard; nothing to clean up.
+            }
+        }
+        return withStored;
+    }
+
     /**
      * Persist the outcome of a topology description plugin call for a streams 
group.
      *
diff --git 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionManager.java
 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionManager.java
index bf6f62dc804..9b52a1506d3 100644
--- 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionManager.java
+++ 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionManager.java
@@ -18,14 +18,20 @@ package org.apache.kafka.coordinator.group.streams;
 
 import org.apache.kafka.common.message.StreamsGroupHeartbeatResponseData;
 import org.apache.kafka.common.protocol.Errors;
+import org.apache.kafka.common.requests.ApiError;
 import org.apache.kafka.common.requests.StreamsGroupHeartbeatResponse.Status;
 import org.apache.kafka.common.utils.Time;
 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.StreamsTopologyDescriptionPermanentFailureException;
 
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 
 /**
@@ -198,6 +204,61 @@ public class StreamsGroupTopologyDescriptionManager 
implements AutoCloseable {
         backoff.clearGroup(groupId);
     }
 
+    /**
+     * Call {@code plugin.deleteTopology} for every supplied group id. Returns 
a per-group
+     * map of failures keyed by group id; groups absent from the map either 
had no plugin
+     * configured or the plugin call succeeded. The returned future never 
completes
+     * exceptionally — failures are folded into the map so the service-level
+     * {@code DeleteGroups} flow can dispatch on the per-group outcome without 
try/catch
+     * on the underlying future. A synchronous throw from the plugin (which 
violates the
+     * SPI contract) is mapped to the same {@code GROUP_DELETION_FAILED} as an
+     * exceptional future.
+     *
+     * <p>Pure plugin invocation: does not read group state and does not touch 
the
+     * back-off map. The service layer pre-filters the input via
+     * {@code streamsGroupsWithStoredTopologyDescription} and is responsible 
for invoking
+     * {@link #clearBackoffGroup} for the groups that were attempted.
+     */
+    public CompletableFuture<Map<String, ApiError>> 
invokeDeleteTopologies(Set<String> groupIds) {
+        if (plugin.isEmpty() || groupIds.isEmpty()) {
+            return CompletableFuture.completedFuture(Map.of());
+        }
+        final StreamsGroupTopologyDescriptionPlugin p = plugin.get();
+        List<CompletableFuture<Map.Entry<String, ApiError>>> outcomes = new 
ArrayList<>(groupIds.size());
+        for (String groupId : groupIds) {
+            CompletableFuture<Map.Entry<String, ApiError>> outcome;
+            try {
+                outcome = p.deleteTopology(groupId).handle((unused, throwable) 
-> toFailureEntry(groupId, throwable));
+            } catch (Exception e) {
+                // Synchronous throw from the plugin violates the SPI 
contract; treat it as
+                // any other per-group failure so the failures map carries it 
back to the
+                // caller without dropping the rest of the batch.
+                outcome = 
CompletableFuture.completedFuture(toFailureEntry(groupId, e));
+            }
+            outcomes.add(outcome);
+        }
+        CompletableFuture<?>[] all = outcomes.toArray(new 
CompletableFuture<?>[0]);
+        return CompletableFuture.allOf(all).thenApply(unused -> {
+            Map<String, ApiError> failures = new HashMap<>();
+            for (CompletableFuture<Map.Entry<String, ApiError>> future : 
outcomes) {
+                Map.Entry<String, ApiError> entry = future.join();
+                if (entry != null) {
+                    failures.put(entry.getKey(), entry.getValue());
+                }
+            }
+            return failures;
+        });
+    }
+
+    private static Map.Entry<String, ApiError> toFailureEntry(String groupId, 
Throwable throwable) {
+        if (throwable == null) {
+            return null;
+        }
+        Throwable cause = Errors.maybeUnwrapException(throwable);
+        String message = cause != null ? cause.getMessage() : "Plugin failure 
(no cause).";
+        return Map.entry(groupId, new ApiError(Errors.GROUP_DELETION_FAILED, 
message));
+    }
+
     // Visible for testing.
     StreamsGroupTopologyDescriptionBackoff backoff() {
         return backoff;
diff --git 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorServiceTopologyDescriptionTest.java
 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorServiceTopologyDescriptionTest.java
index da4c4ae6591..20ac95683b6 100644
--- 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorServiceTopologyDescriptionTest.java
+++ 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorServiceTopologyDescriptionTest.java
@@ -19,8 +19,10 @@ package org.apache.kafka.coordinator.group;
 import org.apache.kafka.common.TopicPartition;
 import org.apache.kafka.common.Uuid;
 import org.apache.kafka.common.errors.GroupIdNotFoundException;
+import org.apache.kafka.common.errors.NotCoordinatorException;
 import org.apache.kafka.common.errors.UnknownMemberIdException;
 import org.apache.kafka.common.internals.Topic;
+import org.apache.kafka.common.message.DeleteGroupsResponseData;
 import org.apache.kafka.common.message.StreamsGroupHeartbeatRequestData;
 import org.apache.kafka.common.message.StreamsGroupHeartbeatResponseData;
 import 
org.apache.kafka.common.message.StreamsGroupTopologyDescriptionUpdateRequestData;
@@ -28,6 +30,7 @@ import 
org.apache.kafka.common.message.StreamsGroupTopologyDescriptionUpdateResp
 import org.apache.kafka.common.protocol.ApiKeys;
 import org.apache.kafka.common.protocol.Errors;
 import org.apache.kafka.common.utils.MockTime;
+import org.apache.kafka.common.utils.internals.BufferSupplier;
 import org.apache.kafka.common.utils.internals.LogContext;
 import org.apache.kafka.coordinator.common.runtime.CoordinatorRecord;
 import org.apache.kafka.coordinator.common.runtime.CoordinatorRuntime;
@@ -44,6 +47,7 @@ import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.CompletionException;
 import java.util.concurrent.TimeUnit;
@@ -54,6 +58,7 @@ import static 
org.apache.kafka.coordinator.group.GroupConfigManagerTest.createCo
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyInt;
@@ -360,6 +365,47 @@ public class 
GroupCoordinatorServiceTopologyDescriptionTest {
         assertTrue(heartbeatTopologyDescriptionRequired(runtime, service, 3, 
-1, -1));
     }
 
+    @Test
+    public void testUpdatePostPluginWriteRoutingFailureDoesNotArmBackoff() 
throws Exception {
+        // The plugin succeeds, but between the plugin call and the 
bookkeeping write this
+        // broker stops being the coordinator (NotCoordinatorException 
surfaces from the
+        // write). The client will retry against the new coordinator, which 
holds no
+        // back-off entry of its own; arming a broker-wide entry on this 
broker would leak
+        // until expiry and could later suppress a legitimate solicitation if 
the group
+        // migrates back. CoordinatorLoadInProgressException and 
CoordinatorNotAvailableException
+        // travel the same NOOP branch — covered by one representative case to 
avoid
+        // parameterized-test scaffolding.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        when(plugin.setTopology(anyString(), anyInt(), any()))
+            .thenReturn(CompletableFuture.completedFuture(null));
+        when(runtime.scheduleReadOperation(
+            eq("streams-group-topology-description-validate"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(null));
+        when(runtime.scheduleWriteOperation(
+            eq("streams-group-set-stored-topology-epoch"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.failedFuture(
+            new NotCoordinatorException("Lost coordinator status between 
plugin success and bookkeeping write.")));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+
+        StreamsGroupTopologyDescriptionUpdateResponseData response = 
service.streamsGroupTopologyDescriptionUpdate(
+            requestContext(ApiKeys.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_UPDATE),
+            validUpdateRequest()
+        ).get(5, TimeUnit.SECONDS);
+
+        assertEquals(Errors.NOT_COORDINATOR.code(), response.errorCode());
+        verify(plugin, times(1)).setTopology(eq("foo"), eq(3), any());
+
+        // Back-off must be untouched so the new coordinator (or this broker 
after a
+        // migration back) can still solicit a fresh push at the same epoch.
+        assertTrue(heartbeatTopologyDescriptionRequired(runtime, service, 3, 
-1, -1));
+    }
+
     @Test
     public void 
testUpdateGroupDisappearsBetweenPluginSuccessAndWriteDropsBackoffEntry() throws 
Exception {
         // KIP-1331 race: the plugin succeeds, then the group is deleted, then 
the bookkeeping
@@ -634,6 +680,215 @@ public class 
GroupCoordinatorServiceTopologyDescriptionTest {
         verify(plugin, times(1)).close();
     }
 
+    @Test
+    public void testDeleteGroupsPluginFailureReturnsGroupDeletionFailed() 
throws Exception {
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        when(plugin.deleteTopology("foo"))
+            .thenReturn(CompletableFuture.failedFuture(new 
RuntimeException("plugin offline")));
+
+        when(runtime.scheduleWriteOperation(
+            eq("delete-share-groups"),
+            any(),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(Map.of()));
+        when(runtime.scheduleReadOperation(
+            eq("streams-group-topology-pre-delete"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(Set.of("foo")));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+
+        DeleteGroupsResponseData.DeletableGroupResultCollection results =
+            service.deleteGroups(
+                requestContext(ApiKeys.DELETE_GROUPS),
+                List.of("foo"),
+                BufferSupplier.NO_CACHING
+            ).get(5, TimeUnit.SECONDS);
+
+        DeleteGroupsResponseData.DeletableGroupResult result = 
results.find("foo");
+        assertNotNull(result);
+        assertEquals(Errors.GROUP_DELETION_FAILED.code(), result.errorCode());
+        assertEquals("plugin offline", result.errorMessage());
+        verify(runtime, never()).scheduleWriteOperation(
+            eq("delete-groups"), any(), any());
+    }
+
+    @Test
+    public void testDeleteGroupsPluginSuccessProceedsToTombstone() throws 
Exception {
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        when(plugin.deleteTopology("foo"))
+            .thenReturn(CompletableFuture.completedFuture(null));
+
+        when(runtime.scheduleWriteOperation(
+            eq("delete-share-groups"),
+            any(),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(Map.of()));
+        when(runtime.scheduleReadOperation(
+            eq("streams-group-topology-pre-delete"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(Set.of("foo")));
+
+        DeleteGroupsResponseData.DeletableGroupResultCollection 
tombstoneResult =
+            new DeleteGroupsResponseData.DeletableGroupResultCollection();
+        tombstoneResult.add(new 
DeleteGroupsResponseData.DeletableGroupResult().setGroupId("foo"));
+        when(runtime.scheduleWriteOperation(
+            eq("delete-groups"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(tombstoneResult));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+
+        DeleteGroupsResponseData.DeletableGroupResultCollection results =
+            service.deleteGroups(
+                requestContext(ApiKeys.DELETE_GROUPS),
+                List.of("foo"),
+                BufferSupplier.NO_CACHING
+            ).get(5, TimeUnit.SECONDS);
+
+        DeleteGroupsResponseData.DeletableGroupResult result = 
results.find("foo");
+        assertNotNull(result);
+        assertEquals(Errors.NONE.code(), result.errorCode());
+        assertNull(result.errorMessage());
+        verify(plugin, times(1)).deleteTopology("foo");
+        verify(runtime, times(1)).scheduleWriteOperation(
+            eq("delete-groups"), eq(GROUP_TP), any());
+    }
+
+    @Test
+    public void testDeleteGroupsWithoutPluginSkipsPluginCall() throws 
Exception {
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        when(runtime.scheduleWriteOperation(
+            eq("delete-share-groups"),
+            any(),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(Map.of()));
+
+        DeleteGroupsResponseData.DeletableGroupResultCollection 
tombstoneResult =
+            new DeleteGroupsResponseData.DeletableGroupResultCollection();
+        tombstoneResult.add(new 
DeleteGroupsResponseData.DeletableGroupResult().setGroupId("foo"));
+        when(runtime.scheduleWriteOperation(
+            eq("delete-groups"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(tombstoneResult));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.empty(), true);
+
+        DeleteGroupsResponseData.DeletableGroupResultCollection results =
+            service.deleteGroups(
+                requestContext(ApiKeys.DELETE_GROUPS),
+                List.of("foo"),
+                BufferSupplier.NO_CACHING
+            ).get(5, TimeUnit.SECONDS);
+
+        DeleteGroupsResponseData.DeletableGroupResult result = 
results.find("foo");
+        assertNotNull(result);
+        assertEquals(Errors.NONE.code(), result.errorCode());
+        verify(runtime, never()).scheduleReadOperation(
+            eq("streams-group-topology-pre-delete"), any(), any());
+        verify(runtime, times(1)).scheduleWriteOperation(
+            eq("delete-groups"), eq(GROUP_TP), any());
+    }
+
+    @Test
+    public void testDeleteGroupsSkipsPluginCallWhenNoStoredTopology() throws 
Exception {
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+
+        when(runtime.scheduleWriteOperation(
+            eq("delete-share-groups"),
+            any(),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(Map.of()));
+        when(runtime.scheduleReadOperation(
+            eq("streams-group-topology-pre-delete"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(Set.of()));
+
+        DeleteGroupsResponseData.DeletableGroupResultCollection 
tombstoneResult =
+            new DeleteGroupsResponseData.DeletableGroupResultCollection();
+        tombstoneResult.add(new 
DeleteGroupsResponseData.DeletableGroupResult().setGroupId("foo"));
+        when(runtime.scheduleWriteOperation(
+            eq("delete-groups"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(tombstoneResult));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+
+        DeleteGroupsResponseData.DeletableGroupResultCollection results =
+            service.deleteGroups(
+                requestContext(ApiKeys.DELETE_GROUPS),
+                List.of("foo"),
+                BufferSupplier.NO_CACHING
+            ).get(5, TimeUnit.SECONDS);
+
+        DeleteGroupsResponseData.DeletableGroupResult result = 
results.find("foo");
+        assertNotNull(result);
+        assertEquals(Errors.NONE.code(), result.errorCode());
+        verify(plugin, never()).deleteTopology(anyString());
+        verify(runtime, times(1)).scheduleWriteOperation(
+            eq("delete-groups"), eq(GROUP_TP), any());
+    }
+
+    @Test
+    public void testDeleteGroupsMixedPluginOutcome() throws Exception {
+        // Two streams groups on the same partition; plugin succeeds for 
"good", fails for "bad".
+        // Only "good" should reach the underlying delete-groups write; "bad" 
surfaces as
+        // GROUP_DELETION_FAILED in the response.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        when(plugin.deleteTopology("good"))
+            .thenReturn(CompletableFuture.completedFuture(null));
+        when(plugin.deleteTopology("bad"))
+            .thenReturn(CompletableFuture.failedFuture(new 
RuntimeException("rejected")));
+
+        when(runtime.scheduleWriteOperation(
+            eq("delete-share-groups"),
+            any(),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(Map.of()));
+        when(runtime.scheduleReadOperation(
+            eq("streams-group-topology-pre-delete"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(Set.of("good", 
"bad")));
+
+        DeleteGroupsResponseData.DeletableGroupResultCollection 
tombstoneResult =
+            new DeleteGroupsResponseData.DeletableGroupResultCollection();
+        tombstoneResult.add(new 
DeleteGroupsResponseData.DeletableGroupResult().setGroupId("good"));
+        when(runtime.scheduleWriteOperation(
+            eq("delete-groups"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(tombstoneResult));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+
+        DeleteGroupsResponseData.DeletableGroupResultCollection results =
+            service.deleteGroups(
+                requestContext(ApiKeys.DELETE_GROUPS),
+                List.of("good", "bad"),
+                BufferSupplier.NO_CACHING
+            ).get(5, TimeUnit.SECONDS);
+
+        DeleteGroupsResponseData.DeletableGroupResult goodResult = 
results.find("good");
+        assertNotNull(goodResult);
+        assertEquals(Errors.NONE.code(), goodResult.errorCode());
+
+        DeleteGroupsResponseData.DeletableGroupResult badResult = 
results.find("bad");
+        assertNotNull(badResult);
+        assertEquals(Errors.GROUP_DELETION_FAILED.code(), 
badResult.errorCode());
+        assertEquals("rejected", badResult.errorMessage());
+    }
+
     private static StreamsGroupTopologyDescriptionUpdateRequestData 
validUpdateRequest() {
         return new StreamsGroupTopologyDescriptionUpdateRequestData()
             .setGroupId("foo")


Reply via email to