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 d4cd81a5ef2 KAFKA-20696: Periodic plugin.deleteTopology cleanup for 
naturally-expired streams groups (#22622)
d4cd81a5ef2 is described below

commit d4cd81a5ef2d88db7c2851c17f4cb76107cee2ef
Author: TengYao Chi <[email protected]>
AuthorDate: Wed Jun 24 11:02:42 2026 +0100

    KAFKA-20696: Periodic plugin.deleteTopology cleanup for naturally-expired 
streams groups (#22622)
    
    JIRA: KAFKA-20696  This PR is a part of KIP-1331
    
    Cycle (broker-level, on GroupCoordinatorService):
    - A self-rescheduling TimerTask runs at
    offsets.retention.check.interval.ms,
      gated on isActive + manager.isPluginConfigured(). Single-flight
    guarded so a
      cycle that fires while the previous one is still settling per-group
    futures
      is dropped with a warn log.
    - Each tick calls runtime.scheduleReadAllOperation to fan out an
    eligibility
      query to every hosted shard, then fans plugin.deleteTopology out via
    the
      existing manager.invokeDeleteTopologies building block. The manager
    itself
      is unchanged — it keeps the (plugin, time) shape KAFKA-20623-4 settled
    on
      and exposes only plugin-invocation + back-off mutations; chain
    assembly
      lives on the service.
    
    Eligibility + gating (per-shard):
    - New GroupCoordinatorShard.listStreamsGroupsNeedingTopologyCleanup
    returns
      group ids that are STREAMS + isEmpty + storedDescriptionTopologyEpoch
    != -1
      + offsets all past retention, paired with the observed storedEpoch.
    The
      scan uses a non-throwing GroupMetadataManager.maybeGroup lookup to
    avoid
      per-non-streams-group exception cost on plugin-enabled brokers running
      mixed groups; per-group errors are logged and the scan continues.
    - cleanupGroupMetadata now defers maybeDeleteGroup for STREAMS groups
    with
      storedEpoch != -1, but only when a plugin is configured on this broker
    —
      otherwise no cycle would ever clear the field and the gate would block
      natural expiration indefinitely. The cast is factored into a named
    helper
      so the short-circuit precondition for (StreamsGroup) sits next to the
    cast.
    - After a successful plugin.deleteTopology, the cycle writes a
    conditional
      GroupMetadataManager.clearStoredDescriptionTopologyEpoch(groupId,
      expectedStoredEpoch) — only clears when the persisted value still
    equals
      the epoch we observed at scan time, so a concurrent setTopology that
    has
      advanced storedEpoch is preserved. Failed plugin calls retry on the
    next
      cycle.
    - The empty-group → concurrent-setTopology → cycle-delete race is
    documented
      on runStreamsGroupTopologyCleanupCycle: plugin.deleteTopology keys
    only on
      groupId per KIP appendix, so a new member that pushes between scan and
      delete loses its plugin row; describe surfaces NOT_STORED + WARN,
    which is
      the graceful-degradation path KIP-1331 explicitly accepts as
    "plugin-side
      data loss".
    
    OffsetMetadataManager:
    - Adds a read-only allOffsetsExpired(groupId, currentTimestampMs) used
    by the
      shard scan. The per-offset expirability check is factored into a
    private
      isOffsetExpirable helper shared with cleanupExpiredOffsets so the read
    and
      write paths cannot drift.
    
    Metrics (KIP-1331):
    - streams-group-topology-description-cleanup-cycle-{rate,count}
    - streams-group-topology-description-cleanup-eligible-{rate,count}
    - streams-group-topology-description-delete-{success,error}-{rate,count}
    
    Reviewers: Lucas Brutschy <[email protected]>
---
 .../coordinator/group/GroupCoordinatorConfig.java  |   9 +
 .../coordinator/group/GroupCoordinatorService.java | 167 +++++++++++-
 .../coordinator/group/GroupCoordinatorShard.java   |  88 ++++++-
 .../coordinator/group/GroupMetadataManager.java    |  51 ++++
 .../coordinator/group/OffsetMetadataManager.java   |  83 +++++-
 .../group/metrics/GroupCoordinatorMetrics.java     |  79 +++++-
 .../coordinator/group/streams/StreamsGroup.java    |  12 +
 .../StreamsGroupTopologyDescriptionManager.java    | 147 ++++++++++-
 ...pCoordinatorServiceTopologyDescriptionTest.java | 289 +++++++++++++++++++++
 .../group/GroupCoordinatorShardTest.java           | 272 +++++++++++++++++++
 .../group/GroupMetadataManagerTest.java            |  49 ++++
 .../group/OffsetMetadataManagerTest.java           | 152 +++++++++++
 .../group/metrics/GroupCoordinatorMetricsTest.java |   8 +
 13 files changed, 1376 insertions(+), 30 deletions(-)

diff --git 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorConfig.java
 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorConfig.java
index af4aaf2169c..31454863565 100644
--- 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorConfig.java
+++ 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorConfig.java
@@ -918,6 +918,15 @@ public class GroupCoordinatorConfig {
             overrides);
     }
 
+    /**
+     * Whether a topology-description plugin class is set on this broker. 
Checked from per-shard
+     * paths (the {@code cleanupGroupMetadata} gate) that cannot reach the 
broker-level manager
+     * holding the plugin reference. Does not instantiate the plugin; just 
inspects the config.
+     */
+    public boolean isStreamsGroupTopologyDescriptionPluginConfigured() {
+        return 
config.getClass(STREAMS_GROUP_TOPOLOGY_DESCRIPTION_PLUGIN_CLASS_CONFIG) != null;
+    }
+
     /**
      * The number of threads or event loops running.
      */
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 cad84307b08..dfe017f8108 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
@@ -143,9 +143,11 @@ import java.util.Objects;
 import java.util.Optional;
 import java.util.OptionalInt;
 import java.util.Properties;
+import java.util.Queue;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.CompletionException;
+import java.util.concurrent.ConcurrentLinkedQueue;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.function.IntSupplier;
@@ -363,13 +365,19 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
     private final PartitionMetadataClient partitionMetadataClient;
 
     /**
-     * The broker-level component that owns the streams-group topology 
description plugin
-     * (KIP-1331): plugin reference, per-group push back-off, and the three 
entry points
-     * the service delegates into — heartbeat post-processing, the push RPC, 
and the
-     * pre-tombstone hook on DeleteGroups.
+     * The broker-level component that owns the streams-group topology 
description plugin:
+     * plugin reference, per-group push back-off, the entry points the service 
delegates
+     * into (heartbeat post-processing, the push RPC, the pre-tombstone hook on
+     * DeleteGroups), and the periodic plugin-row cleanup cycle for 
naturally-expired
+     * streams groups.
      */
     private final StreamsGroupTopologyDescriptionManager 
streamsGroupTopologyDescriptionManager;
 
+    // Visible for testing.
+    StreamsGroupTopologyDescriptionManager 
streamsGroupTopologyDescriptionManager() {
+        return streamsGroupTopologyDescriptionManager;
+    }
+
     /**
      * The number of partitions of the __consumer_offsets topics. This is 
provided
      * when the component is started.
@@ -786,6 +794,144 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
         throwIfNull(request.topologyDescription(), "TopologyDescription can't 
be null.");
     }
 
+    /**
+     * Build one topology-description cleanup cycle: read every shard for 
streams groups
+     * eligible for plugin-side cleanup (empty + all offsets expired + 
storedEpoch != -1), call
+     * {@code plugin.deleteTopology} for each via the manager, then for every 
group whose
+     * plugin call succeeded write a conditional metadata record that clears
+     * {@code StoredDescriptionTopologyEpoch} only if the persisted value 
still matches the
+     * epoch we observed at scan time (so a concurrent {@code setTopology} 
that has advanced
+     * the field is preserved). Failed plugin calls retry on the next cycle; 
the next sweep
+     * then tombstones the now-empty group.
+     *
+     * <p>The single-flight guard, periodic timer scheduling, and {@code 
running} flag live
+     * on {@link StreamsGroupTopologyDescriptionManager#startCleanupCycle}; 
this method is
+     * the cycle body it invokes, returning a future that the manager joins to 
release the
+     * in-flight flag.
+     *
+     * <p><b>Concurrent setTopology race vs plugin.deleteTopology.</b> {@code 
plugin.deleteTopology}
+     * is keyed only on {@code groupId}. If a new member joins between the
+     * eligibility scan and the cycle's plugin call and pushes a fresh 
topology, the plugin's
+     * row is removed regardless of the new epoch — the conditional clear 
above no-ops on the
+     * metadata side, but the plugin-side data the member just wrote is gone. 
A subsequent
+     * {@code describe} → {@code getTopology} returns null and surfaces {@code 
NOT_STORED} with
+     * a warn log; this is the graceful-degradation path accepted under the 
label
+     * "plugin-side data loss". The {@code isEmpty} requirement on the scan 
keeps the window
+     * narrow — concurrent setTopology requires a member to join an empty, 
fully-expired group
+     * between scan and delete — and the next heartbeat at the same epoch will 
not re-solicit
+     * (storedEpoch in metadata still reflects the new push), so the group 
converges on
+     * NOT_STORED without churn rather than chasing the lost plugin row.
+     */
+    // Visible for testing.
+    CompletableFuture<?> runOneStreamsTopologyCleanupCycle() {
+        if (!streamsGroupTopologyDescriptionManager.isPluginConfigured()) {
+            return CompletableFuture.completedFuture(null);
+        }
+        groupCoordinatorMetrics.recordSensor(
+            
GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_CLEANUP_CYCLE_RUNS_SENSOR_NAME);
+
+        List<CompletableFuture<Map<String, Integer>>> partitionFutures = 
runtime.scheduleReadAllOperation(
+            "list-streams-groups-needing-topology-cleanup",
+            GroupCoordinatorShard::listStreamsGroupsNeedingTopologyCleanup
+        );
+
+        // ConcurrentLinkedQueue because per-partition .handle callbacks can 
append concurrently
+        // from whichever thread completed each runtime read.
+        Queue<CompletableFuture<?>> perGroupFutures = new 
ConcurrentLinkedQueue<>();
+        List<CompletableFuture<Void>> partitionDoneFutures = new 
ArrayList<>(partitionFutures.size());
+        for (CompletableFuture<Map<String, Integer>> partitionFuture : 
partitionFutures) {
+            partitionDoneFutures.add(partitionFuture.handle((eligible, 
throwable) -> {
+                if (throwable != null) {
+                    log.warn("Topology-description cleanup read failed for one 
partition.", throwable);
+                    return null;
+                }
+                if (eligible == null || eligible.isEmpty()) return null;
+                // Shutdown started after the per-partition read was 
scheduled. Skip the
+                // plugin dispatch so we do not issue plugin.deleteTopology 
calls into a
+                // manager whose plugin is about to be closed.
+                if (!isActive.get()) return null;
+                groupCoordinatorMetrics.recordSensor(
+                    
GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_CLEANUP_ELIGIBLE_GROUPS_SENSOR_NAME,
+                    eligible.size()
+                );
+                perGroupFutures.add(streamsGroupTopologyDescriptionManager
+                    .invokeDeleteTopologies(eligible.keySet())
+                    .thenCompose(failures -> {
+                        recordPluginDeleteOutcome(eligible.size(), 
failures.size());
+                        // Shutdown can have started between the plugin call 
and the
+                        // follow-up writes. Skip the conditional clears so we 
do not
+                        // schedule writes against a runtime that is being 
closed.
+                        if (!isActive.get()) return 
CompletableFuture.completedFuture(null);
+                        List<CompletableFuture<Void>> clearFutures = new 
ArrayList<>(eligible.size());
+                        eligible.forEach((groupId, expectedStoredEpoch) -> {
+                            if (failures.containsKey(groupId)) {
+                                // Plugin failed: leave both stored epoch and 
the push-path
+                                // back-off in place. Eligibility's "group is 
empty" snapshot
+                                // only held at scan time; a member can rejoin 
between scan
+                                // and now, and the existing back-off 
correctly throttles their
+                                // set-topology attempt against the 
still-broken plugin
+                                // instead of letting it re-attack at 
attempts=0 every join.
+                                return;
+                            }
+                            // Plugin succeeded; the group will be tombstoned 
in the next sweep
+                            // once the stored epoch is cleared. Drop the 
broker-wide back-off
+                            // entry — it is no longer load-bearing for any 
future state of
+                            // this groupId. A member that re-creates the same 
id afterwards
+                            // is a fresh lifecycle and will arm a fresh 
back-off chain.
+                            
streamsGroupTopologyDescriptionManager.clearBackoffGroup(groupId);
+                            
clearFutures.add(clearStoredDescriptionTopologyEpochAsync(groupId, 
expectedStoredEpoch));
+                        });
+                        return 
CompletableFuture.allOf(clearFutures.toArray(new CompletableFuture<?>[0]));
+                    }));
+                return null;
+            }));
+        }
+
+        return CompletableFuture.allOf(partitionDoneFutures.toArray(new 
CompletableFuture<?>[0]))
+            .thenCompose(__ -> 
CompletableFuture.allOf(perGroupFutures.toArray(new CompletableFuture<?>[0])));
+    }
+
+    /**
+     * Record per-call outcomes from a batched {@code plugin.deleteTopology} 
invocation
+     * against the shared {@code delete-success} / {@code delete-error} 
sensors. Used by
+     * the periodic cleanup cycle and the explicit {@code DeleteGroups} flow 
so a single
+     * pair of meters tracks every {@code plugin.deleteTopology} the broker 
drives,
+     * regardless of trigger.
+     */
+    private void recordPluginDeleteOutcome(int attempted, int errors) {
+        int successes = attempted - errors;
+        if (successes > 0) {
+            groupCoordinatorMetrics.recordSensor(
+                
GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_DELETE_SUCCESS_SENSOR_NAME,
 successes);
+        }
+        if (errors > 0) {
+            groupCoordinatorMetrics.recordSensor(
+                
GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_DELETE_ERROR_SENSOR_NAME,
 errors);
+        }
+    }
+
+    /**
+     * Conditional metadata write that clears {@code 
StoredDescriptionTopologyEpoch} for
+     * {@code groupId} only when the persisted value still equals {@code 
expectedStoredEpoch}.
+     * Mismatches and missing groups are silently ignored by the shard-side 
method. Runtime
+     * write failures (NOT_COORDINATOR etc.) are logged here and swallowed so 
a single failed
+     * write does not poison the cycle's allOf — the next cycle will retry 
naturally because
+     * the persisted storedEpoch is still non-default.
+     */
+    private CompletableFuture<Void> 
clearStoredDescriptionTopologyEpochAsync(String groupId, int 
expectedStoredEpoch) {
+        return runtime.<Void>scheduleWriteOperation(
+            "clear-stored-topology-epoch",
+            topicPartitionFor(groupId),
+            coordinator -> 
coordinator.clearStoredDescriptionTopologyEpoch(groupId, expectedStoredEpoch)
+        ).handle((__, throwable) -> {
+            if (throwable != null) {
+                log.warn("Failed to clear StoredDescriptionTopologyEpoch for 
group {}; the next cleanup cycle will retry.",
+                    groupId, throwable);
+            }
+            return null;
+        });
+    }
+
     /**
      * Validates the ShareGroupHeartbeat request.
      *
@@ -2627,6 +2773,13 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
         log.info("Starting up.");
         numPartitions = groupMetadataTopicPartitionCount.getAsInt();
         isActive.set(true);
+        // Arm the periodic topology-description cleanup cycle on the manager; 
no-op when no
+        // plugin is configured. The manager owns the timer + single-flight 
harness; the
+        // cycle body lives here on the service via 
runOneStreamsTopologyCleanupCycle.
+        streamsGroupTopologyDescriptionManager.startCleanupCycle(
+            timer,
+            config.offsetsRetentionCheckIntervalMs(),
+            this::runOneStreamsTopologyCleanupCycle);
         log.info("Startup complete.");
     }
 
@@ -2642,8 +2795,12 @@ public class GroupCoordinatorService implements 
GroupCoordinator {
 
         log.info("Shutting down.");
         isActive.set(false);
-        Utils.closeQuietly(runtime, "coordinator runtime");
+        // Close the topology-description manager before the runtime so that 
its cycle's
+        // running flag flips false and the scheduled tick is cancelled while 
the runtime is
+        // still alive — writes already scheduled before the flip drain 
through their own
+        // futures rather than racing the runtime tear-down.
         Utils.closeQuietly(streamsGroupTopologyDescriptionManager, "streams 
group topology description manager");
+        Utils.closeQuietly(runtime, "coordinator runtime");
         Utils.closeQuietly(groupCoordinatorMetrics, "group coordinator 
metrics");
         Utils.closeQuietly(groupConfigManager, "group config manager");
         log.info("Shutdown complete.");
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 b3dc8efa6c4..b79f7ebe3a4 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
@@ -120,6 +120,7 @@ import 
org.apache.kafka.coordinator.group.generated.StreamsGroupTopologyValue;
 import org.apache.kafka.coordinator.group.metrics.GroupCoordinatorMetrics;
 import org.apache.kafka.coordinator.group.metrics.GroupCoordinatorMetricsShard;
 import org.apache.kafka.coordinator.group.modern.share.ShareGroup;
+import org.apache.kafka.coordinator.group.streams.StreamsGroup;
 import org.apache.kafka.coordinator.group.streams.StreamsGroupDescribeResult;
 import org.apache.kafka.coordinator.group.streams.StreamsGroupHeartbeatResult;
 import org.apache.kafka.server.authorizer.AuthorizableRequestContext;
@@ -982,6 +983,60 @@ public class GroupCoordinatorShard implements 
CoordinatorShard<CoordinatorRecord
         return 
groupMetadataManager.streamsGroupsWithStoredTopologyDescription(groupIds, 
committedOffset);
     }
 
+    /**
+     * Return the streams groups on this shard eligible for plugin-side 
topology cleanup: empty
+     * (no live members), every committed offset already past {@code 
offsets.retention.ms}, and a
+     * {@code StoredDescriptionTopologyEpoch != -1}. Keyed by group id, valued 
by the
+     * {@code StoredDescriptionTopologyEpoch} observed at {@code 
committedOffset} — the cleanup
+     * cycle echoes that epoch back to {@link 
#clearStoredDescriptionTopologyEpoch} so a
+     * concurrent {@code setTopology} that has since advanced the field cannot 
be silently undone
+     * by a stale plugin delete.
+     *
+     * <p>Every state lookup goes through the snapshot at {@code 
committedOffset}: the iterated
+     * group-id set, the per-group resolution, the {@code EMPTY}-state check, 
the stored
+     * topology epoch, and the per-offset retention check inside
+     * {@link OffsetMetadataManager#allOffsetsExpired}. The only non-snapshot 
input is
+     * {@code now} from the wall clock, which is unavoidable for "offset has 
aged past
+     * retention" and is captured once at the top of the scan so every group 
is compared
+     * against the same instant.
+     *
+     * <p>Non-streams and missing groups are silently skipped. Per-group 
errors are logged and
+     * the scan continues so one bad group cannot stall the cycle.
+     */
+    public Map<String, Integer> listStreamsGroupsNeedingTopologyCleanup(long 
committedOffset) {
+        long now = time.milliseconds();
+        Map<String, Integer> eligible = new HashMap<>();
+        for (String groupId : groupMetadataManager.groupIds(committedOffset)) {
+            try {
+                Group group = groupMetadataManager.maybeGroup(groupId, 
committedOffset);
+                if (group == null || group.type() != Group.GroupType.STREAMS) 
continue;
+                StreamsGroup streamsGroup = (StreamsGroup) group;
+                if (!streamsGroup.isEmpty(committedOffset)) continue;
+                int storedEpoch = 
streamsGroup.storedDescriptionTopologyEpoch(committedOffset);
+                if (storedEpoch == -1) continue;
+                if (!offsetMetadataManager.allOffsetsExpired(groupId, now, 
committedOffset)) continue;
+                eligible.put(groupId, storedEpoch);
+            } catch (Throwable t) {
+                // One bad group must not abort the whole scan; the next cycle 
retries.
+                log.warn("Unexpected error scanning streams group {} for 
topology cleanup; skipping.", groupId, t);
+            }
+        }
+        return eligible;
+    }
+
+    /**
+     * Clear {@code StoredDescriptionTopologyEpoch} to {@code -1} for {@code 
groupId}, but only
+     * when the persisted value still equals {@code expectedStoredEpoch}. 
Called from the
+     * topology-description cleanup cycle so a concurrent {@code setTopology} 
that has advanced
+     * the field is preserved.
+     */
+    public CoordinatorResult<Void, CoordinatorRecord> 
clearStoredDescriptionTopologyEpoch(
+        String groupId,
+        int expectedStoredEpoch
+    ) {
+        return 
groupMetadataManager.clearStoredDescriptionTopologyEpoch(groupId, 
expectedStoredEpoch);
+    }
+
     /**
      * Handles a ShareGroupDescribe request.
      *
@@ -1054,15 +1109,15 @@ public class GroupCoordinatorShard implements 
CoordinatorShard<CoordinatorRecord
      */
     public CoordinatorResult<Void, CoordinatorRecord> cleanupGroupMetadata() {
         long startMs = time.milliseconds();
+        boolean topologyPluginConfigured = 
config.isStreamsGroupTopologyDescriptionPluginConfigured();
         List<CoordinatorRecord> records = new ArrayList<>();
         groupMetadataManager.groupIds().forEach(groupId -> {
             Group group = groupMetadataManager.group(groupId);
-            if (group.shouldExpire()) {
-                boolean allOffsetsExpired = 
offsetMetadataManager.cleanupExpiredOffsets(groupId, records);
-                if (allOffsetsExpired) {
-                    groupMetadataManager.maybeDeleteGroup(groupId, records);
-                }
-            }
+            if (!group.shouldExpire()) return;
+            boolean allOffsetsExpired = 
offsetMetadataManager.cleanupExpiredOffsets(groupId, records);
+            if (!allOffsetsExpired) return;
+            if 
(deferStreamsGroupTombstoneForPluginCleanup(topologyPluginConfigured, group)) 
return;
+            groupMetadataManager.maybeDeleteGroup(groupId, records);
         });
 
         if (!records.isEmpty()) {
@@ -1075,6 +1130,27 @@ public class GroupCoordinatorShard implements 
CoordinatorShard<CoordinatorRecord
         return new CoordinatorResult<>(records, false);
     }
 
+    /**
+     * Decide whether the natural-expiration sweep must defer tombstoning 
{@code group} so the
+     * broker-level topology-description cleanup cycle can drive {@code 
plugin.deleteTopology}
+     * and clear {@code StoredDescriptionTopologyEpoch} first. Returns true 
only when all of:
+     * a plugin is configured on this broker (otherwise no cycle would ever 
clear the field
+     * and the gate would prevent natural expiration indefinitely), the group 
is a streams
+     * group, and its {@code StoredDescriptionTopologyEpoch} is not the {@code 
-1} default.
+     *
+     * <p>The {@code (StreamsGroup) group} cast is safe because the {@code 
group.type() == STREAMS}
+     * check precedes it via short-circuit evaluation; pulling this out of the 
sweep lambda
+     * keeps the gate's intent and the cast's precondition next to each other.
+     */
+    private static boolean deferStreamsGroupTombstoneForPluginCleanup(
+        boolean topologyPluginConfigured,
+        Group group
+    ) {
+        return topologyPluginConfigured
+            && group.type() == Group.GroupType.STREAMS
+            && ((StreamsGroup) group).storedDescriptionTopologyEpoch() != -1;
+    }
+
     /**
      * Schedule the group/offsets expiration job. If any exceptions are thrown 
above, the timer will retry.
      */
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 b0136ca3d95..08a0abfda04 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
@@ -627,6 +627,16 @@ public class GroupMetadataManager {
         return group;
     }
 
+    /**
+     * Non-throwing variant of {@link #group(String, long)}: returns {@code 
null} if no group
+     * with the given id exists at {@code committedOffset}. Used by scans 
(e.g. the topology-
+     * description cleanup cycle) where a missing group is a normal 
continue-the-scan condition
+     * rather than an error worth a {@link GroupIdNotFoundException} cost per 
iteration.
+     */
+    public Group maybeGroup(String groupId, long committedOffset) {
+        return groups.get(groupId, committedOffset);
+    }
+
     /**
      * Get the Group List.
      *
@@ -8500,6 +8510,37 @@ public class GroupMetadataManager {
         return new CoordinatorResult<>(List.of(record), null);
     }
 
+    /**
+     * Clear {@code StoredDescriptionTopologyEpoch} to {@code -1} only when 
the group's stored
+     * epoch still equals {@code expectedStoredEpoch} (the value observed at 
the start of the
+     * cleanup cycle). A concurrent {@code setTopology} that advanced the 
epoch in the
+     * meantime is preserved — the next cycle will pick up the new state. 
Missing groups,
+     * non-streams groups, and mismatched epochs all yield an empty record 
list so the cycle's
+     * downstream tombstone pass treats them as no-ops rather than errors.
+     */
+    public CoordinatorResult<Void, CoordinatorRecord> 
clearStoredDescriptionTopologyEpoch(
+        String groupId,
+        int expectedStoredEpoch
+    ) {
+        Group group = groups.get(groupId);
+        if (!(group instanceof StreamsGroup streamsGroup)) {
+            return new CoordinatorResult<>(List.of());
+        }
+        if (streamsGroup.storedDescriptionTopologyEpoch() != 
expectedStoredEpoch) {
+            return new CoordinatorResult<>(List.of());
+        }
+        CoordinatorRecord record = newStreamsGroupMetadataRecord(
+            groupId,
+            streamsGroup.groupEpoch(),
+            streamsGroup.metadataHash(),
+            streamsGroup.validatedTopologyEpoch(),
+            streamsGroup.lastAssignmentConfigs(),
+            -1,
+            streamsGroup.failedDescriptionTopologyEpoch()
+        );
+        return new CoordinatorResult<>(List.of(record), null);
+    }
+
     /**
      * Validates that (1) the instance id exists and is mapped to the member id
      * if the group instance id is provided; and (2) the member id exists in 
the group.
@@ -9295,6 +9336,16 @@ public class GroupMetadataManager {
         return Collections.unmodifiableSet(this.groups.keySet());
     }
 
+    /**
+     * Snapshot-aware counterpart to {@link #groupIds()}: returns the set of 
group ids
+     * present at {@code committedOffset}. Used by read operations whose 
entire eligibility
+     * decision must be reproducible from a single committed snapshot (e.g. 
the topology
+     * cleanup scan in {@link GroupCoordinatorShard}).
+     */
+    public Set<String> groupIds(long committedOffset) {
+        return 
Collections.unmodifiableSet(this.groups.keySet(committedOffset));
+    }
+
     // Visible for testing
     Map<String, Long> topicHashCache() {
         return Collections.unmodifiableMap(this.topicHashCache);
diff --git 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/OffsetMetadataManager.java
 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/OffsetMetadataManager.java
index e30b036c66e..1465af9e8e4 100644
--- 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/OffsetMetadataManager.java
+++ 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/OffsetMetadataManager.java
@@ -57,6 +57,7 @@ import org.slf4j.Logger;
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.OptionalLong;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -258,6 +259,16 @@ public class OffsetMetadataManager {
             return openTransactionsByGroup.containsKey(groupId);
         }
 
+        /**
+         * Snapshot-aware overload of {@link #contains(String)}: returns 
{@code true} if the
+         * given group had any pending transactional offsets at {@code 
committedOffset}. Used
+         * by read operations that must only observe committed state (e.g. the 
topology
+         * cleanup cycle's eligibility scan).
+         */
+        private boolean contains(String groupId, long committedOffset) {
+            return openTransactionsByGroup.containsKey(groupId, 
committedOffset);
+        }
+
         /**
          * Returns {@code true} if the given group has any pending 
transactional offsets for the given topic and partition.
          *
@@ -271,6 +282,21 @@ public class OffsetMetadataManager {
             return openTransactions != null;
         }
 
+        /**
+         * Snapshot-aware overload of {@link #contains(String, String, int)}: 
returns
+         * {@code true} if the given group had any pending transactional 
offsets for the
+         * given topic and partition at {@code committedOffset}.
+         */
+        private boolean contains(String groupId, String topic, int partition, 
long committedOffset) {
+            TimelineHashMap<String, TimelineHashMap<Integer, 
TimelineHashSet<Long>>> openTransactionsByTopic =
+                openTransactionsByGroup.get(groupId, committedOffset);
+            if (openTransactionsByTopic == null) return false;
+            TimelineHashMap<Integer, TimelineHashSet<Long>> 
openTransactionsByPartition =
+                openTransactionsByTopic.get(topic, committedOffset);
+            if (openTransactionsByPartition == null) return false;
+            return openTransactionsByPartition.containsKey(partition, 
committedOffset);
+        }
+
         /**
          * Performs the given action for each partition with a pending 
transactional offset for the given group.
          *
@@ -1034,6 +1060,58 @@ public class OffsetMetadataManager {
             .setTopics(topicResponses);
     }
 
+    /**
+     * Read-only counterpart to {@link #cleanupExpiredOffsets(String, List)}: 
returns whether
+     * every committed offset for the group is currently eligible for 
expiration and no pending
+     * transactional offsets remain. Used by the topology-description plugin 
cleanup cycle on
+     * the eligibility read side, where the sweep must not mutate any record 
but still needs to
+     * decide whether the group is fully expirable before driving a {@code 
plugin.deleteTopology}.
+     *
+     * <p>{@code committedOffset} is the snapshot point the runtime hands to 
the read operation
+     * that calls this method. Every timeline-backed lookup in here uses that 
snapshot — the
+     * runtime contract is that read operations only observe committed state, 
so a concurrent
+     * uncommitted offset commit or pending-transaction record must not flip 
the eligibility
+     * outcome on us.
+     */
+    public boolean allOffsetsExpired(String groupId, long currentTimestampMs, 
long committedOffset) {
+        TimelineHashMap<String, TimelineHashMap<Integer, OffsetAndMetadata>> 
offsetsByTopic =
+            offsets.offsetsByGroup.get(groupId, committedOffset);
+        if (offsetsByTopic == null) {
+            return !openTransactions.contains(groupId, committedOffset);
+        }
+        Group group;
+        try {
+            group = groupMetadataManager.group(groupId, committedOffset);
+        } catch (GroupIdNotFoundException e) {
+            // The group disappeared between the caller's existence check and 
this lookup at
+            // the same snapshot — it is not eligible for plugin cleanup, the 
next sweep will
+            // pick this up naturally.
+            return false;
+        }
+        Optional<OffsetExpirationCondition> offsetExpirationCondition = 
group.offsetExpirationCondition();
+        if (offsetExpirationCondition.isEmpty()) {
+            return false;
+        }
+        OffsetExpirationCondition condition = offsetExpirationCondition.get();
+        for (Map.Entry<String, TimelineHashMap<Integer, OffsetAndMetadata>> 
topicEntry
+                : offsetsByTopic.entrySet(committedOffset)) {
+            String topic = topicEntry.getKey();
+            if (group.isSubscribedToTopic(topic)) {
+                return false;
+            }
+            for (Map.Entry<Integer, OffsetAndMetadata> partitionEntry
+                    : topicEntry.getValue().entrySet(committedOffset)) {
+                int partition = partitionEntry.getKey();
+                OffsetAndMetadata offsetAndMetadata = 
partitionEntry.getValue();
+                if (!condition.isOffsetExpired(offsetAndMetadata, 
currentTimestampMs, config.offsetsRetentionMs())
+                    || openTransactions.contains(groupId, topic, partition, 
committedOffset)) {
+                    return false;
+                }
+            }
+        }
+        return !openTransactions.contains(groupId, committedOffset);
+    }
+
     /**
      * Remove expired offsets for the given group.
      *
@@ -1065,9 +1143,8 @@ public class OffsetMetadataManager {
         offsetsByTopic.forEach((topic, partitions) -> {
             if (!group.isSubscribedToTopic(topic)) {
                 partitions.forEach((partition, offsetAndMetadata) -> {
-                    // We don't expire the offset yet if there is a pending 
transactional offset for the partition.
-                    if (condition.isOffsetExpired(offsetAndMetadata, 
currentTimestampMs, config.offsetsRetentionMs()) &&
-                        !hasPendingTransactionalOffsets(groupId, topic, 
partition)) {
+                    if (condition.isOffsetExpired(offsetAndMetadata, 
currentTimestampMs, config.offsetsRetentionMs())
+                        && !hasPendingTransactionalOffsets(groupId, topic, 
partition)) {
                         appendOffsetCommitTombstone(groupId, topic, partition, 
records);
                         log.debug("[GroupId {}] Expired offset for 
partition={}-{}", groupId, topic, partition);
                     } else {
diff --git 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/metrics/GroupCoordinatorMetrics.java
 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/metrics/GroupCoordinatorMetrics.java
index af67090bd02..185ea52d83a 100644
--- 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/metrics/GroupCoordinatorMetrics.java
+++ 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/metrics/GroupCoordinatorMetrics.java
@@ -122,6 +122,10 @@ public class GroupCoordinatorMetrics extends 
CoordinatorMetrics implements AutoC
     public static final String CONSUMER_GROUP_REBALANCES_SENSOR_NAME = 
"ConsumerGroupRebalances";
     public static final String SHARE_GROUP_REBALANCES_SENSOR_NAME = 
"ShareGroupRebalances";
     public static final String STREAMS_GROUP_REBALANCES_SENSOR_NAME = 
"StreamsGroupRebalances";
+    public static final String 
STREAMS_GROUP_TOPOLOGY_DESCRIPTION_CLEANUP_CYCLE_RUNS_SENSOR_NAME = 
"StreamsGroupTopologyDescriptionCleanupCycleRuns";
+    public static final String 
STREAMS_GROUP_TOPOLOGY_DESCRIPTION_CLEANUP_ELIGIBLE_GROUPS_SENSOR_NAME = 
"StreamsGroupTopologyDescriptionCleanupEligibleGroups";
+    public static final String 
STREAMS_GROUP_TOPOLOGY_DESCRIPTION_DELETE_SUCCESS_SENSOR_NAME = 
"StreamsGroupTopologyDescriptionDeleteSuccess";
+    public static final String 
STREAMS_GROUP_TOPOLOGY_DESCRIPTION_DELETE_ERROR_SENSOR_NAME = 
"StreamsGroupTopologyDescriptionDeleteError";
 
     private final MetricName offsetCountMetricName;
     private final MetricName classicGroupCountMetricName;
@@ -398,6 +402,46 @@ public class GroupCoordinatorMetrics extends 
CoordinatorMetrics implements AutoC
                 METRICS_GROUP,
                 "The total number of streams group rebalances")));
 
+        Sensor streamsGroupTopologyDescriptionCleanupCycleRunsSensor =
+            
metrics.sensor(STREAMS_GROUP_TOPOLOGY_DESCRIPTION_CLEANUP_CYCLE_RUNS_SENSOR_NAME);
+        streamsGroupTopologyDescriptionCleanupCycleRunsSensor.add(new Meter(
+            
metrics.metricName("streams-group-topology-description-cleanup-cycle-rate",
+                METRICS_GROUP,
+                "The rate at which the topology-description cleanup cycle 
fires"),
+            
metrics.metricName("streams-group-topology-description-cleanup-cycle-count",
+                METRICS_GROUP,
+                "The total number of topology-description cleanup cycles that 
ran")));
+
+        Sensor streamsGroupTopologyDescriptionCleanupEligibleGroupsSensor =
+            
metrics.sensor(STREAMS_GROUP_TOPOLOGY_DESCRIPTION_CLEANUP_ELIGIBLE_GROUPS_SENSOR_NAME);
+        streamsGroupTopologyDescriptionCleanupEligibleGroupsSensor.add(new 
Meter(
+            
metrics.metricName("streams-group-topology-description-cleanup-eligible-rate",
+                METRICS_GROUP,
+                "The rate of streams groups identified as eligible for 
topology-description cleanup"),
+            
metrics.metricName("streams-group-topology-description-cleanup-eligible-count",
+                METRICS_GROUP,
+                "The total number of streams groups identified as eligible for 
topology-description cleanup")));
+
+        Sensor streamsGroupTopologyDescriptionDeleteSuccessSensor =
+            
metrics.sensor(STREAMS_GROUP_TOPOLOGY_DESCRIPTION_DELETE_SUCCESS_SENSOR_NAME);
+        streamsGroupTopologyDescriptionDeleteSuccessSensor.add(new Meter(
+            
metrics.metricName("streams-group-topology-description-delete-success-rate",
+                METRICS_GROUP,
+                "The rate of successful plugin.deleteTopology calls 
(DeleteGroups and periodic cleanup combined)"),
+            
metrics.metricName("streams-group-topology-description-delete-success-count",
+                METRICS_GROUP,
+                "The total number of successful plugin.deleteTopology calls 
(DeleteGroups and periodic cleanup combined)")));
+
+        Sensor streamsGroupTopologyDescriptionDeleteErrorSensor =
+            
metrics.sensor(STREAMS_GROUP_TOPOLOGY_DESCRIPTION_DELETE_ERROR_SENSOR_NAME);
+        streamsGroupTopologyDescriptionDeleteErrorSensor.add(new Meter(
+            
metrics.metricName("streams-group-topology-description-delete-error-rate",
+                METRICS_GROUP,
+                "The rate of failed plugin.deleteTopology calls (DeleteGroups 
and periodic cleanup combined)"),
+            
metrics.metricName("streams-group-topology-description-delete-error-count",
+                METRICS_GROUP,
+                "The total number of failed plugin.deleteTopology calls 
(DeleteGroups and periodic cleanup combined)")));
+
         globalSensors = Collections.unmodifiableMap(Utils.mkMap(
             Utils.mkEntry(OFFSET_COMMITS_SENSOR_NAME, offsetCommitsSensor),
             Utils.mkEntry(OFFSET_EXPIRED_SENSOR_NAME, offsetExpiredSensor),
@@ -405,10 +449,37 @@ public class GroupCoordinatorMetrics extends 
CoordinatorMetrics implements AutoC
             Utils.mkEntry(CLASSIC_GROUP_COMPLETED_REBALANCES_SENSOR_NAME, 
classicGroupCompletedRebalancesSensor),
             Utils.mkEntry(CONSUMER_GROUP_REBALANCES_SENSOR_NAME, 
consumerGroupRebalanceSensor),
             Utils.mkEntry(SHARE_GROUP_REBALANCES_SENSOR_NAME, 
shareGroupRebalanceSensor),
-            Utils.mkEntry(STREAMS_GROUP_REBALANCES_SENSOR_NAME, 
streamsGroupRebalanceSensor)
+            Utils.mkEntry(STREAMS_GROUP_REBALANCES_SENSOR_NAME, 
streamsGroupRebalanceSensor),
+            
Utils.mkEntry(STREAMS_GROUP_TOPOLOGY_DESCRIPTION_CLEANUP_CYCLE_RUNS_SENSOR_NAME,
+                streamsGroupTopologyDescriptionCleanupCycleRunsSensor),
+            
Utils.mkEntry(STREAMS_GROUP_TOPOLOGY_DESCRIPTION_CLEANUP_ELIGIBLE_GROUPS_SENSOR_NAME,
+                streamsGroupTopologyDescriptionCleanupEligibleGroupsSensor),
+            
Utils.mkEntry(STREAMS_GROUP_TOPOLOGY_DESCRIPTION_DELETE_SUCCESS_SENSOR_NAME,
+                streamsGroupTopologyDescriptionDeleteSuccessSensor),
+            
Utils.mkEntry(STREAMS_GROUP_TOPOLOGY_DESCRIPTION_DELETE_ERROR_SENSOR_NAME,
+                streamsGroupTopologyDescriptionDeleteErrorSensor)
         ));
     }
 
+    /**
+     * Record a single observation against a global sensor by name. No-op if 
the sensor is not
+     * configured (e.g. tests that build the metrics without the streams 
plugin scaffolding).
+     */
+    public void recordSensor(String name) {
+        Sensor sensor = globalSensors.get(name);
+        if (sensor != null) sensor.record();
+    }
+
+    /**
+     * Record a numeric observation against a global sensor by name. No-op if 
the sensor is
+     * not configured. Used by the topology-description cleanup cycle to 
report the eligible
+     * group count per cycle.
+     */
+    public void recordSensor(String name, double value) {
+        Sensor sensor = globalSensors.get(name);
+        if (sensor != null) sensor.record(value);
+    }
+
     private Long numOffsets() {
         return 
shards.values().stream().mapToLong(GroupCoordinatorMetricsShard::numOffsets).sum();
     }
@@ -491,7 +562,11 @@ public class GroupCoordinatorMetrics extends 
CoordinatorMetrics implements AutoC
             CLASSIC_GROUP_COMPLETED_REBALANCES_SENSOR_NAME,
             CONSUMER_GROUP_REBALANCES_SENSOR_NAME,
             SHARE_GROUP_REBALANCES_SENSOR_NAME,
-            STREAMS_GROUP_REBALANCES_SENSOR_NAME
+            STREAMS_GROUP_REBALANCES_SENSOR_NAME,
+            STREAMS_GROUP_TOPOLOGY_DESCRIPTION_CLEANUP_CYCLE_RUNS_SENSOR_NAME,
+            
STREAMS_GROUP_TOPOLOGY_DESCRIPTION_CLEANUP_ELIGIBLE_GROUPS_SENSOR_NAME,
+            STREAMS_GROUP_TOPOLOGY_DESCRIPTION_DELETE_SUCCESS_SENSOR_NAME,
+            STREAMS_GROUP_TOPOLOGY_DESCRIPTION_DELETE_ERROR_SENSOR_NAME
         ).forEach(metrics::removeSensor);
     }
 
diff --git 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroup.java
 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroup.java
index 39caa8ddb9d..85c0d1d894f 100644
--- 
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroup.java
+++ 
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroup.java
@@ -988,6 +988,18 @@ public class StreamsGroup implements Group {
         return state() == StreamsGroupState.EMPTY;
     }
 
+    /**
+     * Snapshot-aware counterpart to {@link #isEmpty()}: returns whether the 
group was in the
+     * {@code EMPTY} state at {@code committedOffset}. Used by read operations 
(e.g. the
+     * topology-description cleanup eligibility scan) that must observe 
committed state only —
+     * a member-join write that has been committed but not yet applied to the 
live state, or
+     * vice versa, would otherwise widen the scan-vs-clear race window beyond 
what the runtime
+     * contract allows.
+     */
+    public boolean isEmpty(long committedOffset) {
+        return state.get(committedOffset) == StreamsGroupState.EMPTY;
+    }
+
     /**
      * See {@link org.apache.kafka.coordinator.group.OffsetExpirationCondition}
      *
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 61da755aa9e..5955155b9fa 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
@@ -31,6 +31,8 @@ import org.apache.kafka.common.utils.internals.LogContext;
 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 org.apache.kafka.server.util.timer.Timer;
+import org.apache.kafka.server.util.timer.TimerTask;
 
 import org.slf4j.Logger;
 
@@ -43,18 +45,22 @@ import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.CompletionException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Supplier;
 
 import static 
org.apache.kafka.common.requests.StreamsGroupDescribeResponse.TOPOLOGY_DESCRIPTION_STATUS_AVAILABLE;
 import static 
org.apache.kafka.common.requests.StreamsGroupDescribeResponse.TOPOLOGY_DESCRIPTION_STATUS_ERROR;
 import static 
org.apache.kafka.common.requests.StreamsGroupDescribeResponse.TOPOLOGY_DESCRIPTION_STATUS_NOT_STORED;
 
 /**
- * Broker-level component that owns the streams-group topology description 
plugin
- * reference and the per-group re-solicitation back-off. The chain that drives 
a push
- * RPC (validate → convert → plugin → metadata write → back-off mutation) 
lives on
- * {@code GroupCoordinatorService}; this class exposes the building blocks 
(plugin
- * invocation, back-off mutations) and the heartbeat-path gate, but does not 
assemble
- * the chain itself.
+ * Broker-level component that owns the streams-group topology description 
plugin reference
+ * and the per-group re-solicitation back-off. The push-RPC chain (validate → 
convert →
+ * plugin → metadata write → back-off mutation) and the periodic cleanup 
cycle's body live
+ * on {@code GroupCoordinatorService}; this class exposes the building blocks
+ * ({@link #invokeSetTopology}, {@link #completeEpochWrite}, {@link 
#armBackoff},
+ * {@link #invokeDeleteTopologies}, {@link #clearBackoffGroup}) and one 
harness method,
+ * {@link #startCleanupCycle}, that wraps a service-supplied cycle body with 
single-flight
+ * scheduling on the broker timer.
  *
  * <p>This class is broker-level (one instance per {@code 
GroupCoordinatorService}); the
  * back-off map is keyed by {@code groupId} and shared across all partitions 
hosted on the
@@ -68,6 +74,29 @@ public class StreamsGroupTopologyDescriptionManager 
implements AutoCloseable {
     private final Optional<StreamsGroupTopologyDescriptionPlugin> plugin;
     private final StreamsGroupTopologyDescriptionBackoff backoff;
 
+    /**
+     * True between {@link #startCleanupCycle} and {@link #close}. The {@link 
TimerTask}
+     * checks this on each tick before invoking the cycle supplier, so {@code 
close} flips
+     * the flag and the next tick refuses to fire even if {@code cancel} on 
the task races.
+     */
+    private final AtomicBoolean running = new AtomicBoolean(false);
+
+    /**
+     * Single-flight guard for the periodic cleanup cycle: a tick that fires 
while the
+     * previous cycle is still settling is dropped with a warn-level log. Set 
true in
+     * {@link #runOnce} before invoking the supplier; released by the terminal
+     * {@code whenComplete} attached to the future the supplier returns.
+     */
+    private final AtomicBoolean cycleInFlight = new AtomicBoolean(false);
+
+    /**
+     * The currently-scheduled cleanup tick on the broker-level {@link Timer}.
+     * Self-rescheduled inside the {@link TimerTask}'s {@code run}; {@link 
#close} cancels
+     * this snapshot and the task's own re-arm check observes {@code running 
== false}
+     * so the next tick does not re-schedule itself.
+     */
+    private volatile TimerTask scheduledTask;
+
     public StreamsGroupTopologyDescriptionManager(
         LogContext logContext,
         Optional<StreamsGroupTopologyDescriptionPlugin> plugin,
@@ -79,18 +108,108 @@ public class StreamsGroupTopologyDescriptionManager 
implements AutoCloseable {
     }
 
     /**
-     * Release plugin-side resources. The plugin is instantiated by the 
service via
-     * {@code config.getConfiguredInstance(...)}, so the service owns it and 
must close
-     * it on shutdown to avoid leaking threads, network clients, etc. across 
broker
-     * restart cycles.
+     * Arm the periodic cleanup cycle. The manager owns the scheduling harness 
— timer task,
+     * single-flight guard, running flag — and fires the service-supplied 
{@code cycleSupplier}
+     * on every tick; the cycle body (which operations to schedule on the 
runtime in what
+     * order) lives entirely on the service side. No-op when no plugin is 
configured. Must
+     * be called before {@link #close}; a second call while already running 
logs and is
+     * otherwise a no-op.
+     */
+    public void startCleanupCycle(
+        Timer timer,
+        long cleanupCheckIntervalMs,
+        Supplier<CompletableFuture<?>> cycleSupplier
+    ) {
+        if (plugin.isEmpty()) return;
+        if (!running.compareAndSet(false, true)) {
+            log.warn("Topology-description cleanup cycle is already started.");
+            return;
+        }
+        scheduleNextTick(timer, cleanupCheckIntervalMs, cycleSupplier);
+    }
+
+    /**
+     * Stop the cleanup cycle and release plugin-side resources. Flips {@code 
running}
+     * false (so the next timer tick refuses to fire) and cancels the 
currently-scheduled
+     * tick, then closes the plugin. Called by {@code 
GroupCoordinatorService.shutdown}
+     * before the runtime is closed, so writes already scheduled by the 
previous tick
+     * drain through their own futures rather than racing the runtime 
tear-down.
      */
     @Override
     public void close() throws Exception {
+        if (running.compareAndSet(true, false)) {
+            TimerTask snapshot = scheduledTask;
+            if (snapshot != null) {
+                snapshot.cancel();
+            }
+        }
         if (plugin.isPresent()) {
             plugin.get().close();
         }
     }
 
+    private void scheduleNextTick(
+        Timer timer,
+        long cleanupCheckIntervalMs,
+        Supplier<CompletableFuture<?>> cycleSupplier
+    ) {
+        if (!running.get()) return;
+        TimerTask task = new TimerTask(cleanupCheckIntervalMs) {
+            @Override
+            public void run() {
+                if (!running.get()) return;
+                try {
+                    runOnce(cycleSupplier);
+                } catch (Throwable t) {
+                    log.warn("Unexpected error running topology-description 
cleanup cycle.", t);
+                }
+                if (running.get()) scheduleNextTick(timer, 
cleanupCheckIntervalMs, cycleSupplier);
+            }
+        };
+        scheduledTask = task;
+        timer.add(task);
+    }
+
+    /**
+     * Invoke {@code cycleSupplier} once under the single-flight guard: a call 
that fires
+     * while a previous cycle is still settling its returned future is dropped 
with a
+     * warn-level log. Released in the terminal {@code whenComplete}; a 
synchronous throw
+     * from the supplier releases the flag before propagating so the next tick 
can run.
+     */
+    // Visible for testing.
+    public void runOnce(Supplier<CompletableFuture<?>> cycleSupplier) {
+        if (!cycleInFlight.compareAndSet(false, true)) {
+            log.warn("Topology-description cleanup cycle skipped: previous 
cycle is still in flight.");
+            return;
+        }
+        try {
+            CompletableFuture<?> chain = cycleSupplier.get();
+            if (chain == null) {
+                cycleInFlight.set(false);
+                return;
+            }
+            chain.whenComplete((__, throwable) -> {
+                if (throwable != null) {
+                    log.warn("Topology-description cleanup cycle failed to 
complete cleanly.", throwable);
+                }
+                cycleInFlight.set(false);
+            });
+        } catch (Throwable t) {
+            cycleInFlight.set(false);
+            throw t;
+        }
+    }
+
+    // Visible for testing.
+    TimerTask scheduledCleanupTask() {
+        return scheduledTask;
+    }
+
+    // Visible for testing.
+    boolean isRunning() {
+        return running.get();
+    }
+
     /**
      * @return true if a topology description plugin is configured on this 
broker.
      */
@@ -263,10 +382,10 @@ public class StreamsGroupTopologyDescriptionManager 
implements AutoCloseable {
      * 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.
+     * <p>Pure plugin invocation: does not read group state, does not touch 
the back-off
+     * map, and does not record metrics. The service layer pre-filters the 
input, records
+     * delete-success / delete-error sensors on the returned failure count, 
and is
+     * responsible for invoking {@link #clearBackoffGroup} for the groups it 
chose to clear.
      */
     public CompletableFuture<Map<String, ApiError>> 
invokeDeleteTopologies(Set<String> groupIds) {
         if (plugin.isEmpty() || groupIds.isEmpty()) {
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 43a06050339..204e1e95509 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
@@ -47,6 +47,7 @@ import org.apache.kafka.server.util.timer.MockTimer;
 
 import org.junit.jupiter.api.Test;
 
+import java.time.Duration;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
@@ -63,6 +64,7 @@ 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.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyInt;
@@ -917,6 +919,293 @@ public class 
GroupCoordinatorServiceTopologyDescriptionTest {
         assertEquals("Topology description plugin failed to delete the 
topology.", badResult.errorMessage());
     }
 
+    @Test
+    public void testCleanupCycleNoOpWhenNoPlugin() {
+        // No plugin configured -> the cycle must not even touch the runtime.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        GroupCoordinatorService service = buildService(runtime, 
Optional.empty(), true);
+
+        service.runOneStreamsTopologyCleanupCycle();
+
+        verify(runtime, 
never()).scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"),
 any());
+        verify(runtime, 
never()).scheduleWriteOperation(eq("clear-stored-topology-epoch"), any(), 
any());
+    }
+
+    @Test
+    public void testCleanupCycleClearsStoredEpochOnPluginSuccess() {
+        // Eligibility scan returns one group at storedEpoch=4; plugin 
succeeds; the cycle must
+        // schedule the conditional clear-stored write echoing the same epoch 
back so a
+        // concurrent setTopology that has advanced the field is preserved.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        
when(plugin.deleteTopology("foo")).thenReturn(CompletableFuture.completedFuture(null));
+        
when(runtime.scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"),
 any()))
+            
.thenReturn(List.of(CompletableFuture.completedFuture(Map.of("foo", 4))));
+        when(runtime.scheduleWriteOperation(eq("clear-stored-topology-epoch"), 
eq(GROUP_TP), any()))
+            .thenReturn(CompletableFuture.completedFuture(null));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+        service.runOneStreamsTopologyCleanupCycle();
+
+        verify(plugin, times(1)).deleteTopology("foo");
+        verify(runtime, 
times(1)).scheduleWriteOperation(eq("clear-stored-topology-epoch"), 
eq(GROUP_TP), any());
+    }
+
+    @Test
+    public void testCleanupCycleSkipsClearOnPluginFailure() {
+        // Plugin fails -> the cycle must NOT clear stored epoch; the group 
stays gated on
+        // the next sweep and the next cycle retries the plugin call.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        when(plugin.deleteTopology("foo"))
+            .thenReturn(CompletableFuture.failedFuture(new 
RuntimeException("plugin offline")));
+        
when(runtime.scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"),
 any()))
+            
.thenReturn(List.of(CompletableFuture.completedFuture(Map.of("foo", 4))));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+        service.runOneStreamsTopologyCleanupCycle();
+
+        verify(plugin, times(1)).deleteTopology("foo");
+        verify(runtime, 
never()).scheduleWriteOperation(eq("clear-stored-topology-epoch"), any(), 
any());
+    }
+
+    @Test
+    public void testCleanupCyclePreservesBackoffOnPluginFailure() throws 
Exception {
+        // Unconditionally clearing the broker-wide back-off entry on
+        // a failed plugin.deleteTopology bypasses push-path ratchet for any 
group
+        // the cycle touches. If a member rejoins between the failing scan and 
the next cycle,
+        // the push-path back-off check finds no entry and re-attacks the 
broken plugin at
+        // attempts=0 every join. The cycle must leave the entry in place so 
the existing
+        // exponential window still throttles concurrent set-topology pushes.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        when(plugin.deleteTopology("foo"))
+            .thenReturn(CompletableFuture.failedFuture(new 
RuntimeException("plugin offline")));
+        
when(runtime.scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"),
 any()))
+            
.thenReturn(List.of(CompletableFuture.completedFuture(Map.of("foo", 4))));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+        // Arm a back-off entry at the same currentEpoch we will probe with 
the heartbeat helper,
+        // then run a failing cycle. The helper's gate calls armIfNotActive at 
that epoch — if
+        // the cycle wiped the entry the heartbeat would arm freshly and set 
the flag; if the
+        // entry survived the heartbeat sees an active window and the flag 
stays unset.
+        service.streamsGroupTopologyDescriptionManager().armBackoff("foo", 4);
+        service.runOneStreamsTopologyCleanupCycle();
+
+        assertFalse(heartbeatTopologyDescriptionRequired(runtime, service, 4, 
2, -1),
+            "failed cycle must not clear the back-off entry");
+    }
+
+    @Test
+    public void testCleanupCycleClearsBackoffOnPluginSuccess() throws 
Exception {
+        // Symmetric counterpart: a successful plugin.deleteTopology means the 
group is on its
+        // way to tombstone — the back-off entry is no longer load-bearing for 
any future state
+        // of this groupId. The cycle clears it; a subsequent re-creation of 
the same id is a
+        // fresh lifecycle and arms a fresh chain.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        
when(plugin.deleteTopology("foo")).thenReturn(CompletableFuture.completedFuture(null));
+        
when(runtime.scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"),
 any()))
+            
.thenReturn(List.of(CompletableFuture.completedFuture(Map.of("foo", 4))));
+        when(runtime.scheduleWriteOperation(eq("clear-stored-topology-epoch"), 
eq(GROUP_TP), any()))
+            .thenReturn(CompletableFuture.completedFuture(null));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+        service.streamsGroupTopologyDescriptionManager().armBackoff("foo", 4);
+        service.runOneStreamsTopologyCleanupCycle();
+
+        assertTrue(heartbeatTopologyDescriptionRequired(runtime, service, 4, 
2, -1),
+            "successful cycle must clear the back-off so a fresh solicitation 
can arm");
+    }
+
+    @Test
+    public void testCleanupCycleEmptyEligibility() {
+        // No groups eligible -> plugin is not called and no clear write is 
scheduled.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        
when(runtime.scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"),
 any()))
+            .thenReturn(List.of(CompletableFuture.completedFuture(Map.of())));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+        service.runOneStreamsTopologyCleanupCycle();
+
+        verify(plugin, never()).deleteTopology(anyString());
+        verify(runtime, 
never()).scheduleWriteOperation(eq("clear-stored-topology-epoch"), any(), 
any());
+    }
+
+    @Test
+    public void testCleanupCycleSingleFlightSkipsConcurrentCycle() {
+        // The first cycle's per-partition read is parked on an unresolved 
future. A second
+        // call must observe cleanupCycleInFlight and skip without scheduling 
another read.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        
when(runtime.scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"),
 any()))
+            .thenReturn(List.of(new CompletableFuture<>()));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+        
service.streamsGroupTopologyDescriptionManager().runOnce(service::runOneStreamsTopologyCleanupCycle);
+        
service.streamsGroupTopologyDescriptionManager().runOnce(service::runOneStreamsTopologyCleanupCycle);
+
+        verify(runtime, 
times(1)).scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"),
 any());
+    }
+
+    @Test
+    public void testCleanupCycleSingleFlightHoldsFlagUntilClearWriteSettles() {
+        // Locks the fix for the gap Copilot flagged: invokeDeleteTopologies's 
plugin call
+        // completes synchronously, but the conditional clear-stored-epoch 
write is parked
+        // on an unresolved future. Until that write settles, the in-flight 
flag must remain
+        // held — a fresh cycle scheduled by the timer would otherwise re-scan 
the same
+        // eligible group (storedEpoch still != -1 because the clear has not 
landed) and
+        // double-fire plugin.deleteTopology. After the parked write completes 
the flag is
+        // released and a subsequent cycle observes a fresh 
scheduleReadAllOperation.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        
when(plugin.deleteTopology("foo")).thenReturn(CompletableFuture.completedFuture(null));
+        
when(runtime.scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"),
 any()))
+            
.thenReturn(List.of(CompletableFuture.completedFuture(Map.of("foo", 4))));
+        CompletableFuture<Object> parkedClearWrite = new CompletableFuture<>();
+        when(runtime.scheduleWriteOperation(eq("clear-stored-topology-epoch"), 
eq(GROUP_TP), any()))
+            .thenReturn(parkedClearWrite);
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+        
service.streamsGroupTopologyDescriptionManager().runOnce(service::runOneStreamsTopologyCleanupCycle);
+        // Plugin call resolved synchronously but clear-write is parked — 
second cycle skipped.
+        
service.streamsGroupTopologyDescriptionManager().runOnce(service::runOneStreamsTopologyCleanupCycle);
+        verify(runtime, 
times(1)).scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"),
 any());
+
+        // Settle the clear-write: flag should now release, next cycle scans 
afresh.
+        parkedClearWrite.complete(null);
+        
service.streamsGroupTopologyDescriptionManager().runOnce(service::runOneStreamsTopologyCleanupCycle);
+        verify(runtime, 
times(2)).scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"),
 any());
+    }
+
+    @Test
+    public void 
testCleanupCycleSingleFlightReleasesFlagOnSynchronousThrowDuringChainConstruction()
 {
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        
when(runtime.scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"),
 any()))
