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 e835bc4e3ea KAFKA-20623: Follow-up cleanups for the streams group 
topology description plugin (#22616)
e835bc4e3ea is described below

commit e835bc4e3eadde40d5423bccae0054dbb199f4a8
Author: Lucas Brutschy <[email protected]>
AuthorDate: Thu Jun 18 16:58:26 2026 +0200

    KAFKA-20623: Follow-up cleanups for the streams group topology description 
plugin (#22616)
    
    Follow-up cleanups for the streams group topology description plugin
    (KIP-1331), addressing review feedback from the 2/3 and 3/3 PRs (#22552,
    #22554) that was deferred at merge time.
    
    The DeleteGroups pre-delete path is hardened. A failure of the streams
    topology pre-delete read now goes through handleOperationException, so
    it reports the same translated, retriable error codes as the rest of the
    delete pipeline rather than a raw code, and the plugin's own failure
    message is no longer forwarded to the client. The stored and failed
    topology epochs are only ever advanced, never regressed, which closes a
    window where a slow push committing after the group's epoch advanced —
    or after a concurrent higher-epoch push — could move them backwards.
    
    The push RPC handler is simplified. The back-off disposition is now
    acted on where it is known rather than threaded through the future chain
    as a value object, which removes the SetTopologyOutcome, BackoffAction,
    applySetTopologyBackoff and outcomeForPostPluginWrite machinery; the
    post-write back-off handling lives on
    StreamsGroupTopologyDescriptionManager next to the back-off it mutates,
    while the runtime chain stays in GroupCoordinatorService. Suppressing
    solicitation for a departing member moves out of GroupMetadataManager,
    which previously built a sentinel result, into the service-layer gate
    that already owns the solicitation policy, so leave and fence results
    carry the group's real epochs like any other heartbeat.
    
    Smaller cleanups round it out: the per-group back-off uses
    ConcurrentHashMap.compute consistently rather than a hand-rolled
    compare-and-swap loop, the pre-delete group filter uses a non-throwing
    lookup instead of catching an exception per non-streams group, and the
    boolean-flag epoch-write method is split into named methods.
    
    One known limitation is intentionally left for a separate follow-up: the
    broker-local back-off map is only evicted on DeleteGroups, so groups
    removed by session expiry, partition unload, or tombstone-via-replay
    retain their entry until the group id is reused.
    
    Reviewers: TengYao Chi <[email protected]>
---
 .../coordinator/group/GroupCoordinatorService.java | 170 +++++++--------------
 .../coordinator/group/GroupCoordinatorShard.java   |  31 ++--
 .../coordinator/group/GroupMetadataManager.java    |  91 +++++++----
 .../group/streams/StreamsGroupHeartbeatResult.java |  25 +--
 .../StreamsGroupTopologyDescriptionBackoff.java    |  22 ++-
 .../StreamsGroupTopologyDescriptionManager.java    |  68 +++++++--
 ...pCoordinatorServiceTopologyDescriptionTest.java |  28 +++-
 7 files changed, 231 insertions(+), 204 deletions(-)

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 2708b7b261b..80a3f29ff61 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,9 +20,6 @@ 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;
 import org.apache.kafka.common.errors.StreamsInvalidTopologyException;
@@ -633,7 +630,7 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
     ) {
         if (!isActive.get()) {
             return CompletableFuture.completedFuture(
-                StreamsGroupHeartbeatResult.withoutEpochContext(
+                StreamsGroupHeartbeatResult.forError(
                     new 
StreamsGroupHeartbeatResponseData().setErrorCode(Errors.COORDINATOR_NOT_AVAILABLE.code())
                 )
             );
@@ -645,7 +642,7 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
         } catch (Throwable ex) {
             ApiError apiError = ApiError.fromThrowable(ex);
             return CompletableFuture.completedFuture(
-                StreamsGroupHeartbeatResult.withoutEpochContext(
+                StreamsGroupHeartbeatResult.forError(
                     new StreamsGroupHeartbeatResponseData()
                         .setErrorCode(apiError.error().code())
                         .setErrorMessage(apiError.message())
@@ -662,7 +659,7 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
             heartbeat = heartbeat.thenApply(result -> {
                 try {
                     return 
streamsGroupTopologyDescriptionManager.maybeSetTopologyDescriptionRequired(
-                        result, request.groupId(), context.requestVersion());
+                        result, request.groupId(), context.requestVersion(), 
request.memberEpoch());
                 } catch (Throwable t) {
                     // The heartbeat has already committed durably; if 
decoration fails (e.g.
                     // because of an unexpected response shape) we log and 
return the
@@ -682,7 +679,7 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
             request,
             exception,
             (error, message) ->
-                StreamsGroupHeartbeatResult.withoutEpochContext(
+                StreamsGroupHeartbeatResult.forError(
                     new StreamsGroupHeartbeatResponseData()
                         .setErrorCode(error.code())
                         .setErrorMessage(message)
@@ -725,14 +722,12 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
         final int pushedEpoch = request.topologyEpoch();
         final TopicPartition tp = topicPartitionFor(groupId);
 
-        // Each terminal branch produces a SetTopologyOutcome carrying the 
response and the
-        // back-off disposition. Pre-plugin failures (validate / convert / 
runtime error)
-        // skip the post-plugin stages and are wrapped with BackoffAction.NOOP 
by
-        // exceptionally so a fenced or unauthorized caller cannot grief the 
back-off and
-        // suppress legitimate solicitation. Post-plugin failures arm the 
back-off; a
-        // post-plugin write failing with GroupIdNotFoundException — the group 
was deleted
-        // between the plugin call and the write — drops the orphaned entry 
since no live
-        // group remains to throttle.
+        // The back-off is mutated where the disposition is known: pre-plugin 
failures (validate /
+        // convert / runtime) never reach the arming code, so a fenced or 
unauthorized caller
+        // cannot grief the back-off; a transient plugin failure arms it; and 
the post-plugin
+        // bookkeeping write clears it on success, drops the whole entry if 
the group was deleted
+        // underneath us, leaves it alone on a coordinator-moved error, or 
arms it (see
+        // StreamsGroupTopologyDescriptionManager#completeEpochWrite).
         return runtime.scheduleReadOperation(
                 "streams-group-topology-description-validate",
                 tp,
@@ -744,15 +739,30 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
             .thenApply(__ -> 
StreamsGroupTopologyDescriptionConverter.fromRequest(request.topologyDescription()))
             .thenCompose(description -> 
streamsGroupTopologyDescriptionManager.invokeSetTopology(
                 groupId, pushedEpoch, description))
-            .thenCompose(pluginOutcome -> 
postPluginSetTopologyAction(pluginOutcome, groupId, pushedEpoch, tp))
-            .exceptionally(t -> new SetTopologyOutcome(null, 
BackoffAction.NOOP, t))
-            .thenApply(outcome -> {
-                applySetTopologyBackoff(outcome, groupId, pushedEpoch);
-                return outcome;
+            .thenCompose(pluginOutcome -> switch (pluginOutcome.kind()) {
+                case SUCCESS -> runtime.scheduleWriteOperation(
+                    "streams-group-set-stored-topology-epoch",
+                    tp,
+                    coordinator -> 
coordinator.setStoredDescriptionTopologyEpoch(groupId, pushedEpoch)
+                ).handle((unused, throwable) -> 
streamsGroupTopologyDescriptionManager.completeEpochWrite(
+                    groupId, pushedEpoch, throwable,
+                    new StreamsGroupTopologyDescriptionUpdateResponseData()));
+                case PERMANENT -> runtime.scheduleWriteOperation(
+                    "streams-group-set-failed-topology-epoch",
+                    tp,
+                    coordinator -> 
coordinator.setFailedDescriptionTopologyEpoch(groupId, pushedEpoch)
+                ).handle((unused, throwable) -> 
streamsGroupTopologyDescriptionManager.completeEpochWrite(
+                    groupId, pushedEpoch, throwable,
+                    new StreamsGroupTopologyDescriptionUpdateResponseData()
+                        
.setErrorCode(Errors.STREAMS_TOPOLOGY_DESCRIPTION_UPDATE_FAILED.code())
+                        .setErrorMessage(pluginOutcome.message())));
+                case TRANSIENT -> {
+                    streamsGroupTopologyDescriptionManager.armBackoff(groupId, 
pushedEpoch);
+                    yield CompletableFuture.completedFuture(new 
StreamsGroupTopologyDescriptionUpdateResponseData()
+                        
.setErrorCode(Errors.STREAMS_TOPOLOGY_DESCRIPTION_UPDATE_FAILED.code())
+                        .setErrorMessage(pluginOutcome.message()));
+                }
             })
-            .thenCompose(outcome -> outcome.failure() == null
-                ? CompletableFuture.completedFuture(outcome.response())
-                : CompletableFuture.failedFuture(outcome.failure()))
             .exceptionally(exception -> handleOperationException(
                 "streams-group-topology-description-update",
                 request,
@@ -764,86 +774,6 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
             ));
     }
 
-    private CompletableFuture<SetTopologyOutcome> postPluginSetTopologyAction(
-        StreamsGroupTopologyDescriptionManager.PluginOutcome pluginOutcome,
-        String groupId,
-        int pushedEpoch,
-        TopicPartition tp
-    ) {
-        return switch (pluginOutcome.kind()) {
-            case SUCCESS -> runtime.scheduleWriteOperation(
-                "streams-group-set-stored-topology-epoch",
-                tp,
-                coordinator -> 
coordinator.streamsGroupSetTopologyDescriptionEpoch(groupId, pushedEpoch, false)
-            ).handle((unused, throwable) -> outcomeForPostPluginWrite(
-                throwable, new 
StreamsGroupTopologyDescriptionUpdateResponseData()));
-            case PERMANENT -> runtime.scheduleWriteOperation(
-                "streams-group-set-failed-topology-epoch",
-                tp,
-                coordinator -> 
coordinator.streamsGroupSetTopologyDescriptionEpoch(groupId, pushedEpoch, true)
-            ).handle((unused, throwable) -> outcomeForPostPluginWrite(
-                throwable,
-                
topologyDescriptionUpdateError(Errors.STREAMS_TOPOLOGY_DESCRIPTION_UPDATE_FAILED,
 pluginOutcome.message())));
-            case TRANSIENT -> CompletableFuture.completedFuture(new 
SetTopologyOutcome(
-                
topologyDescriptionUpdateError(Errors.STREAMS_TOPOLOGY_DESCRIPTION_UPDATE_FAILED,
 pluginOutcome.message()),
-                BackoffAction.ARM, null));
-        };
-    }
-
-    private static SetTopologyOutcome outcomeForPostPluginWrite(
-        Throwable throwable,
-        StreamsGroupTopologyDescriptionUpdateResponseData responseOnSuccess
-    ) {
-        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 (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);
-    }
-
-    private void applySetTopologyBackoff(SetTopologyOutcome outcome, String 
groupId, int pushedEpoch) {
-        switch (outcome.backoffAction()) {
-            case NOOP -> { }
-            case CLEAR -> 
streamsGroupTopologyDescriptionManager.clearBackoff(groupId, pushedEpoch);
-            case CLEAR_GROUP -> 
streamsGroupTopologyDescriptionManager.clearBackoffGroup(groupId);
-            case ARM -> 
streamsGroupTopologyDescriptionManager.armBackoff(groupId, pushedEpoch);
-        }
-    }
-
-    private static StreamsGroupTopologyDescriptionUpdateResponseData 
topologyDescriptionUpdateError(
-        Errors error,
-        String message
-    ) {
-        return new StreamsGroupTopologyDescriptionUpdateResponseData()
-            .setErrorCode(error.code())
-            .setErrorMessage(message);
-    }
-
-    private record SetTopologyOutcome(
-        StreamsGroupTopologyDescriptionUpdateResponseData response,
-        BackoffAction backoffAction,
-        Throwable failure
-    ) { }
-
-    private enum BackoffAction { NOOP, ARM, CLEAR, CLEAR_GROUP }
-
     private void throwIfStreamsGroupTopologyDescriptionUpdateInvalid(
         StreamsGroupTopologyDescriptionUpdateRequestData request
     ) throws InvalidRequestException, UnsupportedVersionException {
@@ -1641,7 +1571,7 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
                     return 
CompletableFuture.completedFuture(deletableGroupResults);
                 }
 
-                return runPrePluginDelete(topicPartition, retainedGroupIds)
+                return deleteStreamsTopologyDescriptions(topicPartition, 
retainedGroupIds)
                     .thenCompose(streamsErrMap -> {
                         List<String> afterStreams = 
filterStreamsTopologyErrors(
                             streamsErrMap, retainedGroupIds, 
deletableGroupResults);
@@ -1654,11 +1584,11 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
                     })
                     .exceptionally(exception -> {
                         // Defensive net for any uncaught synchronous throw in 
the
-                        // post-runPrePluginDelete stage. Without this, the 
exception would
+                        // post-deleteStreamsTopologyDescriptions 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
+                        // inside deleteStreamsTopologyDescriptions 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.
@@ -1752,7 +1682,7 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
      * pass through the more specific runtime error rather than collapsing 
everything to
      * {@code GROUP_DELETION_FAILED}.
      */
-    private CompletableFuture<Map<String, ApiError>> runPrePluginDelete(
+    private CompletableFuture<Map<String, ApiError>> 
deleteStreamsTopologyDescriptions(
         TopicPartition topicPartition,
         List<String> groupIds
     ) {
@@ -1775,17 +1705,21 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
                         
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;
-            });
+            .exceptionally(exception -> handleOperationException(
+                // Translate coordinator errors so a read failure reports the 
same retriable code
+                // as the rest of the DeleteGroups pipeline (e.g. 
NOT_LEADER_OR_FOLLOWER ->
+                // NOT_COORDINATOR), and unwrap/sanitize the message.
+                "streams-group-topology-pre-delete",
+                groupIds,
+                exception,
+                (error, message) -> {
+                    ApiError apiError = new ApiError(error, message);
+                    Map<String, ApiError> failures = new HashMap<>();
+                    groupIds.forEach(id -> failures.put(id, apiError));
+                    return failures;
+                },
+                log
+            ));
     }
 
     /**
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 d449f4b18f2..b3dc8efa6c4 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
@@ -941,22 +941,33 @@ public class GroupCoordinatorShard implements 
CoordinatorShard<CoordinatorRecord
     }
 
     /**
-     * Persist the outcome of a topology description plugin call. Writes a 
metadata record
-     * advancing either {@code StoredDescriptionTopologyEpoch} (on plugin 
success) or
-     * {@code FailedDescriptionTopologyEpoch} (on permanent plugin failure).
+     * Advance {@code StoredDescriptionTopologyEpoch} after a successful 
plugin {@code setTopology}.
      *
-     * @param groupId           The streams group id.
-     * @param pushedEpoch       The topology epoch on the push that just 
completed.
-     * @param permanentFailure  True if the plugin signalled a permanent 
failure; false on success.
+     * @param groupId      The streams group id.
+     * @param pushedEpoch  The topology epoch on the push that just completed.
      * @return A coordinator result carrying the metadata record.
      * @throws GroupIdNotFoundException if the streams group no longer exists.
      */
-    public CoordinatorResult<Void, CoordinatorRecord> 
streamsGroupSetTopologyDescriptionEpoch(
+    public CoordinatorResult<Void, CoordinatorRecord> 
setStoredDescriptionTopologyEpoch(
         String groupId,
-        int pushedEpoch,
-        boolean permanentFailure
+        int pushedEpoch
+    ) {
+        return groupMetadataManager.setStoredDescriptionTopologyEpoch(groupId, 
pushedEpoch);
+    }
+
+    /**
+     * Advance {@code FailedDescriptionTopologyEpoch} after a permanent plugin 
failure.
+     *
+     * @param groupId      The streams group id.
+     * @param pushedEpoch  The topology epoch on the push that just completed.
+     * @return A coordinator result carrying the metadata record.
+     * @throws GroupIdNotFoundException if the streams group no longer exists.
+     */
+    public CoordinatorResult<Void, CoordinatorRecord> 
setFailedDescriptionTopologyEpoch(
+        String groupId,
+        int pushedEpoch
     ) {
-        return 
groupMetadataManager.streamsGroupSetTopologyDescriptionEpoch(groupId, 
pushedEpoch, permanentFailure);
+        return groupMetadataManager.setFailedDescriptionTopologyEpoch(groupId, 
pushedEpoch);
     }
 
     /**
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 5a32fa8f1cb..ef62f63eca1 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
@@ -4470,15 +4470,16 @@ public class GroupMetadataManager {
             .setMemberEpoch(memberEpoch)
             .setStatus(List.of());
 
-        // Leave/fence paths use 
StreamsGroupHeartbeatResult.withoutEpochContext: the departing
-        // member will never push a topology description, so the service-layer 
post-processing
-        // sees epochs of -1 and short-circuits. Without this the manager 
would arm a back-off
-        // window for a member that is on its way out, delaying push 
solicitation for the rest
-        // of the group.
         if (instanceId == null) {
             StreamsGroupMember member = group.getMemberOrThrow(memberId);
             log.info("[GroupId {}][MemberId {}] Member {} left the streams 
group.", groupId, memberId, memberId);
-            return streamsGroupFenceMember(group, member, 
StreamsGroupHeartbeatResult.withoutEpochContext(response));
+            return streamsGroupFenceMember(group, member, new 
StreamsGroupHeartbeatResult(
+                response,
+                Map.of(),
+                group.currentTopologyEpoch(),
+                group.storedDescriptionTopologyEpoch(),
+                group.failedDescriptionTopologyEpoch()
+            ));
         } else {
             StreamsGroupMember member = group.staticMember(instanceId);
             throwIfStaticMemberIsUnknown(member, instanceId);
@@ -4490,7 +4491,13 @@ public class GroupMetadataManager {
             } else {
                 log.info("[GroupId {}][MemberId {}] Static member {} with 
instance id {} left the streams group.",
                     group.groupId(), memberId, memberId, instanceId);
-                return streamsGroupFenceMember(group, member, 
StreamsGroupHeartbeatResult.withoutEpochContext(response));
+                return streamsGroupFenceMember(group, member, new 
StreamsGroupHeartbeatResult(
+                    response,
+                    Map.of(),
+                    group.currentTopologyEpoch(),
+                    group.storedDescriptionTopologyEpoch(),
+                    group.failedDescriptionTopologyEpoch()
+                ));
             }
         }
     }
@@ -4557,12 +4564,15 @@ public class GroupMetadataManager {
             .setMemberEpoch(LEAVE_GROUP_STATIC_MEMBER_EPOCH)
             .setStatus(List.of());
 
-        // Static-leave is a departing path: a member that will not push at 
this epoch,
-        // so we use the withoutEpochContext factory to skip the heartbeat 
post-processing
-        // and avoid arming the back-off on its behalf.
         return new CoordinatorResult<>(
             List.of(record),
-            StreamsGroupHeartbeatResult.withoutEpochContext(response)
+            new StreamsGroupHeartbeatResult(
+                response,
+                Map.of(),
+                group.currentTopologyEpoch(),
+                group.storedDescriptionTopologyEpoch(),
+                group.failedDescriptionTopologyEpoch()
+            )
         );
     }
 
@@ -8408,41 +8418,64 @@ public class GroupMetadataManager {
     ) {
         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.
+            // Non-throwing lookup + type check: silently skip absent or 
non-streams groups.
+            Group group = groups.get(groupId, committedOffset);
+            if (group != null
+                && group.type() == STREAMS
+                && ((StreamsGroup) 
group).storedDescriptionTopologyEpoch(committedOffset) != -1) {
+                withStored.add(groupId);
             }
         }
         return withStored;
     }
 
     /**
-     * Persist the outcome of a topology description plugin call for a streams 
group.
+     * Advance {@code StoredDescriptionTopologyEpoch} to {@code pushedEpoch} 
after a successful
+     * plugin {@code setTopology}, so subsequent heartbeats at the same epoch 
do not re-solicit.
      *
-     * <p>On a successful plugin {@code setTopology} the {@code 
StoredDescriptionTopologyEpoch}
-     * field is advanced to the pushed epoch; on a permanent failure the
-     * {@code FailedDescriptionTopologyEpoch} field is advanced instead so 
subsequent
-     * heartbeats at the same epoch do not re-solicit a push.
+     * @param groupId      The streams group id.
+     * @param pushedEpoch  The topology epoch on the push that just completed.
+     * @return A coordinator result carrying the metadata record that updates 
the field.
+     * @throws GroupIdNotFoundException if the streams group no longer exists.
+     */
+    public CoordinatorResult<Void, CoordinatorRecord> 
setStoredDescriptionTopologyEpoch(
+        String groupId,
+        int pushedEpoch
+    ) throws GroupIdNotFoundException {
+        return updateTopologyDescriptionEpochs(groupId, pushedEpoch, false);
+    }
+
+    /**
+     * Advance {@code FailedDescriptionTopologyEpoch} to {@code pushedEpoch} 
after a permanent
+     * plugin failure, so subsequent heartbeats at the same epoch do not 
re-solicit a push.
      *
-     * @param groupId           The streams group id.
-     * @param pushedEpoch       The topology epoch on the push that just 
completed.
-     * @param permanentFailure  True if the plugin signalled a permanent 
failure; false on success.
+     * @param groupId      The streams group id.
+     * @param pushedEpoch  The topology epoch on the push that just completed.
      * @return A coordinator result carrying the metadata record that updates 
the field.
      * @throws GroupIdNotFoundException if the streams group no longer exists.
      */
-    public CoordinatorResult<Void, CoordinatorRecord> 
streamsGroupSetTopologyDescriptionEpoch(
+    public CoordinatorResult<Void, CoordinatorRecord> 
setFailedDescriptionTopologyEpoch(
+        String groupId,
+        int pushedEpoch
+    ) throws GroupIdNotFoundException {
+        return updateTopologyDescriptionEpochs(groupId, pushedEpoch, true);
+    }
+
+    private CoordinatorResult<Void, CoordinatorRecord> 
updateTopologyDescriptionEpochs(
         String groupId,
         int pushedEpoch,
         boolean permanentFailure
     ) throws GroupIdNotFoundException {
         StreamsGroup group = streamsGroup(groupId);
 
-        int newStored = permanentFailure ? 
group.storedDescriptionTopologyEpoch() : pushedEpoch;
-        int newFailed = permanentFailure ? pushedEpoch : 
group.failedDescriptionTopologyEpoch();
+        // Only advance these epochs, never regress them: a stale push 
committing after the group
+        // advanced (or after a concurrent higher-epoch push) must not move 
stored/failed back.
+        int newStored = permanentFailure
+            ? group.storedDescriptionTopologyEpoch()
+            : Math.max(group.storedDescriptionTopologyEpoch(), pushedEpoch);
+        int newFailed = permanentFailure
+            ? Math.max(group.failedDescriptionTopologyEpoch(), pushedEpoch)
+            : group.failedDescriptionTopologyEpoch();
 
         CoordinatorRecord record = newStreamsGroupMetadataRecord(
             groupId,
diff --git 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupHeartbeatResult.java
 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupHeartbeatResult.java
index 28fc1e844f2..0a2638e753f 100644
--- 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupHeartbeatResult.java
+++ 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupHeartbeatResult.java
@@ -55,25 +55,14 @@ public record StreamsGroupHeartbeatResult(
     }
 
     /**
-     * Build a heartbeat result that bypasses the service-layer topology 
post-processing.
-     * Used at two distinct call sites:
-     *
-     * <ul>
-     *   <li>Failure-fast paths in {@code 
GroupCoordinatorService#streamsGroupHeartbeat}
-     *       — broker not active, request validation rejected, runtime error 
translated
-     *       by {@code handleOperationException}. The group is not resolved, 
so there is
-     *       no epoch context to track.</li>
-     *   <li>Departing-member paths in {@code GroupMetadataManager} (leave or 
fence).
-     *       The group exists but the member will not push a topology 
description, so
-     *       attaching the live epoch would arm a back-off window on its 
behalf and
-     *       delay solicitation for the rest of the group.</li>
-     * </ul>
-     *
-     * <p>All three epoch fields are set to -1, causing
-     * {@code maybeSetTopologyDescriptionRequired} to short-circuit before 
arming the
-     * back-off or setting the {@code TopologyDescriptionRequired} flag.
+     * Build a heartbeat result for an error response — a failure-fast path in
+     * {@code GroupCoordinatorService#streamsGroupHeartbeat} (broker not 
active, request
+     * validation rejected, or a runtime error translated by {@code 
handleOperationException}).
+     * No group is resolved, so there are no internal topics to create and no 
epoch context:
+     * all three epoch fields are -1, and {@code 
maybeSetTopologyDescriptionRequired}
+     * short-circuits on the error code anyway. Callers must pass a response 
carrying an error.
      */
-    public static StreamsGroupHeartbeatResult 
withoutEpochContext(StreamsGroupHeartbeatResponseData data) {
+    public static StreamsGroupHeartbeatResult 
forError(StreamsGroupHeartbeatResponseData data) {
         return new StreamsGroupHeartbeatResult(data, Map.of(), -1, -1, -1);
     }
 }
diff --git 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionBackoff.java
 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionBackoff.java
index 2d4035aea95..3bf2c3f696f 100644
--- 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionBackoff.java
+++ 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionBackoff.java
@@ -20,6 +20,7 @@ import org.apache.kafka.common.utils.Time;
 import org.apache.kafka.common.utils.internals.ExponentialBackoff;
 
 import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 /**
  * In-memory per-group back-off that throttles broker re-solicitation of a 
topology
@@ -72,23 +73,18 @@ public class StreamsGroupTopologyDescriptionBackoff {
      * or the push was lost in flight before {@link #armOrExtend} could run).
      */
     public boolean armIfNotActive(String groupId, int topologyEpoch) {
-        while (true) {
-            final long now = time.milliseconds();
-            Entry existing = state.get(groupId);
+        final long now = time.milliseconds();
+        final AtomicBoolean armed = new AtomicBoolean(false);
+        state.compute(groupId, (key, existing) -> {
             if (existing != null
                 && existing.topologyEpoch() == topologyEpoch
                 && now < existing.nextAttemptMs()) {
-                return false;
-            }
-            Entry next = computeNextEntry(existing, topologyEpoch, now);
-            boolean installed = existing == null
-                ? state.putIfAbsent(groupId, next) == null
-                : state.replace(groupId, existing, next);
-            if (installed) {
-                return true;
+                return existing;
             }
-            // Lost a race with a concurrent mutation; retry with the fresh 
state.
-        }
+            armed.set(true);
+            return computeNextEntry(existing, topologyEpoch, now);
+        });
+        return armed.get();
     }
 
     /**
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 9b52a1506d3..93cf81504e3 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
@@ -16,7 +16,12 @@
  */
 package org.apache.kafka.coordinator.group.streams;
 
+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.NotCoordinatorException;
 import org.apache.kafka.common.message.StreamsGroupHeartbeatResponseData;
+import 
org.apache.kafka.common.message.StreamsGroupTopologyDescriptionUpdateResponseData;
 import org.apache.kafka.common.protocol.Errors;
 import org.apache.kafka.common.requests.ApiError;
 import org.apache.kafka.common.requests.StreamsGroupHeartbeatResponse.Status;
@@ -33,6 +38,7 @@ import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
 
 /**
  * Broker-level component that owns the streams-group topology description 
plugin
@@ -102,9 +108,13 @@ public class StreamsGroupTopologyDescriptionManager 
implements AutoCloseable {
     public StreamsGroupHeartbeatResult maybeSetTopologyDescriptionRequired(
         StreamsGroupHeartbeatResult result,
         String groupId,
-        int apiVersion
+        int apiVersion,
+        int memberEpoch
     ) {
-        if (apiVersion < 1 || plugin.isEmpty()) {
+        // Do not solicit a push from a departing member (a leave heartbeat 
carries a negative
+        // member epoch): arming the back-off on its behalf would only delay 
solicitation for the
+        // rest of the group.
+        if (apiVersion < 1 || plugin.isEmpty() || memberEpoch < 0) {
             return result;
         }
         StreamsGroupHeartbeatResponseData response = result.data();
@@ -185,19 +195,47 @@ public class StreamsGroupTopologyDescriptionManager 
implements AutoCloseable {
     }
 
     /**
-     * Drop the back-off entry for a group at the given topology epoch. 
Epoch-scoped so a
-     * late post-plugin callback at an old epoch cannot wipe a window a 
concurrent
-     * heartbeat armed at the advanced epoch. Delegates to
-     * {@link StreamsGroupTopologyDescriptionBackoff#clear}.
+     * Settle the per-group back-off after the bookkeeping write that records 
a push outcome
+     * (the stored epoch on success, the failed epoch on a permanent failure) 
completes, and
+     * return the response to send to the client — or rethrow the write 
failure so the service's
+     * terminal handler maps it.
+     *
+     * <p>On a clean write the back-off is cleared for this epoch 
(epoch-scoped so a late callback
+     * at an old epoch cannot wipe a window a concurrent heartbeat armed at 
the advanced epoch);
+     * if the group was deleted underneath the write its whole entry is 
dropped; a coordinator-moved
+     * error leaves the back-off untouched (the new coordinator owns 
convergence once the client
+     * retries); any other failure arms it so the next heartbeat re-solicits.
      */
-    public void clearBackoff(String groupId, int topologyEpoch) {
-        backoff.clear(groupId, topologyEpoch);
+    public StreamsGroupTopologyDescriptionUpdateResponseData 
completeEpochWrite(
+        String groupId,
+        int topologyEpoch,
+        Throwable writeException,
+        StreamsGroupTopologyDescriptionUpdateResponseData responseOnCommit
+    ) {
+        if (writeException == null) {
+            backoff.clear(groupId, topologyEpoch);
+            return responseOnCommit;
+        }
+        Throwable cause = Errors.maybeUnwrapException(writeException);
+        if (cause instanceof GroupIdNotFoundException) {
+            backoff.clearGroup(groupId);
+        } else if (cause instanceof NotCoordinatorException
+            || cause instanceof CoordinatorLoadInProgressException
+            || cause instanceof CoordinatorNotAvailableException) {
+            // Coordinator moved between the plugin call and the write; the 
new coordinator owns
+            // convergence after the client retries, so leave the back-off 
alone.
+        } else {
+            backoff.armOrExtend(groupId, topologyEpoch);
+        }
+        throw new CompletionException(writeException);
     }
 
     /**
-     * Drop the back-off entry for a group unconditionally. Used by paths that 
remove the
-     * group entirely (explicit DeleteGroups, periodic cleanup of 
naturally-expired
-     * groups, post-plugin write failing with GroupIdNotFoundException). 
Delegates to
+     * Drop the back-off entry for a group unconditionally. Currently called 
when a group is
+     * removed via explicit DeleteGroups and on a post-plugin write failing 
with
+     * GroupIdNotFoundException. NOTE: groups removed by other lifecycle paths 
(session expiry,
+     * partition unload, tombstone-via-replay) are not yet wired to this, so 
their back-off
+     * entries can leak until the group id is reused. Delegates to
      * {@link StreamsGroupTopologyDescriptionBackoff#clearGroup}.
      */
     public void clearBackoffGroup(String groupId) {
@@ -254,9 +292,11 @@ public class StreamsGroupTopologyDescriptionManager 
implements AutoCloseable {
         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));
+        // Do not forward the plugin's raw exception message to the client: it 
can be null and
+        // may leak plugin internals (the ErrorMessage is serialized at 
DeleteGroups v3+). Use a
+        // fixed generic message, mirroring invokeSetTopology.
+        return Map.entry(groupId, new ApiError(Errors.GROUP_DELETION_FAILED,
+            "Topology description plugin failed to delete the topology."));
     }
 
     // Visible for testing.
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 20ac95683b6..8fe7b89a60b 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
@@ -495,6 +495,29 @@ public class 
GroupCoordinatorServiceTopologyDescriptionTest {
         assertTrue(result.data().topologyDescriptionRequired());
     }
 
+    @Test
+    public void testHeartbeatSkipsFlagForDepartingMember() throws Exception {
+        // A leave heartbeat carries a negative member epoch. Even though the 
result shows the
+        // stored epoch lagging (which would otherwise solicit a push), the 
gate must not arm
+        // the back-off or set the flag for a member on its way out.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        when(runtime.scheduleWriteOperation(
+            eq("streams-group-heartbeat"),
+            eq(GROUP_TP),
+            any()
+        )).thenReturn(CompletableFuture.completedFuture(
+            new StreamsGroupHeartbeatResult(new 
StreamsGroupHeartbeatResponseData(), Map.of(), 5, -1, -1)));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+        StreamsGroupHeartbeatResult result = service.streamsGroupHeartbeat(
+            requestContext(ApiKeys.STREAMS_GROUP_HEARTBEAT),
+            validHeartbeatRequest().setMemberEpoch(-1)
+        ).get(5, TimeUnit.SECONDS);
+
+        assertFalse(result.data().topologyDescriptionRequired());
+    }
+
     @Test
     public void testHeartbeatArmSuppressReSolicitCycle() throws Exception {
         // End-to-end exercise of the arm → suppress → re-solicit cycle 
through the
@@ -710,7 +733,8 @@ public class GroupCoordinatorServiceTopologyDescriptionTest 
{
         DeleteGroupsResponseData.DeletableGroupResult result = 
results.find("foo");
         assertNotNull(result);
         assertEquals(Errors.GROUP_DELETION_FAILED.code(), result.errorCode());
-        assertEquals("plugin offline", result.errorMessage());
+        // The raw plugin message ("plugin offline") must not be forwarded to 
the client.
+        assertEquals("Topology description plugin failed to delete the 
topology.", result.errorMessage());
         verify(runtime, never()).scheduleWriteOperation(
             eq("delete-groups"), any(), any());
     }
@@ -886,7 +910,7 @@ public class GroupCoordinatorServiceTopologyDescriptionTest 
{
         DeleteGroupsResponseData.DeletableGroupResult badResult = 
results.find("bad");
         assertNotNull(badResult);
         assertEquals(Errors.GROUP_DELETION_FAILED.code(), 
badResult.errorCode());
-        assertEquals("rejected", badResult.errorMessage());
+        assertEquals("Topology description plugin failed to delete the 
topology.", badResult.errorMessage());
     }
 
     private static StreamsGroupTopologyDescriptionUpdateRequestData 
validUpdateRequest() {


Reply via email to