lucasbru commented on code in PR #22622:
URL: https://github.com/apache/kafka/pull/22622#discussion_r3442406974


##########
group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorShard.java:
##########
@@ -982,6 +983,52 @@ public Set<String> 
streamsGroupsWithStoredTopologyDescription(
         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>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) {

Review Comment:
   Eligibility reads mix snapshot and live state. `maybeGroup(groupId, 
committedOffset)` and `storedDescriptionTopologyEpoch(committedOffset)` are 
snapshotted, but `groupMetadataManager.groupIds()` (live keySet), 
`streamsGroup.isEmpty()` (live field), and 
`offsetMetadataManager.allOffsetsExpired(groupId, now)` (live state + 
wall-clock) are not.
   
   The docstring above acknowledges a "concurrent setTopology" race window but 
argues it's narrow because `isEmpty` gates eligibility. `isEmpty()` itself is 
read live, not at `committedOffset` — so a member-join write 
committed-but-not-yet-applied to the live view, or vice versa, can widen the 
window beyond what the comment claims.
   
   Either snapshot the whole eligibility check at `committedOffset` (add an 
`isEmpty(long)` overload similar to the existing 
`storedDescriptionTopologyEpoch(long)`), or update the docstring to say what 
the actual race window looks like.



##########
group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorService.java:
##########
@@ -786,6 +805,163 @@ private void 
throwIfStreamsGroupTopologyDescriptionUpdateInvalid(
         throwIfNull(request.topologyDescription(), "TopologyDescription can't 
be null.");
     }
 
+    /**
+     * Schedule the next topology-description cleanup tick on the broker-level 
{@link Timer}.
+     * The {@link TimerTask} self-reschedules from inside its own {@code run} 
so the cycle keeps
+     * firing every {@code offsets.retention.check.interval.ms} until {@code 
shutdown} flips
+     * {@code isActive} to false. No-op when no plugin is configured.
+     *
+     * <p>This lifecycle lives on the service rather than on
+     * {@link StreamsGroupTopologyDescriptionManager} because the cycle drives 
a runtime chain
+     * (per-shard read → per-group plugin call → conditional metadata write); 
per the existing
+     * separation, the manager exposes plugin-invocation and back-off building 
blocks and the
+     * service assembles the chain.
+     */
+    private void scheduleStreamsGroupTopologyCleanupCycle() {
+        if (!streamsGroupTopologyDescriptionManager.isPluginConfigured()) 
return;
+        if (!isActive.get()) return;
+        TimerTask task = new 
TimerTask(config.offsetsRetentionCheckIntervalMs()) {
+            @Override
+            public void run() {
+                if (!isActive.get()) return;
+                try {
+                    runStreamsGroupTopologyCleanupCycle();
+                } catch (Throwable t) {
+                    log.warn("Unexpected error running topology-description 
cleanup cycle.", t);
+                }
+                if (isActive.get()) scheduleStreamsGroupTopologyCleanupCycle();
+            }
+        };
+        streamsTopologyCleanupTask = task;
+        timer.add(task);
+    }
+
+    /**
+     * Drive 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>Single-flight: a cycle that fires while a previous one is still 
settling per-group
+     * futures is dropped with a warn-level log.
+     *
+     * <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 KIP-1331 accepts 
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.
+    void runStreamsGroupTopologyCleanupCycle() {
+        if (!streamsGroupTopologyDescriptionManager.isPluginConfigured()) 
return;
+        if (!streamsTopologyCleanupCycleInFlight.compareAndSet(false, true)) {
+            log.warn("Topology-description cleanup cycle skipped: previous 
cycle is still in flight.");
+            return;
+        }
+        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;
+                groupCoordinatorMetrics.recordSensor(
+                    
GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_CLEANUP_ELIGIBLE_GROUPS_SENSOR_NAME,
+                    eligible.size()
+                );
+                perGroupFutures.add(streamsGroupTopologyDescriptionManager
+                    .invokeDeleteTopologies(eligible.keySet())
+                    .thenAccept(failures -> {
+                        recordPluginDeleteOutcome(eligible.size(), 
failures.size());
+                        eligible.forEach((groupId, expectedStoredEpoch) -> {
+                            // Eligibility required the group to be empty: no 
member is heartbeating
+                            // anymore, so any push back-off entry is moot 
regardless of plugin
+                            // outcome — dropping it now avoids carrying 
broker-wide state forward
+                            // for an id that no live member will solicit on.
+                            
streamsGroupTopologyDescriptionManager.clearBackoffGroup(groupId);

Review Comment:
   `clearBackoffGroup(groupId)` runs for every eligible group regardless of 
plugin outcome — including the failure branch right below where the comment 
explicitly says "next cycle retries". That wipes the back-off entry the plugin 
manager just set/extended during the failing `invokeDeleteTopologies` call.
   
   If a new member joins the group before the next cycle and triggers a 
setTopology push, the push-path back-off check finds no entry and invokes 
`plugin.setTopology` immediately, even though the plugin is in a persistently 
failing state. The KIP-1331 back-off semantics get bypassed for any group the 
cleanup cycle touched.
   
   The doc above argues "no member is heartbeating anymore, so any push 
back-off entry is moot" — but that assumes the group stays empty between 
cycles, which isn't guaranteed (membership can change between scan and clear).



##########
group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorService.java:
##########
@@ -786,6 +805,163 @@ private void 
throwIfStreamsGroupTopologyDescriptionUpdateInvalid(
         throwIfNull(request.topologyDescription(), "TopologyDescription can't 
be null.");
     }
 
+    /**
+     * Schedule the next topology-description cleanup tick on the broker-level 
{@link Timer}.
+     * The {@link TimerTask} self-reschedules from inside its own {@code run} 
so the cycle keeps
+     * firing every {@code offsets.retention.check.interval.ms} until {@code 
shutdown} flips
+     * {@code isActive} to false. No-op when no plugin is configured.
+     *
+     * <p>This lifecycle lives on the service rather than on
+     * {@link StreamsGroupTopologyDescriptionManager} because the cycle drives 
a runtime chain
+     * (per-shard read → per-group plugin call → conditional metadata write); 
per the existing
+     * separation, the manager exposes plugin-invocation and back-off building 
blocks and the
+     * service assembles the chain.
+     */
+    private void scheduleStreamsGroupTopologyCleanupCycle() {
+        if (!streamsGroupTopologyDescriptionManager.isPluginConfigured()) 
return;
+        if (!isActive.get()) return;
+        TimerTask task = new 
TimerTask(config.offsetsRetentionCheckIntervalMs()) {
+            @Override
+            public void run() {
+                if (!isActive.get()) return;
+                try {
+                    runStreamsGroupTopologyCleanupCycle();
+                } catch (Throwable t) {
+                    log.warn("Unexpected error running topology-description 
cleanup cycle.", t);
+                }
+                if (isActive.get()) scheduleStreamsGroupTopologyCleanupCycle();
+            }
+        };
+        streamsTopologyCleanupTask = task;
+        timer.add(task);
+    }
+
+    /**
+     * Drive 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>Single-flight: a cycle that fires while a previous one is still 
settling per-group
+     * futures is dropped with a warn-level log.
+     *
+     * <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 KIP-1331 accepts 
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.
+    void runStreamsGroupTopologyCleanupCycle() {

Review Comment:
   The whole cycle (single-flight `AtomicBoolean`, `scheduleReadAllOperation` 
fan-out, per-group plugin invoke, per-group conditional-clear write, dual 
`allOf` fan-in, self-rescheduling `TimerTask`, volatile-field snapshot for 
shutdown) lives on `GroupCoordinatorService` and orchestrates a 
streams-specific runtime chain.
   
   This belongs behind `StreamsGroupTopologyDescriptionManager` — that class 
already owns plugin lifecycle and back-off. The service would call one method 
(`manager.runCleanupCycle(runtime)`), the timer task would live in the manager, 
and the existing `clearBackoffGroup` / `invokeDeleteTopologies` would be inline 
instead of cross-class calls. The hand-rolled recurring-task pattern (volatile 
field + snapshot-and-cancel) also goes away — the manager can hold a stable 
reference and self-arm.



##########
group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorService.java:
##########
@@ -786,6 +805,163 @@ private void 
throwIfStreamsGroupTopologyDescriptionUpdateInvalid(
         throwIfNull(request.topologyDescription(), "TopologyDescription can't 
be null.");
     }
 
+    /**
+     * Schedule the next topology-description cleanup tick on the broker-level 
{@link Timer}.
+     * The {@link TimerTask} self-reschedules from inside its own {@code run} 
so the cycle keeps
+     * firing every {@code offsets.retention.check.interval.ms} until {@code 
shutdown} flips
+     * {@code isActive} to false. No-op when no plugin is configured.
+     *
+     * <p>This lifecycle lives on the service rather than on
+     * {@link StreamsGroupTopologyDescriptionManager} because the cycle drives 
a runtime chain
+     * (per-shard read → per-group plugin call → conditional metadata write); 
per the existing
+     * separation, the manager exposes plugin-invocation and back-off building 
blocks and the
+     * service assembles the chain.
+     */
+    private void scheduleStreamsGroupTopologyCleanupCycle() {
+        if (!streamsGroupTopologyDescriptionManager.isPluginConfigured()) 
return;
+        if (!isActive.get()) return;
+        TimerTask task = new 
TimerTask(config.offsetsRetentionCheckIntervalMs()) {
+            @Override
+            public void run() {
+                if (!isActive.get()) return;
+                try {
+                    runStreamsGroupTopologyCleanupCycle();
+                } catch (Throwable t) {
+                    log.warn("Unexpected error running topology-description 
cleanup cycle.", t);
+                }
+                if (isActive.get()) scheduleStreamsGroupTopologyCleanupCycle();
+            }
+        };
+        streamsTopologyCleanupTask = task;
+        timer.add(task);
+    }
+
+    /**
+     * Drive 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>Single-flight: a cycle that fires while a previous one is still 
settling per-group
+     * futures is dropped with a warn-level log.
+     *
+     * <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 KIP-1331 accepts 
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.
+    void runStreamsGroupTopologyCleanupCycle() {
+        if (!streamsGroupTopologyDescriptionManager.isPluginConfigured()) 
return;
+        if (!streamsTopologyCleanupCycleInFlight.compareAndSet(false, true)) {

Review Comment:
   No try/finally protecting `streamsTopologyCleanupCycleInFlight`. The only 
reset is the `whenComplete` at the bottom of the method. If anything between 
this CAS and the chain construction throws — `recordSensor` (next line), 
`scheduleReadAllOperation`, even an OOM on the ArrayList alloc — the flag stays 
true forever. Outer timer-task catch reschedules normally, but every subsequent 
cycle then short-circuits at the CAS and logs "previous cycle is still in 
flight". Combined with the new defer gate in the sweep, streams groups with 
`storedDescriptionTopologyEpoch != -1` are deferred indefinitely and never 
tombstoned until broker restart.



##########
group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorService.java:
##########
@@ -2639,6 +2819,13 @@ public void shutdown() {
 
         log.info("Shutting down.");
         isActive.set(false);
+        // Cancel the in-flight cleanup tick (if any) before tearing down the 
runtime and the
+        // manager — the task's re-arm check will also observe isActive==false 
and refuse to
+        // schedule a follow-up tick.
+        TimerTask cleanupTaskSnapshot = streamsTopologyCleanupTask;
+        if (cleanupTaskSnapshot != null) {
+            cleanupTaskSnapshot.cancel();
+        }
         Utils.closeQuietly(runtime, "coordinator runtime");
         Utils.closeQuietly(streamsGroupTopologyDescriptionManager, "streams 
group topology description manager");

Review Comment:
   `shutdown()` cancels the TimerTask snapshot then closes the runtime and the 
topology-description manager. It doesn't await an in-flight cycle. `cancel()` 
is non-blocking; if `runStreamsGroupTopologyCleanupCycle` is mid-flight when 
shutdown fires, its plugin futures can complete *after* the manager has called 
`plugin.close()`.
   
   The `.thenAccept` then invokes `clearBackoffGroup` and 
`clearStoredDescriptionTopologyEpochAsync` against a closing runtime, and 
`invokeDeleteTopologies` may have already dispatched `plugin.deleteTopology` 
against the now-closed plugin. Outcome is plugin-dependent (some throw on 
close-then-call, some hang).
   
   Probably want a "wait for in-flight cycle, with a small timeout" before 
closing the manager — or at least an `isActive.get()` check inside the 
per-group `.thenAccept` so we don't issue follow-up writes after shutdown 
started.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to