+            .thenThrow(new RuntimeException("synthetic runtime failure during 
chain construction"))
+            .thenReturn(List.of());
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+        // First tick throws — the cycle must still release the flag so a 
subsequent tick runs.
+        assertThrows(RuntimeException.class,
+            () -> 
service.streamsGroupTopologyDescriptionManager().runOnce(service::runOneStreamsTopologyCleanupCycle));
+        // Second tick must reach the runtime read, confirming the flag was 
released.
+        
service.streamsGroupTopologyDescriptionManager().runOnce(service::runOneStreamsTopologyCleanupCycle);
+
+        verify(runtime, times(2)).scheduleReadAllOperation(
+            eq("list-streams-groups-needing-topology-cleanup"), any());
+    }
+
+    @Test
+    public void testCleanupCycleSingleFlightReleasesFlagAfterCycleCompletes() {
+        // The skip case alone does not prove the flag is ever released: a 
buggy whenComplete
+        // (e.g., missing the partitionDone allOf join) would leave it set 
forever and silently
+        // disable every subsequent cycle. Drive a full cycle to completion 
(read resolves,
+        // plugin delete settles, conditional clear write settles), then issue 
a second cycle
+        // and verify it observes the released flag by scheduling a fresh read.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        
when(plugin.deleteTopology("foo")).thenReturn(CompletableFuture.completedFuture(null));
+        
when(runtime.scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"),
 any()))
+            
.thenReturn(List.of(CompletableFuture.completedFuture(Map.of("foo", 4))));
+        when(runtime.scheduleWriteOperation(eq("clear-stored-topology-epoch"), 
eq(GROUP_TP), any()))
+            .thenReturn(CompletableFuture.completedFuture(null));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+        
service.streamsGroupTopologyDescriptionManager().runOnce(service::runOneStreamsTopologyCleanupCycle);
+        // Same runtime, second invocation: must schedule another read (flag 
released).
+        
service.streamsGroupTopologyDescriptionManager().runOnce(service::runOneStreamsTopologyCleanupCycle);
+
+        verify(runtime, 
times(2)).scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"),
 any());
+    }
+
+    @Test
+    public void testCleanupCycleSkipsFollowUpWorkOncePastShutdown() throws 
Exception {
+        // TimerTask.cancel() does not await an in-flight cycle, so a cycle 
that has already
+        // passed the CAS when shutdown fires would otherwise run 
plugin.deleteTopology and
+        // follow-up scheduleWriteOperation against a manager and runtime that 
are about to
+        // close. The per-partition handle inside the manager's cycle now 
checks the
+        // manager's running flag before dispatching the plugin call; this 
locks that
+        // behavior.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        CompletableFuture<Map<String, Integer>> parkedRead = new 
CompletableFuture<>();
+        when(runtime.<Map<String, 
Integer>>scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"),
 any()))
+            .thenReturn(List.of(parkedRead));
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+        // Cycle dispatched, parked on the per-partition read.
+        service.runOneStreamsTopologyCleanupCycle();
+        // service.shutdown() closes the manager which flips its running flag 
false and
+        // cancels the scheduled task (and closes the mocked runtime; mocks 
remain callable
+        // for verification).
+        service.shutdown();
+        // Now resolve the read: the handle runs under running==false and must 
skip the
+        // plugin dispatch + the conditional clear writes that would have 
followed.
+        parkedRead.complete(Map.of("foo", 4));
+
+        verify(plugin, never()).deleteTopology(anyString());
+        verify(runtime, 
never()).scheduleWriteOperation(eq("clear-stored-topology-epoch"), any(), 
any());
+    }
+
+    @Test
+    public void testShutdownCancelsScheduledCleanupTask() throws Exception {
+        // startup() with a plugin configured arms the manager's periodic 
cleanup tick;
+        // shutdown() must close the manager so the timer queue does not 
retain a
+        // self-rescheduling task referencing a torn-down runtime. 
MockTimer.size() filters
+        // cancelled entries, so observing 1 → 0 confirms manager.close()'s 
cancel() landed;
+        // advancing the clock past the interval afterwards must not fire the 
task — both
+        // the queue-skip on cancellation and the TimerTask body's 
running==false defensive
+        // return guard against it.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        MockTimer timer = new MockTimer();
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true, timer);
+
+        assertEquals(1, timer.size());
+
+        service.shutdown();
+
+        assertEquals(0, timer.size());
+        // No cycle should fire even with the clock advanced well past the 
cleanup interval.
+        timer.advanceClock(Duration.ofHours(2).toMillis());
+        verify(runtime, never()).scheduleReadAllOperation(
+            eq("list-streams-groups-needing-topology-cleanup"), any());
+    }
+
+    @Test
+    public void testShutdownSafeWhenNoCleanupTaskScheduled() {
+        // Plugin absent => manager.startCleanupCycle short-circuits before 
scheduling a tick,
+        // so the scheduledTask field stays null. shutdown() must tolerate the 
null snapshot
+        // without throwing — broker close paths must not propagate.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        MockTimer timer = new MockTimer();
+        GroupCoordinatorService service = buildService(runtime, 
Optional.empty(), true, timer);
+
+        assertEquals(0, timer.size());
+        service.shutdown();
+        assertEquals(0, timer.size());
+    }
+
+    @Test
+    public void testCleanupCycleSingleFlightReleasesFlagOnEmptyPartitionList() 
{
+        // Pathological boundary: zero hosted partitions (e.g. broker just 
started, nothing
+        // loaded yet). partitionFutures is empty -> allOf(empty) -> immediate 
completion ->
+        // whenComplete still fires and releases the flag. A subsequent cycle 
must run.
+        CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime = 
mockRuntime();
+        StreamsGroupTopologyDescriptionPlugin plugin = 
mock(StreamsGroupTopologyDescriptionPlugin.class);
+        
when(runtime.scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"),
 any()))
+            .thenReturn(List.of());
+
+        GroupCoordinatorService service = buildService(runtime, 
Optional.of(plugin), true);
+        
service.streamsGroupTopologyDescriptionManager().runOnce(service::runOneStreamsTopologyCleanupCycle);
+        
service.streamsGroupTopologyDescriptionManager().runOnce(service::runOneStreamsTopologyCleanupCycle);
+
+        verify(runtime, 
times(2)).scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"),
 any());
+    }
+
     @Test
     public void testDescribeWithIncludeFlagDisabledLeavesStatusDefault() 
throws Exception {
         // includeTopologyDescription=false -> plugin is not consulted 
regardless of whether
diff --git 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorShardTest.java
 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorShardTest.java
index 5f375d67c2e..ca005188ef9 100644
--- 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorShardTest.java
+++ 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorShardTest.java
@@ -92,6 +92,7 @@ import 
org.apache.kafka.coordinator.group.generated.StreamsGroupTopologyKey;
 import org.apache.kafka.coordinator.group.generated.StreamsGroupTopologyValue;
 import org.apache.kafka.coordinator.group.modern.consumer.ConsumerGroup;
 import org.apache.kafka.coordinator.group.modern.share.ShareGroup;
+import org.apache.kafka.coordinator.group.streams.StreamsGroup;
 import org.apache.kafka.coordinator.group.streams.StreamsGroupDescribeResult;
 import org.apache.kafka.coordinator.group.streams.StreamsGroupHeartbeatResult;
 import org.apache.kafka.coordinator.group.streams.StreamsGroupMember;
@@ -126,16 +127,19 @@ import static 
org.apache.kafka.coordinator.group.GroupCoordinatorShard.GROUP_SIZ
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -1514,6 +1518,274 @@ public class GroupCoordinatorShardTest {
         verify(groupMetadataManager, 
times(0)).maybeDeleteGroup(eq("other-group-id"), any());
     }
 
+    @Test
+    public void 
testListStreamsGroupsNeedingTopologyCleanupFiltersByAllPredicates() {
+        // Covers the full filter chain inside the shard's eligibility scan:
+        //   - maybeGroup == null  -> skip
+        //   - type() != STREAMS   -> skip
+        //   - !isEmpty()          -> skip (live members still on the group)
+        //   - storedEpoch == -1   -> skip (no plugin state to clean)
+        //   - !allOffsetsExpired  -> skip (offsets still in retention)
+        //   - empty + stored != -1 + all expired -> include, value = observed 
storedEpoch
+        GroupMetadataManager groupMetadataManager = 
mock(GroupMetadataManager.class);
+        OffsetMetadataManager offsetMetadataManager = 
mock(OffsetMetadataManager.class);
+        GroupCoordinatorConfig config = mock(GroupCoordinatorConfig.class);
+        when(config.offsetsRetentionCheckIntervalMs()).thenReturn(60 * 60 * 
1000L);
+        MockTime mockTime = new MockTime();
+        MockCoordinatorTimer<CoordinatorRecord> timer = new 
MockCoordinatorTimer<>(mockTime);
+        GroupCoordinatorShard coordinator = new GroupCoordinatorShard(
+            new LogContext(),
+            groupMetadataManager,
+            offsetMetadataManager,
+            mockTime,
+            timer,
+            config,
+            mock(CoordinatorMetrics.class),
+            mock(CoordinatorMetricsShard.class)
+        );
+
+        long committedOffset = 100L;
+        // Six candidate ids, one for each filter branch.
+        when(groupMetadataManager.groupIds(committedOffset)).thenReturn(Set.of(
+            "missing", "not-streams", "non-empty", "default-stored", 
"unexpired-offsets", "eligible"));
+
+        // missing: maybeGroup returns null
+        when(groupMetadataManager.maybeGroup("missing", 
committedOffset)).thenReturn(null);
+
+        // not-streams: type returns CONSUMER (anything != STREAMS)
+        Group notStreams = mock(Group.class);
+        when(notStreams.type()).thenReturn(Group.GroupType.CONSUMER);
+        when(groupMetadataManager.maybeGroup("not-streams", 
committedOffset)).thenReturn(notStreams);
+
+        // non-empty: STREAMS but isEmpty == false
+        StreamsGroup nonEmpty = mock(StreamsGroup.class);
+        when(nonEmpty.type()).thenReturn(Group.GroupType.STREAMS);
+        when(nonEmpty.isEmpty(committedOffset)).thenReturn(false);
+        when(groupMetadataManager.maybeGroup("non-empty", 
committedOffset)).thenReturn(nonEmpty);
+
+        // default-stored: STREAMS, empty, but storedEpoch == -1
+        StreamsGroup defaultStored = mock(StreamsGroup.class);
+        when(defaultStored.type()).thenReturn(Group.GroupType.STREAMS);
+        when(defaultStored.isEmpty(committedOffset)).thenReturn(true);
+        
when(defaultStored.storedDescriptionTopologyEpoch(committedOffset)).thenReturn(-1);
+        when(groupMetadataManager.maybeGroup("default-stored", 
committedOffset)).thenReturn(defaultStored);
+
+        // unexpired-offsets: STREAMS, empty, storedEpoch=5, but offsets are 
not all expired
+        StreamsGroup unexpired = mock(StreamsGroup.class);
+        when(unexpired.type()).thenReturn(Group.GroupType.STREAMS);
+        when(unexpired.isEmpty(committedOffset)).thenReturn(true);
+        
when(unexpired.storedDescriptionTopologyEpoch(committedOffset)).thenReturn(5);
+        when(groupMetadataManager.maybeGroup("unexpired-offsets", 
committedOffset)).thenReturn(unexpired);
+        when(offsetMetadataManager.allOffsetsExpired(eq("unexpired-offsets"), 
anyLong(), anyLong())).thenReturn(false);
+
+        // eligible: passes every predicate; storedEpoch=7 must appear in the 
result map.
+        StreamsGroup eligible = mock(StreamsGroup.class);
+        when(eligible.type()).thenReturn(Group.GroupType.STREAMS);
+        when(eligible.isEmpty(committedOffset)).thenReturn(true);
+        
when(eligible.storedDescriptionTopologyEpoch(committedOffset)).thenReturn(7);
+        when(groupMetadataManager.maybeGroup("eligible", 
committedOffset)).thenReturn(eligible);
+        when(offsetMetadataManager.allOffsetsExpired(eq("eligible"), 
anyLong(), anyLong())).thenReturn(true);
+
+        Map<String, Integer> result = 
coordinator.listStreamsGroupsNeedingTopologyCleanup(committedOffset);
+
+        assertEquals(Map.of("eligible", 7), result);
+    }
+
+    @Test
+    public void 
testListStreamsGroupsNeedingTopologyCleanupSwallowsPerGroupError() {
+        // A per-group failure (e.g. unexpected ClassCastException, NPE in a 
mocked path) must
+        // not abort the whole scan: the cycle is periodic and an isolated bad 
group should be
+        // logged and skipped, leaving the other eligible groups in the result.
+        GroupMetadataManager groupMetadataManager = 
mock(GroupMetadataManager.class);
+        OffsetMetadataManager offsetMetadataManager = 
mock(OffsetMetadataManager.class);
+        GroupCoordinatorConfig config = mock(GroupCoordinatorConfig.class);
+        when(config.offsetsRetentionCheckIntervalMs()).thenReturn(60 * 60 * 
1000L);
+        MockTime mockTime = new MockTime();
+        MockCoordinatorTimer<CoordinatorRecord> timer = new 
MockCoordinatorTimer<>(mockTime);
+        GroupCoordinatorShard coordinator = new GroupCoordinatorShard(
+            new LogContext(),
+            groupMetadataManager,
+            offsetMetadataManager,
+            mockTime,
+            timer,
+            config,
+            mock(CoordinatorMetrics.class),
+            mock(CoordinatorMetricsShard.class)
+        );
+
+        long committedOffset = 0L;
+        
when(groupMetadataManager.groupIds(committedOffset)).thenReturn(Set.of("bad", 
"good"));
+
+        // bad: maybeGroup throws an unexpected RuntimeException mid-scan.
+        when(groupMetadataManager.maybeGroup("bad", committedOffset))
+            .thenThrow(new RuntimeException("synthetic scan failure"));
+
+        // good: a fully eligible group.
+        StreamsGroup good = mock(StreamsGroup.class);
+        when(good.type()).thenReturn(Group.GroupType.STREAMS);
+        when(good.isEmpty(committedOffset)).thenReturn(true);
+        
when(good.storedDescriptionTopologyEpoch(committedOffset)).thenReturn(3);
+        when(groupMetadataManager.maybeGroup("good", 
committedOffset)).thenReturn(good);
+        when(offsetMetadataManager.allOffsetsExpired(eq("good"), anyLong(), 
anyLong())).thenReturn(true);
+
+        Map<String, Integer> result = 
coordinator.listStreamsGroupsNeedingTopologyCleanup(committedOffset);
+
+        assertEquals(Map.of("good", 3), result);
+    }
+
+    @Test
+    public void 
testClearStoredDescriptionTopologyEpochDelegatesToGroupMetadataManager() {
+        // The shard method is a pure delegation, but the cycle's correctness 
depends on the
+        // exact (groupId, expectedStoredEpoch) tuple flowing through 
unchanged and the GMM's
+        // CoordinatorResult being returned verbatim — the per-group 
conditional write reads
+        // its records to decide whether the clear actually persisted.
+        GroupMetadataManager groupMetadataManager = 
mock(GroupMetadataManager.class);
+        OffsetMetadataManager offsetMetadataManager = 
mock(OffsetMetadataManager.class);
+        GroupCoordinatorConfig config = mock(GroupCoordinatorConfig.class);
+        when(config.offsetsRetentionCheckIntervalMs()).thenReturn(60 * 60 * 
1000L);
+        MockTime mockTime = new MockTime();
+        MockCoordinatorTimer<CoordinatorRecord> timer = new 
MockCoordinatorTimer<>(mockTime);
+        GroupCoordinatorShard coordinator = new GroupCoordinatorShard(
+            new LogContext(),
+            groupMetadataManager,
+            offsetMetadataManager,
+            mockTime,
+            timer,
+            config,
+            mock(CoordinatorMetrics.class),
+            mock(CoordinatorMetricsShard.class)
+        );
+
+        CoordinatorResult<Void, CoordinatorRecord> expected = new 
CoordinatorResult<>(List.of());
+        
when(groupMetadataManager.clearStoredDescriptionTopologyEpoch("group-id", 
7)).thenReturn(expected);
+
+        assertSame(expected, 
coordinator.clearStoredDescriptionTopologyEpoch("group-id", 7));
+        
verify(groupMetadataManager).clearStoredDescriptionTopologyEpoch("group-id", 7);
+    }
+
+    @Test
+    public void 
testCleanupGroupMetadataDefersStreamsGroupWithStoredTopologyEpoch() {
+        // Plugin configured + streams group + storedEpoch != -1 + all offsets 
expired:
+        // the gate must skip maybeDeleteGroup so the broker-level 
topology-cleanup cycle
+        // can drive plugin.deleteTopology and clear the field first. The 
offset tombstones
+        // are still written — only the group tombstone is deferred.
+        GroupMetadataManager groupMetadataManager = 
mock(GroupMetadataManager.class);
+        OffsetMetadataManager offsetMetadataManager = 
mock(OffsetMetadataManager.class);
+        GroupCoordinatorConfig config = mock(GroupCoordinatorConfig.class);
+        
when(config.isStreamsGroupTopologyDescriptionPluginConfigured()).thenReturn(true);
+        when(config.offsetsRetentionCheckIntervalMs()).thenReturn(60 * 60 * 
1000L);
+        Time mockTime = new MockTime();
+        MockCoordinatorTimer<CoordinatorRecord> timer = new 
MockCoordinatorTimer<>(mockTime);
+        GroupCoordinatorShard coordinator = new GroupCoordinatorShard(
+            new LogContext(),
+            groupMetadataManager,
+            offsetMetadataManager,
+            mockTime,
+            timer,
+            config,
+            mock(CoordinatorMetrics.class),
+            mock(CoordinatorMetricsShard.class)
+        );
+
+        CoordinatorRecord offsetCommitTombstone = 
GroupCoordinatorRecordHelpers.newOffsetCommitTombstoneRecord("group-id", 
"topic", 0);
+
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<List<CoordinatorRecord>> recordsCapture = 
ArgumentCaptor.forClass(List.class);
+
+        StreamsGroup streamsGroup = mock(StreamsGroup.class);
+        when(streamsGroup.shouldExpire()).thenReturn(true);
+        when(streamsGroup.type()).thenReturn(Group.GroupType.STREAMS);
+        when(streamsGroup.storedDescriptionTopologyEpoch()).thenReturn(4);
+
+        when(groupMetadataManager.groupIds()).thenReturn(Set.of("group-id"));
+        when(groupMetadataManager.group("group-id")).thenReturn(streamsGroup);
+        when(offsetMetadataManager.cleanupExpiredOffsets(eq("group-id"), 
recordsCapture.capture()))
+            .thenAnswer(invocation -> {
+                recordsCapture.getValue().add(offsetCommitTombstone);
+                return true;
+            });
+
+        CoordinatorResult<Void, CoordinatorRecord> result = 
coordinator.cleanupGroupMetadata();
+
+        // Offset tombstone went through; group tombstone is deferred.
+        assertEquals(List.of(offsetCommitTombstone), result.records());
+        verify(offsetMetadataManager, 
times(1)).cleanupExpiredOffsets(eq("group-id"), any());
+        verify(groupMetadataManager, never()).maybeDeleteGroup(eq("group-id"), 
any());
+    }
+
+    @Test
+    public void 
testCleanupGroupMetadataTombstoneStreamsGroupWithoutStoredTopologyEpoch() {
+        // Plugin configured + streams group + storedEpoch == -1: the gate 
must NOT fire, so
+        // the group is tombstoned via the normal path. This is the 
steady-state after the
+        // topology-cleanup cycle has cleared the field on the previous tick.
+        GroupMetadataManager groupMetadataManager = 
mock(GroupMetadataManager.class);
+        OffsetMetadataManager offsetMetadataManager = 
mock(OffsetMetadataManager.class);
+        GroupCoordinatorConfig config = mock(GroupCoordinatorConfig.class);
+        
when(config.isStreamsGroupTopologyDescriptionPluginConfigured()).thenReturn(true);
+        when(config.offsetsRetentionCheckIntervalMs()).thenReturn(60 * 60 * 
1000L);
+        Time mockTime = new MockTime();
+        MockCoordinatorTimer<CoordinatorRecord> timer = new 
MockCoordinatorTimer<>(mockTime);
+        GroupCoordinatorShard coordinator = new GroupCoordinatorShard(
+            new LogContext(),
+            groupMetadataManager,
+            offsetMetadataManager,
+            mockTime,
+            timer,
+            config,
+            mock(CoordinatorMetrics.class),
+            mock(CoordinatorMetricsShard.class)
+        );
+
+        StreamsGroup streamsGroup = mock(StreamsGroup.class);
+        when(streamsGroup.shouldExpire()).thenReturn(true);
+        when(streamsGroup.type()).thenReturn(Group.GroupType.STREAMS);
+        when(streamsGroup.storedDescriptionTopologyEpoch()).thenReturn(-1);
+
+        when(groupMetadataManager.groupIds()).thenReturn(Set.of("group-id"));
+        when(groupMetadataManager.group("group-id")).thenReturn(streamsGroup);
+        when(offsetMetadataManager.cleanupExpiredOffsets(eq("group-id"), 
any())).thenReturn(true);
+
+        coordinator.cleanupGroupMetadata();
+
+        verify(groupMetadataManager, 
times(1)).maybeDeleteGroup(eq("group-id"), any());
+    }
+
+    @Test
+    public void 
testCleanupGroupMetadataIgnoresStoredTopologyEpochWhenNoPluginConfigured() {
+        // Plugin absent on this broker (operator unset / never set): even a 
streams group with
+        // a non-default storedEpoch must NOT be deferred — no cleanup cycle 
is running to
+        // clear it, and leaving the gate engaged would prevent natural 
expiration forever.
+        GroupMetadataManager groupMetadataManager = 
mock(GroupMetadataManager.class);
+        OffsetMetadataManager offsetMetadataManager = 
mock(OffsetMetadataManager.class);
+        GroupCoordinatorConfig config = mock(GroupCoordinatorConfig.class);
+        
when(config.isStreamsGroupTopologyDescriptionPluginConfigured()).thenReturn(false);
+        when(config.offsetsRetentionCheckIntervalMs()).thenReturn(60 * 60 * 
1000L);
+        Time mockTime = new MockTime();
+        MockCoordinatorTimer<CoordinatorRecord> timer = new 
MockCoordinatorTimer<>(mockTime);
+        GroupCoordinatorShard coordinator = new GroupCoordinatorShard(
+            new LogContext(),
+            groupMetadataManager,
+            offsetMetadataManager,
+            mockTime,
+            timer,
+            config,
+            mock(CoordinatorMetrics.class),
+            mock(CoordinatorMetricsShard.class)
+        );
+
+        StreamsGroup streamsGroup = mock(StreamsGroup.class);
+        when(streamsGroup.shouldExpire()).thenReturn(true);
+        when(streamsGroup.type()).thenReturn(Group.GroupType.STREAMS);
+        // storedDescriptionTopologyEpoch is irrelevant — the gate must not 
even look at it.
+        when(groupMetadataManager.groupIds()).thenReturn(Set.of("group-id"));
+        when(groupMetadataManager.group("group-id")).thenReturn(streamsGroup);
+        when(offsetMetadataManager.cleanupExpiredOffsets(eq("group-id"), 
any())).thenReturn(true);
+
+        coordinator.cleanupGroupMetadata();
+
+        verify(groupMetadataManager, 
times(1)).maybeDeleteGroup(eq("group-id"), any());
+    }
+
     @Test
     public void testCleanupGroupMetadataForShareGroup() {
         GroupMetadataManager groupMetadataManager = 
mock(GroupMetadataManager.class);
diff --git 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupMetadataManagerTest.java
 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupMetadataManagerTest.java
index 7acd1a4e016..fbe513e7fd3 100644
--- 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupMetadataManagerTest.java
+++ 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupMetadataManagerTest.java
@@ -10912,6 +10912,55 @@ public class GroupMetadataManagerTest {
         assertEquals(describedGroup, actual.get(0));
     }
 
+    @Test
+    public void 
testClearStoredDescriptionTopologyEpochClearsWhenEpochMatches() {
+        // the cleanup cycle echoes the storedEpoch it observed at scan time 
into
+        // the conditional clear. When the persisted value still matches, the 
write emits a
+        // metadata record setting StoredDescriptionTopologyEpoch back to -1 
while preserving
+        // the other tagged fields (FailedDescriptionTopologyEpoch in 
particular).
+        String groupId = "streams-group";
+        GroupMetadataManagerTestContext context = new 
GroupMetadataManagerTestContext.Builder().build();
+        
context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
+            groupId, 1, 0L, -1, Map.of(), 7, 3));
+
+        CoordinatorResult<Void, CoordinatorRecord> result =
+            
context.groupMetadataManager.clearStoredDescriptionTopologyEpoch(groupId, 7);
+
+        assertEquals(1, result.records().size());
+        // Apply the record and verify storedEpoch is cleared and failedEpoch 
preserved.
+        context.replay(result.records().get(0));
+        StreamsGroup group = 
context.groupMetadataManager.getStreamsGroupOrThrow(groupId);
+        assertEquals(-1, group.storedDescriptionTopologyEpoch());
+        assertEquals(3, group.failedDescriptionTopologyEpoch());
+    }
+
+    @Test
+    public void 
testClearStoredDescriptionTopologyEpochNoOpsWhenEpochMismatches() {
+        // A concurrent setTopology has advanced storedEpoch between the 
cycle's scan and this
+        // write. The clear must be a no-op to preserve the newer push instead 
of silently
+        // undoing it.
+        String groupId = "streams-group";
+        GroupMetadataManagerTestContext context = new 
GroupMetadataManagerTestContext.Builder().build();
+        
context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
+            groupId, 1, 0L, -1, Map.of(), 9, -1));
+
+        CoordinatorResult<Void, CoordinatorRecord> result =
+            
context.groupMetadataManager.clearStoredDescriptionTopologyEpoch(groupId, 7);
+
+        assertEquals(List.of(), result.records());
+    }
+
+    @Test
+    public void testClearStoredDescriptionTopologyEpochNoOpsForMissingGroup() {
+        // Missing groups must not throw — the next cycle will simply not see 
them again.
+        GroupMetadataManagerTestContext context = new 
GroupMetadataManagerTestContext.Builder().build();
+
+        CoordinatorResult<Void, CoordinatorRecord> result =
+            
context.groupMetadataManager.clearStoredDescriptionTopologyEpoch("missing-group",
 7);
+
+        assertEquals(List.of(), result.records());
+    }
+
     @Test
     public void 
testStreamsGroupMetadataReplayRoundTripsTopologyDescriptionEpochs() {
         // KIP-1331: replay must read storedDescriptionTopologyEpoch and 
failedDescriptionTopologyEpoch from the record
diff --git 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/OffsetMetadataManagerTest.java
 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/OffsetMetadataManagerTest.java
index 484a18d9393..960224f7156 100644
--- 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/OffsetMetadataManagerTest.java
+++ 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/OffsetMetadataManagerTest.java
@@ -104,6 +104,8 @@ import static 
org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
@@ -338,6 +340,13 @@ public class OffsetMetadataManagerTest {
             return isOffsetsEmptyForGroup;
         }
 
+        public boolean allOffsetsExpired(String groupId, long 
currentTimestampMs) {
+            // Existing branch tests don't drive snapshot semantics — they 
replay records
+            // directly, so reading at Long.MAX_VALUE is equivalent to 
"latest" and exercises
+            // the same predicates the snapshot-aware path runs.
+            return offsetMetadataManager.allOffsetsExpired(groupId, 
currentTimestampMs, Long.MAX_VALUE);
+        }
+
         public List<OffsetFetchResponseData.OffsetFetchResponseTopics> 
fetchOffsets(
             String groupId,
             List<OffsetFetchRequestData.OffsetFetchRequestTopics> topics,
@@ -3249,6 +3258,149 @@ public class OffsetMetadataManagerTest {
         assertEquals(List.of(), records);
     }
 
+    @Test
+    public void 
testAllOffsetsExpiredReturnsTrueWhenGroupHasNoOffsetsAndNoOpenTxn() {
+        // offsetsByGroup == null branch: no offsets committed for this group, 
and no pending
+        // transactional offsets either. The group's metadata is fully 
expirable.
+        OffsetMetadataManagerTestContext context = new 
OffsetMetadataManagerTestContext.Builder().build();
+        assertTrue(context.allOffsetsExpired("unknown-group-id", 
context.time.milliseconds()));
+    }
+
+    @Test
+    public void 
testAllOffsetsExpiredReturnsFalseWhenGroupHasNoOffsetsButHasOpenTxn() {
+        // offsetsByGroup == null branch with an open transaction recorded for 
the group: still
+        // not expirable, the txn could land more offsets. 
cleanupExpiredOffsets uses the same
+        // gate; allOffsetsExpired must agree.
+        OffsetMetadataManagerTestContext context = new 
OffsetMetadataManagerTestContext.Builder()
+            .withOffsetsRetentionMinutes(1)
+            .build();
+
+        // A pending transactional commit creates an openTransactions entry 
without populating
+        // the durable offsets map.
+        context.commitOffset(42L, "group-id", "foo", 0, 100L, 0, 
context.time.milliseconds());
+        assertFalse(context.allOffsetsExpired("group-id", 
context.time.milliseconds()));
+    }
+
+    @Test
+    public void 
testAllOffsetsExpiredReturnsFalseWhenExpirationConditionEmpty() {
+        // offsetExpirationCondition.isEmpty() branch: e.g., a classic-style 
group with no
+        // expiration policy. The eligibility check must conservatively return 
false rather
+        // than treating the absence of a policy as "always expirable".
+        GroupMetadataManager groupMetadataManager = 
mock(GroupMetadataManager.class);
+        Group group = mock(Group.class);
+        OffsetMetadataManagerTestContext context = new 
OffsetMetadataManagerTestContext.Builder()
+            .withGroupMetadataManager(groupMetadataManager)
+            .build();
+
+        context.commitOffset("group-id", "foo", 0, 100L, 0);
+        when(groupMetadataManager.group(eq("group-id"), 
anyLong())).thenReturn(group);
+        when(group.offsetExpirationCondition()).thenReturn(Optional.empty());
+
+        assertFalse(context.allOffsetsExpired("group-id", 
context.time.milliseconds()));
+    }
+
+    @Test
+    public void testAllOffsetsExpiredReturnsFalseWhenSubscribedToTopic() {
+        // isSubscribedToTopic branch: an offset whose topic is in the group's 
live
+        // subscription is not eligible for expiration regardless of age — 
even after the
+        // retention window elapses, an active subscription holds the offset.
+        GroupMetadataManager groupMetadataManager = 
mock(GroupMetadataManager.class);
+        Group group = mock(Group.class);
+        OffsetMetadataManagerTestContext context = new 
OffsetMetadataManagerTestContext.Builder()
+            .withGroupMetadataManager(groupMetadataManager)
+            .withOffsetsRetentionMinutes(1)
+            .build();
+
+        context.commitOffset("group-id", "foo", 0, 100L, 0, 
context.time.milliseconds());
+        context.time.sleep(Duration.ofMinutes(1).toMillis());
+
+        when(groupMetadataManager.group(eq("group-id"), 
anyLong())).thenReturn(group);
+        when(group.offsetExpirationCondition()).thenReturn(Optional.of(
+            new OffsetExpirationConditionImpl(offsetAndMetadata -> 
offsetAndMetadata.commitTimestampMs)));
+        when(group.isSubscribedToTopic("foo")).thenReturn(true);
+
+        assertFalse(context.allOffsetsExpired("group-id", 
context.time.milliseconds()));
+    }
+
+    @Test
+    public void 
testAllOffsetsExpiredReturnsFalseWhenPendingTransactionalOffset() {
+        // Pending transactional offset branch: an unsubscribed topic whose 
retention window
+        // has elapsed is still not eligible if a transactional offset is 
pending on the same
+        // partition — the txn could commit a fresh value the cleanup would 
lose.
+        GroupMetadataManager groupMetadataManager = 
mock(GroupMetadataManager.class);
+        Group group = mock(Group.class);
+        OffsetMetadataManagerTestContext context = new 
OffsetMetadataManagerTestContext.Builder()
+            .withGroupMetadataManager(groupMetadataManager)
+            .withOffsetsRetentionMinutes(1)
+            .build();
+
+        long commitTimestamp = context.time.milliseconds();
+        context.commitOffset("group-id", "foo", 0, 100L, 0, commitTimestamp);
+        // Concurrent transactional commit on the same partition; not yet 
visible.
+        context.commitOffset(10L, "group-id", "foo", 0, 101L, 0, 
commitTimestamp + 500);
+        context.time.sleep(Duration.ofMinutes(1).toMillis());
+
+        when(groupMetadataManager.group(eq("group-id"), 
anyLong())).thenReturn(group);
+        when(group.offsetExpirationCondition()).thenReturn(Optional.of(
+            new OffsetExpirationConditionImpl(offsetAndMetadata -> 
offsetAndMetadata.commitTimestampMs)));
+        when(group.isSubscribedToTopic("foo")).thenReturn(false);
+
+        assertFalse(context.allOffsetsExpired("group-id", 
context.time.milliseconds()));
+    }
+
+    @Test
+    public void 
testAllOffsetsExpiredReturnsFalseWhenOpenTxnOnPartitionWithoutCommittedOffset() 
{
+        // Trailing group-level !openTransactions.contains check when 
offsetsByTopic is non-null:
+        // foo/0 has an expired committed offset and no pending txn on the 
same partition (the
+        // per-partition loop sees it as expirable), but a transactional 
commit is in flight on
+        // bar/0 with no durable committed offset there. The per-partition 
loop never visits
+        // bar/0 because offsetsByGroup has no entry for bar, so only the 
trailing group-level
+        // check catches the open transaction and keeps the group ineligible.
+        GroupMetadataManager groupMetadataManager = 
mock(GroupMetadataManager.class);
+        Group group = mock(Group.class);
+        OffsetMetadataManagerTestContext context = new 
OffsetMetadataManagerTestContext.Builder()
+            .withGroupMetadataManager(groupMetadataManager)
+            .withOffsetsRetentionMinutes(1)
+            .build();
+
+        long commitTimestamp = context.time.milliseconds();
+        context.commitOffset("group-id", "foo", 0, 100L, 0, commitTimestamp);
+        // Transactional commit on a different topic-partition; never visited 
by the per-partition
+        // loop in allOffsetsExpired because offsetsByGroup has no entry for 
bar.
+        context.commitOffset(10L, "group-id", "bar", 0, 200L, 0, 
commitTimestamp + 500);
+        context.time.sleep(Duration.ofMinutes(1).toMillis());
+
+        when(groupMetadataManager.group(eq("group-id"), 
anyLong())).thenReturn(group);
+        when(group.offsetExpirationCondition()).thenReturn(Optional.of(
+            new OffsetExpirationConditionImpl(offsetAndMetadata -> 
offsetAndMetadata.commitTimestampMs)));
+        when(group.isSubscribedToTopic("foo")).thenReturn(false);
+
+        assertFalse(context.allOffsetsExpired("group-id", 
context.time.milliseconds()));
+    }
+
+    @Test
+    public void 
testAllOffsetsExpiredReturnsTrueWhenAllOffsetsPastRetentionAndUnsubscribed() {
+        // Happy path: an unsubscribed topic whose offset has aged past 
retention, no pending
+        // transactional offsets. The group is fully eligible for the 
downstream cleanup pass.
+        GroupMetadataManager groupMetadataManager = 
mock(GroupMetadataManager.class);
+        Group group = mock(Group.class);
+        OffsetMetadataManagerTestContext context = new 
OffsetMetadataManagerTestContext.Builder()
+            .withGroupMetadataManager(groupMetadataManager)
+            .withOffsetsRetentionMinutes(1)
+            .build();
+
+        long commitTimestamp = context.time.milliseconds();
+        context.commitOffset("group-id", "foo", 0, 100L, 0, commitTimestamp);
+        context.time.sleep(Duration.ofMinutes(1).toMillis());
+
+        when(groupMetadataManager.group(eq("group-id"), 
anyLong())).thenReturn(group);
+        when(group.offsetExpirationCondition()).thenReturn(Optional.of(
+            new OffsetExpirationConditionImpl(offsetAndMetadata -> 
offsetAndMetadata.commitTimestampMs)));
+        when(group.isSubscribedToTopic("foo")).thenReturn(false);
+
+        assertTrue(context.allOffsetsExpired("group-id", 
context.time.milliseconds()));
+    }
+
     private static OffsetFetchResponseData.OffsetFetchResponsePartitions 
mkOffsetPartitionResponse(
         int partition,
         long offset,
diff --git 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/metrics/GroupCoordinatorMetricsTest.java
 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/metrics/GroupCoordinatorMetricsTest.java
index 70da4657464..df031f07935 100644
--- 
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/metrics/GroupCoordinatorMetricsTest.java
+++ 
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/metrics/GroupCoordinatorMetricsTest.java
@@ -150,6 +150,14 @@ public class GroupCoordinatorMetricsTest {
                 Map.of("protocol", Group.GroupType.STREAMS.toString())),
             metrics.metricName("streams-group-rebalance-rate", 
GroupCoordinatorMetrics.METRICS_GROUP),
             metrics.metricName("streams-group-rebalance-count", 
GroupCoordinatorMetrics.METRICS_GROUP),
+            
metrics.metricName("streams-group-topology-description-cleanup-cycle-rate", 
GroupCoordinatorMetrics.METRICS_GROUP),
+            
metrics.metricName("streams-group-topology-description-cleanup-cycle-count", 
GroupCoordinatorMetrics.METRICS_GROUP),
+            
metrics.metricName("streams-group-topology-description-cleanup-eligible-rate", 
GroupCoordinatorMetrics.METRICS_GROUP),
+            
metrics.metricName("streams-group-topology-description-cleanup-eligible-count", 
GroupCoordinatorMetrics.METRICS_GROUP),
+            
metrics.metricName("streams-group-topology-description-delete-success-rate", 
GroupCoordinatorMetrics.METRICS_GROUP),
+            
metrics.metricName("streams-group-topology-description-delete-success-count", 
GroupCoordinatorMetrics.METRICS_GROUP),
+            
metrics.metricName("streams-group-topology-description-delete-error-rate", 
GroupCoordinatorMetrics.METRICS_GROUP),
+            
metrics.metricName("streams-group-topology-description-delete-error-count", 
GroupCoordinatorMetrics.METRICS_GROUP),
             metrics.metricName(
                 "streams-group-count",
                 GroupCoordinatorMetrics.METRICS_GROUP,

Reply via email to