markTopologyUncertainAsync(
+ TopicPartition tp, String groupId, boolean markWhenNone
+ ) {
+ return runtime.scheduleWriteOperation(
+ "mark-topology-uncertain",
+ tp,
+ coordinator -> coordinator.markStoredDescriptionTopologyEpochUncertain(groupId, markWhenNone)
+ );
+ }
+
private void throwIfStreamsGroupTopologyDescriptionUpdateInvalid(
StreamsGroupTopologyDescriptionUpdateRequestData request
) throws InvalidRequestException, UnsupportedVersionException {
@@ -795,32 +820,22 @@ private void throwIfStreamsGroupTopologyDescriptionUpdateInvalid(
}
/**
- * 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.
+ * Build one topology-description cleanup cycle across all shards. For each partition the
+ * cycle: (1) reads the streams groups eligible for plugin-side cleanup (empty, all offsets
+ * expired, and {@code storedEpoch} is either a real epoch or UNCERTAIN {@code -2}); (2)
+ * writes a durable UNCERTAIN({@code -2}) barrier for those groups via a batched mark
+ * operation that re-checks the latest in-memory state and drops any candidate revived since
+ * the scan; (3) calls {@code plugin.deleteTopology} for the still-eligible subset; and (4)
+ * smart-finalizes the groups whose delete succeeded — clearing to NONE({@code -1}) if the
+ * stored epoch is still {@code -2}, or writing {@code -2} again if a concurrent
+ * {@code setTopology} push raced the delete and advanced the epoch (forcing a re-solicit on
+ * the member's next heartbeat). Groups whose delete failed remain at {@code -2} and retry
+ * on the next cycle; the next sweep tombstones a group whose epoch was cleared to {@code -1}.
*
* 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.
- *
- *
Concurrent setTopology race vs plugin.deleteTopology. {@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() {
@@ -830,7 +845,7 @@ CompletableFuture> runOneStreamsTopologyCleanupCycle() {
groupCoordinatorMetrics.recordSensor(
GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_CLEANUP_CYCLE_RUNS_SENSOR_NAME);
- List>> partitionFutures = runtime.scheduleReadAllOperation(
+ List>> partitionFutures = runtime.scheduleReadAllOperation(
"list-streams-groups-needing-topology-cleanup",
GroupCoordinatorShard::listStreamsGroupsNeedingTopologyCleanup
);
@@ -839,7 +854,7 @@ CompletableFuture> runOneStreamsTopologyCleanupCycle() {
// from whichever thread completed each runtime read.
Queue> perGroupFutures = new ConcurrentLinkedQueue<>();
List> partitionDoneFutures = new ArrayList<>(partitionFutures.size());
- for (CompletableFuture> partitionFuture : partitionFutures) {
+ for (CompletableFuture> partitionFuture : partitionFutures) {
partitionDoneFutures.add(partitionFuture.handle((eligible, throwable) -> {
if (throwable != null) {
log.warn("Topology-description cleanup read failed for one partition.", throwable);
@@ -854,39 +869,7 @@ CompletableFuture> runOneStreamsTopologyCleanupCycle() {
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);
- Map toClear = new HashMap<>(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);
- toClear.put(groupId, expectedStoredEpoch);
- });
- if (toClear.isEmpty()) return CompletableFuture.completedFuture(null);
- // All groups in `eligible` came from the same partition's read so they
- // hash to the same __consumer_offsets partition; one batched write covers
- // every clear on this shard.
- return clearStoredDescriptionTopologyEpochBatchAsync(toClear);
- }));
+ perGroupFutures.add(cleanupTopologyForPartition(eligible));
return null;
}));
}
@@ -895,6 +878,63 @@ CompletableFuture> runOneStreamsTopologyCleanupCycle() {
.thenCompose(__ -> CompletableFuture.allOf(perGroupFutures.toArray(new CompletableFuture>[0])));
}
+ /**
+ * Drive one shard's topology cleanup for the groups the eligibility scan returned: mark the
+ * UNCERTAIN(-2) barrier, run {@code plugin.deleteTopology} for the subset the mark confirmed
+ * still eligible, then smart-finalize the groups whose delete succeeded. All groups in
+ * {@code eligible} came from the same partition's read so they hash to the same
+ * __consumer_offsets partition; one mark/finalize write covers this shard.
+ */
+ private CompletableFuture cleanupTopologyForPartition(Set eligible) {
+ TopicPartition tp = topicPartitionFor(eligible.iterator().next());
+ return markTopologyUncertainBatchAsync(tp, eligible)
+ .exceptionally(throwable -> {
+ // Same containment as the finalize write: one shard's routine write failure
+ // (e.g. NOT_COORDINATOR during a move) must not fail the whole cycle's allOf,
+ // and the log should name the affected partition and groups. Skipping the
+ // plugin delete is safe — the groups keep their stored epoch and the next
+ // cycle retries.
+ log.warn("Failed to write the UNCERTAIN barrier for groups {} on partition {}; "
+ + "skipping their plugin delete — the next cleanup cycle will retry.",
+ eligible, tp, throwable);
+ return Set.of();
+ })
+ .thenCompose(stillEligible -> {
+ // The mark write re-checks the latest state and drops any candidate that was
+ // revived or converted since the committed scan. Nothing to do for a partition
+ // whose every candidate dropped out.
+ if (stillEligible.isEmpty()) return CompletableFuture.completedFuture(null);
+ return streamsGroupTopologyDescriptionManager.invokeDeleteTopologies(stillEligible)
+ .thenCompose(failures -> finalizeCleanupAfterDelete(tp, stillEligible, failures));
+ });
+ }
+
+ /**
+ * Follow-up after the cleanup cycle's {@code plugin.deleteTopology}: record the outcome, drop
+ * the push-path back-off for groups whose delete succeeded, and smart-finalize them. Groups
+ * whose delete failed are left at {@code -2} (delete-eligible and re-soliciting) so the next
+ * cycle retries while their back-off keeps throttling a rejoining member against the broken
+ * plugin.
+ */
+ private CompletableFuture finalizeCleanupAfterDelete(
+ TopicPartition tp,
+ Set stillEligible,
+ Map failures
+ ) {
+ recordPluginDeleteOutcome(stillEligible.size(), failures.size());
+ // Shutdown can have started between the plugin call and the follow-up write. Skip the
+ // finalize so we do not schedule a write against a runtime that is being closed.
+ if (!isActive.get()) return CompletableFuture.completedFuture(null);
+ Set toFinalize = new LinkedHashSet<>(stillEligible.size());
+ for (String groupId : stillEligible) {
+ if (failures.containsKey(groupId)) continue;
+ streamsGroupTopologyDescriptionManager.clearBackoffGroup(groupId);
+ toFinalize.add(groupId);
+ }
+ if (toFinalize.isEmpty()) return CompletableFuture.completedFuture(null);
+ return finalizeAfterDeleteBatchAsync(tp, toFinalize);
+ }
+
/**
* Record per-call outcomes from a batched {@code plugin.deleteTopology} invocation
* against the shared {@code delete-success} / {@code delete-error} sensors. Used by
@@ -928,28 +968,43 @@ private void recordPluginSetOutcome(StreamsGroupTopologyDescriptionManager.Plugi
}
/**
- * Batched conditional metadata write that clears {@code StoredDescriptionTopologyEpoch}
- * for every entry in {@code expectedStoredEpochByGroupId}, but only for the entries whose
- * persisted value still equals the supplied epoch. Mismatches and missing groups are
- * silently ignored by the shard-side method. All groups in the batch must hash to the same
- * __consumer_offsets partition (the caller guarantees this — the eligibility scan is per
- * partition). 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.
+ * Batched UNCERTAIN(-2) barrier write before the cleanup cycle's plugin delete. The shard-side
+ * method re-checks the latest state per group and returns only the subset that is still a
+ * streams group and is now UNCERTAIN; revived or converted candidates drop out so they are not
+ * deleted. All groups in the batch hash to the same __consumer_offsets partition (the caller
+ * guarantees this — the eligibility scan is per partition).
*/
- private CompletableFuture clearStoredDescriptionTopologyEpochBatchAsync(
- Map expectedStoredEpochByGroupId
+ private CompletableFuture> markTopologyUncertainBatchAsync(
+ TopicPartition tp,
+ Set groupIds
) {
- if (expectedStoredEpochByGroupId.isEmpty()) return CompletableFuture.completedFuture(null);
- TopicPartition tp = topicPartitionFor(expectedStoredEpochByGroupId.keySet().iterator().next());
return runtime.scheduleWriteOperation(
- "clear-stored-topology-epoch",
+ "mark-topology-uncertain-batch",
+ tp,
+ coordinator -> coordinator.markStoredDescriptionTopologyEpochUncertainBatch(groupIds));
+ }
+
+ /**
+ * Batched smart-finalize write after the cleanup cycle's plugin delete. Per group: if stored
+ * is still UNCERTAIN no push raced and it is cleared to NONE (the next sweep tombstones it); if
+ * stored advanced past UNCERTAIN a push raced our delete and it is forced back to UNCERTAIN to
+ * re-solicit. All groups in the batch hash to the same __consumer_offsets partition. 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 retries because the persisted storedEpoch
+ * is still non-default.
+ */
+ private CompletableFuture finalizeAfterDeleteBatchAsync(
+ TopicPartition tp,
+ Set groupIds
+ ) {
+ return runtime.scheduleWriteOperation(
+ "finalize-stored-topology-epoch-after-delete-batch",
tp,
- coordinator -> coordinator.clearStoredDescriptionTopologyEpochBatch(expectedStoredEpochByGroupId)
+ coordinator -> coordinator.finalizeStoredDescriptionTopologyEpochAfterDeleteBatch(groupIds)
).handle((__, throwable) -> {
if (throwable != null) {
- log.warn("Failed to clear StoredDescriptionTopologyEpoch for groups {} on partition {}; the next cleanup cycle will retry.",
- expectedStoredEpochByGroupId.keySet(), tp, throwable);
+ log.warn("Failed to finalize StoredDescriptionTopologyEpoch for groups {} on partition {}; "
+ + "the next cleanup cycle will retry.", groupIds, tp, throwable);
}
return null;
});
@@ -1247,12 +1302,22 @@ public CompletableFuture joinGroup(
}
CompletableFuture responseFuture = new CompletableFuture<>();
-
- runtime.scheduleWriteOperation(
- "classic-group-join",
- topicPartitionFor(request.groupId()),
- coordinator -> coordinator.classicGroupJoin(context, request, responseFuture)
- ).exceptionally(exception -> {
+ TopicPartition tp = topicPartitionFor(request.groupId());
+
+ // The classic-join write op resolves the group and, when a plugin is configured, detects an
+ // empty streams group with a stored topology before mutating anything. A plugin-less broker
+ // has no topology to clean up, so it converts directly on the first call (topologyCleanupHandled
+ // true). The op returns whether streams-topology cleanup is needed before conversion; for
+ // already-classic, non-existent, and non-streams groups (the common case) it returns false and
+ // has already completed the response, so no extra op runs.
+ runClassicGroupJoin(context, request, responseFuture, tp,
+ !streamsGroupTopologyDescriptionManager.isPluginConfigured()
+ ).thenCompose(needsCleanup -> {
+ if (!needsCleanup) {
+ return CompletableFuture.completedFuture(null);
+ }
+ return cleanupTopologyBeforeConversion(context, request, responseFuture, tp);
+ }).exceptionally(exception -> {
if (!responseFuture.isDone()) {
responseFuture.complete(handleOperationException(
"classic-group-join",
@@ -1268,6 +1333,115 @@ public CompletableFuture joinGroup(
return responseFuture;
}
+ /**
+ * Converting an empty streams group to classic would orphan the plugin's topology, so the
+ * join detected cleanup is needed: delete the topology (behind a durable UNCERTAIN(-2)
+ * barrier) and re-run the join, which then converts because cleanup has been handled.
+ *
+ * Throttle first: on {@code REBALANCE_IN_PROGRESS} the classic client retries the join
+ * immediately ({@code RebalanceInProgressException} skips its retry back-off), so a broken
+ * plugin would otherwise be hit with {@code deleteTopology} in a tight loop. While the window
+ * armed by a previous failed conversion delete is in effect, fail fast without touching the
+ * plugin or scheduling the mark; the interval-throttled cleanup cycle reclaims the group in
+ * the meantime.
+ */
+ private CompletableFuture cleanupTopologyBeforeConversion(
+ AuthorizableRequestContext context,
+ JoinGroupRequestData request,
+ CompletableFuture responseFuture,
+ TopicPartition tp
+ ) {
+ if (streamsGroupTopologyDescriptionManager.isConversionDeleteThrottled(request.groupId())) {
+ failJoinRetriably(request, responseFuture);
+ return CompletableFuture.completedFuture(null);
+ }
+ return markTopologyUncertainAsync(tp, request.groupId(), false)
+ .thenCompose(marked -> {
+ if (!marked) {
+ // The group changed underneath us (revived, converted, or removed) between
+ // the join's cleanup check and the barrier write: no barrier exists, so
+ // running the plugin delete could wipe a live group's topology. Fail the
+ // join with a retriable error and let the client retry against the latest
+ // group state.
+ failJoinRetriably(request, responseFuture);
+ return CompletableFuture.completedFuture(null);
+ }
+ return streamsGroupTopologyDescriptionManager.invokeDeleteTopologies(Set.of(request.groupId()))
+ .thenCompose(failures -> {
+ recordPluginDeleteOutcome(1, failures.size());
+ if (!failures.isEmpty()) {
+ // Plugin delete failed: leave the group a streams group at UNCERTAIN(-2)
+ // (reclaimable by the cleanup cycle and re-soliciting), arm the
+ // conversion-delete throttle so the client's immediate join retries do
+ // not hammer the broken plugin, and fail the join with a retriable
+ // error instead of converting over orphaned plugin data.
+ streamsGroupTopologyDescriptionManager.throttleConversionDelete(request.groupId());
+ failJoinRetriably(request, responseFuture);
+ return CompletableFuture.completedFuture(null);
+ }
+ streamsGroupTopologyDescriptionManager.clearBackoffGroup(request.groupId());
+ // Smart-finalize after the re-join: a no-op once the group has been
+ // converted (it is no longer a streams group). It only writes for a group
+ // revived between the barrier and the re-join — the re-join then rejects
+ // with INCONSISTENT_GROUP_PROTOCOL and, without the finalize, a raced
+ // push's epoch write would land on stored == UNCERTAIN and record a real
+ // epoch over the plugin this delete just emptied.
+ return runClassicGroupJoin(context, request, responseFuture, tp, true)
+ .thenCompose(__ -> finalizeAfterDeleteBatchAsync(tp, Set.of(request.groupId())));
+ });
+ });
+ }
+
+ /**
+ * Complete the join with {@code REBALANCE_IN_PROGRESS} (if not already completed): a
+ * retriable error classic clients respond to by re-joining, used when the pre-conversion
+ * topology cleanup could not run to completion.
+ */
+ private static void failJoinRetriably(
+ JoinGroupRequestData request,
+ CompletableFuture responseFuture
+ ) {
+ if (!responseFuture.isDone()) {
+ responseFuture.complete(new JoinGroupResponseData()
+ .setMemberId(request.memberId())
+ .setErrorCode(Errors.REBALANCE_IN_PROGRESS.code()));
+ }
+ }
+
+ /**
+ * Run the classic-group-join write op. On the common path {@code classicGroupJoin} completes
+ * {@code responseFuture} internally and the returned future yields {@code false}. When it detects
+ * an empty streams group with a stored topology and {@code topologyCleanupHandled} is false, it
+ * makes no mutation, leaves {@code responseFuture} uncompleted, and the returned future yields
+ * {@code true} so the caller can run plugin cleanup and re-invoke with {@code topologyCleanupHandled}
+ * set. A scheduling failure completes {@code responseFuture} with the translated error and yields
+ * {@code false}.
+ */
+ private CompletableFuture runClassicGroupJoin(
+ AuthorizableRequestContext context,
+ JoinGroupRequestData request,
+ CompletableFuture responseFuture,
+ TopicPartition tp,
+ boolean topologyCleanupHandled
+ ) {
+ return runtime.scheduleWriteOperation(
+ "classic-group-join",
+ tp,
+ coordinator -> coordinator.classicGroupJoin(context, request, responseFuture, topologyCleanupHandled)
+ ).exceptionally(exception -> {
+ if (!responseFuture.isDone()) {
+ responseFuture.complete(handleOperationException(
+ "classic-group-join",
+ request,
+ exception,
+ (error, __) -> new JoinGroupResponseData().setErrorCode(error.code()),
+ log
+ ));
+ }
+ return Boolean.FALSE;
+ });
+ }
+
/**
* See {@link GroupCoordinator#syncGroup(AuthorizableRequestContext, SyncGroupRequestData, BufferSupplier)}.
*/
@@ -1866,17 +2040,40 @@ private CompletableFuture> deleteStreamsTopologyDescriptio
topicPartition,
(coordinator, lastCommittedOffset) ->
coordinator.streamsGroupsWithStoredTopologyDescription(groupIds, lastCommittedOffset))
- .thenCompose(groupsWithStored ->
- streamsGroupTopologyDescriptionManager.invokeDeleteTopologies(groupsWithStored)
- .thenApply(failures -> {
- // Clear back-off entries for every group whose plugin state we
- // attempted to delete (regardless of plugin outcome): the group is
- // about to be tombstoned on success and re-evaluated by the next
- // heartbeat on failure, so any in-flight back-off entry at the old
- // epoch is no longer load-bearing.
- groupsWithStored.forEach(streamsGroupTopologyDescriptionManager::clearBackoffGroup);
- return failures;
- }))
+ .thenCompose(groupsWithStored -> {
+ // Common case: the batch holds no streams groups with stored topology. Skip the
+ // mark entirely instead of scheduling a no-op write on the shard's event loop.
+ if (groupsWithStored.isEmpty()) {
+ return CompletableFuture.>completedFuture(Map.of());
+ }
+ return markTopologyUncertainBatchAsync(topicPartition, groupsWithStored)
+ .thenCompose(marked ->
+ streamsGroupTopologyDescriptionManager.invokeDeleteTopologies(marked)
+ .thenCompose(failures -> {
+ recordPluginDeleteOutcome(marked.size(), failures.size());
+ Set succeeded = new LinkedHashSet<>(marked);
+ succeeded.removeAll(failures.keySet());
+ // Clear the push-path back-off only for groups whose plugin delete
+ // succeeded (the group is about to be tombstoned), mirroring the
+ // cleanup cycle. A failed delete leaves the group at UNCERTAIN(-2),
+ // which re-solicits a push on the next heartbeat — keep its back-off
+ // so a rejoining member does not immediately hit the still-broken
+ // plugin, and let the interval-throttled cleanup cycle retry it.
+ succeeded.forEach(streamsGroupTopologyDescriptionManager::clearBackoffGroup);
+ if (succeeded.isEmpty()) {
+ return CompletableFuture.completedFuture(failures);
+ }
+ // Smart-finalize the successful deletes before the tombstone,
+ // mirroring the cleanup cycle: a group revived between the mark
+ // and the plugin delete survives the tombstone (NON_EMPTY_GROUP),
+ // and without the finalize a raced push's epoch write would land
+ // on stored == UNCERTAIN and record a real epoch over the plugin
+ // this delete just emptied. For groups the tombstone does remove
+ // the extra record is harmless.
+ return finalizeAfterDeleteBatchAsync(topicPartition, succeeded)
+ .thenApply(__ -> failures);
+ }));
+ })
.exceptionally(exception -> handleOperationException(
// Translate coordinator errors so a read failure reports the same retriable code
// as the rest of the DeleteGroups pipeline (e.g. NOT_LEADER_OR_FOLLOWER ->
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 8a0c2b508baf4..58639a20b70e2 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
@@ -555,21 +555,26 @@ public Map> initializedShareGroupPartitions(
/**
* Handles a JoinGroup request.
*
- * @param context The request context.
- * @param request The actual JoinGroup request.
+ * @param context The request context.
+ * @param request The actual JoinGroup request.
+ * @param responseFuture The join group response future.
+ * @param topologyCleanupHandled Whether streams-topology cleanup has already run (or is not
+ * needed) for this request.
*
- * @return A Result containing the JoinGroup response and
- * a list of records to update the state machine.
+ * @return A Result whose response signals whether the join must be deferred for streams-topology
+ * cleanup, and a list of records to update the state machine.
*/
- public CoordinatorResult classicGroupJoin(
+ public CoordinatorResult classicGroupJoin(
AuthorizableRequestContext context,
JoinGroupRequestData request,
- CompletableFuture responseFuture
+ CompletableFuture responseFuture,
+ boolean topologyCleanupHandled
) {
return groupMetadataManager.classicGroupJoin(
context,
request,
- responseFuture
+ responseFuture,
+ topologyCleanupHandled
);
}
@@ -986,11 +991,11 @@ public Set streamsGroupsWithStoredTopologyDescription(
/**
* Return the streams groups on this shard eligible for plugin-side topology cleanup: empty
* (no live members), no committed offsets and no pending transactional offsets, 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.
+ * {@code StoredDescriptionTopologyEpoch != -1}. The cleanup cycle marks each candidate
+ * UNCERTAIN at the latest state before the plugin delete and smart-finalizes afterwards, so a
+ * concurrent {@code setTopology} that has since advanced the field is re-solicited rather than
+ * silently undone by a stale plugin delete — the observed epoch is not needed downstream, so
+ * only the group-id set is returned.
*
* This sweep relies on the regular offset-expiration cycle to have already tombstoned
* expired offsets, so the eligibility check itself only asks "does this group still have any
@@ -1005,8 +1010,8 @@ public Set streamsGroupsWithStoredTopologyDescription(
* 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 listStreamsGroupsNeedingTopologyCleanup(long committedOffset) {
- Map eligible = new HashMap<>();
+ public Set listStreamsGroupsNeedingTopologyCleanup(long committedOffset) {
+ Set eligible = new HashSet<>();
for (String groupId : groupMetadataManager.groupIds(committedOffset)) {
try {
Group group = groupMetadataManager.maybeGroup(groupId, committedOffset);
@@ -1014,9 +1019,10 @@ public Map listStreamsGroupsNeedingTopologyCleanup(long committ
StreamsGroup streamsGroup = (StreamsGroup) group;
if (!streamsGroup.isEmpty(committedOffset)) continue;
int storedEpoch = streamsGroup.storedDescriptionTopologyEpoch(committedOffset);
- if (storedEpoch == -1) continue;
+ // -2 (UNCERTAIN) falls through: a half-finished push/delete may have left plugin data.
+ if (storedEpoch == StreamsGroup.STORED_TOPOLOGY_EPOCH_NONE) continue;
if (!offsetMetadataManager.groupHasNoOffsets(groupId, committedOffset)) continue;
- eligible.put(groupId, storedEpoch);
+ eligible.add(groupId);
} 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);
@@ -1026,38 +1032,34 @@ public Map listStreamsGroupsNeedingTopologyCleanup(long committ
}
/**
- * 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.
+ * Shard entry point for the {@code -2} (UNCERTAIN) barrier write. See
+ * {@link GroupMetadataManager#markStoredDescriptionTopologyEpochUncertain(String, boolean)}.
*/
- public CoordinatorResult clearStoredDescriptionTopologyEpoch(
+ public CoordinatorResult markStoredDescriptionTopologyEpochUncertain(
String groupId,
- int expectedStoredEpoch
+ boolean markWhenNone
) {
- return groupMetadataManager.clearStoredDescriptionTopologyEpoch(groupId, expectedStoredEpoch);
+ return groupMetadataManager.markStoredDescriptionTopologyEpochUncertain(groupId, markWhenNone);
}
/**
- * Batched form of {@link #clearStoredDescriptionTopologyEpoch}: emits one conditional clear
- * record per entry in {@code expectedStoredEpochByGroupId} in a single
- * {@link CoordinatorResult}, so the cycle issues one {@code scheduleWriteOperation} per
- * shard instead of one per group. Groups whose stored epoch no longer matches yield no
- * record (the GMM-level method returns an empty list for those), matching the single-group
- * variant's silent skip.
- *
- * The result is non-atomic ({@code isAtomic=false}): the conditional clear records are
- * independent per group, so the runtime is free to split them across multiple log batches
- * if a single batch would exceed the limit. Any per-record write failure is retried
- * naturally on the next cycle because the persisted storedEpoch is still non-default.
+ * Batched UNCERTAIN(-2) barrier write for the cleanup cycle. See
+ * {@link GroupMetadataManager#markStoredDescriptionTopologyEpochUncertainBatch(Set)}.
*/
- public CoordinatorResult clearStoredDescriptionTopologyEpochBatch(
- Map expectedStoredEpochByGroupId
+ public CoordinatorResult, CoordinatorRecord> markStoredDescriptionTopologyEpochUncertainBatch(
+ Set groupIds
) {
- List records = new ArrayList<>(expectedStoredEpochByGroupId.size());
- expectedStoredEpochByGroupId.forEach((groupId, expectedStoredEpoch) ->
- records.addAll(groupMetadataManager.clearStoredDescriptionTopologyEpoch(groupId, expectedStoredEpoch).records()));
- return new CoordinatorResult<>(records, false);
+ return groupMetadataManager.markStoredDescriptionTopologyEpochUncertainBatch(groupIds);
+ }
+
+ /**
+ * Smart finalize after {@code plugin.deleteTopology} for the cleanup cycle. See
+ * {@link GroupMetadataManager#finalizeStoredDescriptionTopologyEpochAfterDeleteBatch(Set)}.
+ */
+ public CoordinatorResult finalizeStoredDescriptionTopologyEpochAfterDeleteBatch(
+ Set groupIds
+ ) {
+ return groupMetadataManager.finalizeStoredDescriptionTopologyEpochAfterDeleteBatch(groupIds);
}
/**
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 38fee045c3d7c..f2442c70463f9 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
@@ -189,6 +189,7 @@
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -6807,42 +6808,69 @@ public void replay(
/**
* Handle a JoinGroupRequest.
*
- * @param context The request context.
- * @param request The actual JoinGroup request.
- * @param responseFuture The join group response future.
+ * @param context The request context.
+ * @param request The actual JoinGroup request.
+ * @param responseFuture The join group response future.
+ * @param topologyCleanupHandled Whether streams-topology cleanup has already run (or is not
+ * needed), so an empty streams group with a stored topology may be
+ * converted directly instead of signalling for cleanup.
*
- * @return The result that contains records to append if the join group phase completes.
+ * @return A result whose response is {@code true} when the join must be deferred for
+ * streams-topology cleanup before conversion, and {@code false} otherwise; the records,
+ * if any, are those produced by the join.
*/
- public CoordinatorResult classicGroupJoin(
+ public CoordinatorResult classicGroupJoin(
AuthorizableRequestContext context,
JoinGroupRequestData request,
- CompletableFuture responseFuture
+ CompletableFuture responseFuture,
+ boolean topologyCleanupHandled
) {
Group group = groups.get(request.groupId(), Long.MAX_VALUE);
+ if (!topologyCleanupHandled
+ && group != null
+ && group.type() == STREAMS
+ && group.isEmpty()
+ && ((StreamsGroup) group).storedDescriptionTopologyEpoch() != StreamsGroup.STORED_TOPOLOGY_EPOCH_NONE) {
+ // Converting this empty streams group to classic would tombstone its streams metadata and
+ // orphan any topology the description plugin still holds. Signal the caller to run the
+ // plugin cleanup first; the conversion happens on the re-invocation with the flag set.
+ return new CoordinatorResult<>(List.of(), Boolean.TRUE);
+ }
if (group != null) {
if (group.type() == CONSUMER && !group.isEmpty()) {
// classicGroupJoinToConsumerGroup takes the join requests to non-empty consumer groups.
// The empty consumer groups should be converted to classic groups in classicGroupJoinToClassicGroup.
- return classicGroupJoinToConsumerGroup((ConsumerGroup) group, context, request, responseFuture);
+ return cleanupNotNeeded(classicGroupJoinToConsumerGroup((ConsumerGroup) group, context, request, responseFuture));
} else if (group.type() == CONSUMER || group.type() == CLASSIC || group.type() == STREAMS && group.isEmpty()) {
// classicGroupJoinToClassicGroup accepts:
// - classic groups
// - empty streams groups
// - empty consumer groups
- return classicGroupJoinToClassicGroup(context, request, responseFuture);
+ return cleanupNotNeeded(classicGroupJoinToClassicGroup(context, request, responseFuture));
} else {
// Group exists but it's not a consumer group
responseFuture.complete(new JoinGroupResponseData()
.setMemberId(UNKNOWN_MEMBER_ID)
.setErrorCode(Errors.INCONSISTENT_GROUP_PROTOCOL.code())
);
- return EMPTY_RESULT;
+ return new CoordinatorResult<>(List.of(), Boolean.FALSE);
}
} else {
- return classicGroupJoinToClassicGroup(context, request, responseFuture);
+ return cleanupNotNeeded(classicGroupJoinToClassicGroup(context, request, responseFuture));
}
}
+ /**
+ * Re-wrap a classic-join result as a {@code Boolean}-typed result whose response signals that no
+ * streams-topology cleanup is needed, preserving the records, append future, replay flag and
+ * atomicity of the wrapped result.
+ */
+ private static CoordinatorResult cleanupNotNeeded(
+ CoordinatorResult r
+ ) {
+ return new CoordinatorResult<>(r.records(), Boolean.FALSE, r.appendFuture(), r.replayRecords(), r.isAtomic());
+ }
+
/**
* Handle a JoinGroupRequest to a ClassicGroup or a group to be created.
*
@@ -8518,9 +8546,10 @@ public Set streamsGroupsWithStoredTopologyDescription(
for (String groupId : groupIds) {
// Non-throwing lookup + type check: silently skip absent or non-streams groups.
Group group = groups.get(groupId, committedOffset);
+ // -2 (UNCERTAIN) is intentionally included: the plugin may hold data we must be able to delete.
if (group != null
&& group.type() == STREAMS
- && ((StreamsGroup) group).storedDescriptionTopologyEpoch(committedOffset) != -1) {
+ && ((StreamsGroup) group).storedDescriptionTopologyEpoch(committedOffset) != StreamsGroup.STORED_TOPOLOGY_EPOCH_NONE) {
withStored.add(groupId);
}
}
@@ -8568,9 +8597,19 @@ private CoordinatorResult updateTopologyDescriptionEpoc
// Only advance these epochs, never regress them: a stale push committing after the group
// advanced (or after a concurrent higher-epoch push) must not move stored/failed back.
- int newStored = permanentFailure
- ? group.storedDescriptionTopologyEpoch()
- : Math.max(group.storedDescriptionTopologyEpoch(), pushedEpoch);
+ int stored = group.storedDescriptionTopologyEpoch();
+ int newStored;
+ if (permanentFailure) {
+ newStored = stored;
+ } else if (stored == StreamsGroup.STORED_TOPOLOGY_EPOCH_NONE) {
+ // The UNCERTAIN barrier this push wrote before its plugin op is gone: only a delete's
+ // finalize clears UNCERTAIN to NONE, so a plugin.deleteTopology raced this push and,
+ // the plugin op order being unknown, may have wiped the topology just stored. Re-arm
+ // UNCERTAIN to re-solicit a push rather than record an epoch over an empty plugin.
+ newStored = StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN;
+ } else {
+ newStored = Math.max(stored, pushedEpoch);
+ }
int newFailed = permanentFailure
? Math.max(group.failedDescriptionTopologyEpoch(), pushedEpoch)
: group.failedDescriptionTopologyEpoch();
@@ -8588,36 +8627,143 @@ private CoordinatorResult updateTopologyDescriptionEpoc
}
/**
- * 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.
+ * Finalize the stored topology epoch after a {@code plugin.deleteTopology} on a UNCERTAIN(-2)
+ * group. Closes the in-flight-delete-vs-push race: {@code plugin.deleteTopology} and a racing
+ * {@code plugin.setTopology} have no mutual ordering, so a push that advanced the epoch while
+ * our delete was in flight may have been wiped.
+ *
+ *
+ * Stored still UNCERTAIN: no push epoch write raced -> clear to NONE. Residual window:
+ * a raced push whose plugin {@code setTopology} landed after our delete but reported a
+ * transient failure writes no epoch record, so the plugin may still hold data at NONE.
+ * That heals through the re-push the transient failure's back-off solicits, but leaks
+ * the plugin entry if the group empties and is tombstoned first.
+ * Stored advanced past UNCERTAIN: a push raced; the pushed topology may be orphaned over
+ * an empty plugin, so force UNCERTAIN to re-solicit rather than leave a real epoch.
+ * Stored already NONE (e.g. a concurrent path cleared it): no-op.
+ *
*/
- public CoordinatorResult clearStoredDescriptionTopologyEpoch(
- String groupId,
- int expectedStoredEpoch
+ public CoordinatorResult finalizeStoredDescriptionTopologyEpochAfterDelete(
+ String groupId
) {
Group group = groups.get(groupId);
if (!(group instanceof StreamsGroup streamsGroup)) {
return new CoordinatorResult<>(List.of());
}
- if (streamsGroup.storedDescriptionTopologyEpoch() != expectedStoredEpoch) {
+ int stored = streamsGroup.storedDescriptionTopologyEpoch();
+ if (stored == StreamsGroup.STORED_TOPOLOGY_EPOCH_NONE) {
return new CoordinatorResult<>(List.of());
}
+ int newStored = stored == StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN
+ ? StreamsGroup.STORED_TOPOLOGY_EPOCH_NONE
+ : StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN;
CoordinatorRecord record = newStreamsGroupMetadataRecord(
groupId,
streamsGroup.groupEpoch(),
streamsGroup.metadataHash(),
streamsGroup.validatedTopologyEpoch(),
streamsGroup.lastAssignmentConfigs(),
- -1,
+ newStored,
streamsGroup.failedDescriptionTopologyEpoch()
);
return new CoordinatorResult<>(List.of(record), null);
}
+ /**
+ * Batched form of {@link #finalizeStoredDescriptionTopologyEpochAfterDelete}: folds the
+ * per-group smart finalize for every group in {@code groupIds} into one record list so the
+ * cleanup cycle issues a single write per shard.
+ */
+ public CoordinatorResult finalizeStoredDescriptionTopologyEpochAfterDeleteBatch(
+ Set groupIds
+ ) {
+ List records = new ArrayList<>(groupIds.size());
+ for (String groupId : groupIds) {
+ records.addAll(finalizeStoredDescriptionTopologyEpochAfterDelete(groupId).records());
+ }
+ // Non-atomic: the per-group records are independent, so the runtime may split them
+ // across log batches instead of failing a large shard's write on the batch-size limit.
+ return new CoordinatorResult<>(records, null, null, true, false);
+ }
+
+ /**
+ * Mark the group's {@code StoredDescriptionTopologyEpoch} as UNCERTAIN (-2), the durable
+ * barrier written before any plugin-disturbing operation. UNCERTAIN means "the plugin may or
+ * may not hold a topology": it solicits a fresh push from the client and is delete-eligible,
+ * so any failure after this write self-heals (re-push) or is reclaimable (delete) instead of
+ * leaking or getting stuck.
+ *
+ * {@code markWhenNone} controls the {@code NONE} pre-state. Push callers pass {@code true}
+ * (we are about to put data in the plugin, so mark even from NONE). Delete callers pass
+ * {@code false} (a NONE group has nothing in the plugin, so skip the plugin op entirely);
+ * for them the group must also still be empty at the latest state — a group revived since
+ * the caller's committed read drops out so its plugin data is not deleted underneath the
+ * active members.
+ *
+ * @return records carrying the {@code -2} write (empty if already UNCERTAIN or skipped), and a
+ * response of {@code true} iff the caller should run its plugin op for this group.
+ */
+ public CoordinatorResult markStoredDescriptionTopologyEpochUncertain(
+ String groupId,
+ boolean markWhenNone
+ ) {
+ Group group = groups.get(groupId);
+ if (!(group instanceof StreamsGroup streamsGroup)) {
+ return new CoordinatorResult<>(List.of(), Boolean.FALSE);
+ }
+ if (!markWhenNone && !streamsGroup.isEmpty()) {
+ // Delete callers only ever target empty groups (the cleanup scan requires emptiness
+ // and a DeleteGroups tombstone fails a non-empty group with NON_EMPTY_GROUP anyway).
+ // Re-checking here closes the window between the caller's committed read and this
+ // write in which a member may have revived the group.
+ return new CoordinatorResult<>(List.of(), Boolean.FALSE);
+ }
+ int stored = streamsGroup.storedDescriptionTopologyEpoch();
+ if (stored == StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN) {
+ return new CoordinatorResult<>(List.of(), Boolean.TRUE);
+ }
+ if (stored == StreamsGroup.STORED_TOPOLOGY_EPOCH_NONE && !markWhenNone) {
+ return new CoordinatorResult<>(List.of(), Boolean.FALSE);
+ }
+ CoordinatorRecord record = newStreamsGroupMetadataRecord(
+ groupId,
+ streamsGroup.groupEpoch(),
+ streamsGroup.metadataHash(),
+ streamsGroup.validatedTopologyEpoch(),
+ streamsGroup.lastAssignmentConfigs(),
+ StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN,
+ streamsGroup.failedDescriptionTopologyEpoch()
+ );
+ return new CoordinatorResult<>(List.of(record), Boolean.TRUE);
+ }
+
+ /**
+ * Batched UNCERTAIN(-2) barrier write: folds {@link #markStoredDescriptionTopologyEpochUncertain}
+ * with {@code markWhenNone=false} over every group in {@code groupIds}. The response is the
+ * subset that the per-group mark reported eligible for a plugin op (still an empty
+ * streams group at the latest state and now UNCERTAIN); revived or converted groups drop out
+ * so the cleanup cycle does not run {@code plugin.deleteTopology} against them.
+ */
+ public CoordinatorResult, CoordinatorRecord> markStoredDescriptionTopologyEpochUncertainBatch(
+ Set groupIds
+ ) {
+ List records = new ArrayList<>(groupIds.size());
+ Set eligible = new LinkedHashSet<>(groupIds.size());
+ for (String groupId : groupIds) {
+ CoordinatorResult one =
+ markStoredDescriptionTopologyEpochUncertain(groupId, false);
+ if (Boolean.TRUE.equals(one.response())) {
+ eligible.add(groupId);
+ records.addAll(one.records());
+ }
+ }
+ // Non-atomic: the per-group records are independent, so the runtime may split them
+ // across log batches instead of failing a large shard's write on the batch-size limit.
+ // A failed append still fails the whole operation, so the eligible set never reaches the
+ // plugin-delete step; any group whose UNCERTAIN record did commit retries next cycle.
+ return new CoordinatorResult<>(records, eligible, null, true, false);
+ }
+
/**
* 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.
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 c9d57cb9e53c1..7c646793dc7d8 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
@@ -76,6 +76,25 @@ public class StreamsGroup implements Group {
*/
private static final String PROTOCOL_TYPE = "streams";
+ /** Stored topology epoch meaning the plugin definitely holds no topology for this group. */
+ public static final int STORED_TOPOLOGY_EPOCH_NONE = -1;
+ /**
+ * Stored topology epoch meaning the plugin may or may not hold a topology: written durably
+ * before any plugin-disturbing operation. Treated like {@link #STORED_TOPOLOGY_EPOCH_NONE}
+ * for the "solicit a push" decision and like a real (>= 0) epoch for "delete-eligible".
+ */
+ public static final int STORED_TOPOLOGY_EPOCH_UNCERTAIN = -2;
+
+ /**
+ * @return true when {@code storedEpoch} is a real, reliably-stored topology epoch — i.e. the
+ * plugin definitely holds exactly that topology. Real epochs are non-negative; every
+ * sentinel ({@link #STORED_TOPOLOGY_EPOCH_NONE}, {@link #STORED_TOPOLOGY_EPOCH_UNCERTAIN},
+ * and any added later) is negative and therefore not reliably stored.
+ */
+ public static boolean isReliablyStoredTopologyEpoch(int storedEpoch) {
+ return storedEpoch >= 0;
+ }
+
public enum StreamsGroupState {
EMPTY("Empty"),
NOT_READY("NotReady"),
@@ -159,8 +178,11 @@ public static class DeadlineAndEpoch {
private final TimelineInteger validatedTopologyEpoch;
/**
- * The topology epoch most recently accepted by the topology description plugin (KIP-1331). -1 if none stored.
- * Drives the heartbeat-side decision to set TopologyDescriptionRequired=true.
+ * The broker's record of which topology the group's plugin entry holds (KIP-1331): a real epoch
+ * ({@code >= 0}) when the plugin definitely holds that topology, {@link #STORED_TOPOLOGY_EPOCH_NONE}
+ * when it definitely holds nothing, or {@link #STORED_TOPOLOGY_EPOCH_UNCERTAIN} when a barrier was
+ * written before a plugin operation that may not have completed. Drives the heartbeat-side decision
+ * to set TopologyDescriptionRequired=true and the eligibility for plugin deletion.
*/
private final TimelineInteger storedDescriptionTopologyEpoch;
@@ -268,7 +290,7 @@ public StreamsGroup(
// group.validatedTopologyEpoch()` comparison reads 0 here vs. -1 after replay.
this.validatedTopologyEpoch.set(-1);
this.storedDescriptionTopologyEpoch = new TimelineInteger(snapshotRegistry);
- this.storedDescriptionTopologyEpoch.set(-1);
+ this.storedDescriptionTopologyEpoch.set(STORED_TOPOLOGY_EPOCH_NONE);
this.failedDescriptionTopologyEpoch = new TimelineInteger(snapshotRegistry);
this.failedDescriptionTopologyEpoch.set(-1);
this.metadataHash = new TimelineLong(snapshotRegistry);
@@ -757,7 +779,8 @@ public void setValidatedTopologyEpoch(int validatedTopologyEpoch) {
}
/**
- * @return The topology epoch most recently successfully stored by the topology description plugin, or -1 if none.
+ * @return The stored topology epoch: a real epoch ({@code >= 0}), {@link #STORED_TOPOLOGY_EPOCH_NONE},
+ * or {@link #STORED_TOPOLOGY_EPOCH_UNCERTAIN}.
*/
public int storedDescriptionTopologyEpoch() {
return storedDescriptionTopologyEpoch.get();
@@ -766,7 +789,9 @@ public int storedDescriptionTopologyEpoch() {
/**
* Defer tombstoning of an empty streams group while the broker-level topology-description
* cleanup cycle still has work to do for it: a plugin is configured and the persisted
- * {@code StoredDescriptionTopologyEpoch} is not the {@code -1} default. The cycle drives
+ * {@code StoredDescriptionTopologyEpoch} is not {@link #STORED_TOPOLOGY_EPOCH_NONE}. Both a
+ * real epoch and {@link #STORED_TOPOLOGY_EPOCH_UNCERTAIN} defer — an UNCERTAIN group may
+ * still hold plugin data, so it must stay reclaimable by the cycle. The cycle drives
* {@code plugin.deleteTopology} and clears the stored epoch; the next sweep then proceeds
* with tombstoning. When no plugin is configured the gate never holds, so deferring
* indefinitely is not possible.
@@ -778,13 +803,13 @@ public int storedDescriptionTopologyEpoch() {
@Override
public boolean shouldExpire(GroupCoordinatorConfig config) {
return !(config.isStreamsGroupTopologyDescriptionPluginConfigured()
- && storedDescriptionTopologyEpoch() != -1);
+ && storedDescriptionTopologyEpoch() != STORED_TOPOLOGY_EPOCH_NONE);
}
/**
* @param committedOffset A committed offset corresponding to the desired snapshot.
- * @return The topology epoch most recently successfully stored by the topology description plugin at the given
- * committed offset, or -1 if none.
+ * @return The stored topology epoch at the given committed offset: a real epoch ({@code >= 0}),
+ * {@link #STORED_TOPOLOGY_EPOCH_NONE}, or {@link #STORED_TOPOLOGY_EPOCH_UNCERTAIN}.
*/
public int storedDescriptionTopologyEpoch(long committedOffset) {
return storedDescriptionTopologyEpoch.get(committedOffset);
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 0f37e42812dc6..b2bc6f5df67e2 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
@@ -332,6 +332,29 @@ public void armBackoff(String groupId, int topologyEpoch) {
backoff.armOrExtend(groupId, topologyEpoch);
}
+ /**
+ * @return true while the window armed by {@link #throttleConversionDelete} is in effect for
+ * the group, i.e. a recent classic-join conversion delete failed and the join path
+ * must not re-invoke the plugin yet.
+ */
+ public boolean isConversionDeleteThrottled(String groupId) {
+ return backoff.isActive(groupId, StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN);
+ }
+
+ /**
+ * Arm (or continue) the back-off chain that throttles the classic-join conversion delete
+ * against a failing plugin. On {@code REBALANCE_IN_PROGRESS} the classic client retries the
+ * join immediately (no client-side back-off), so without this window a broken plugin would
+ * be hit with {@code deleteTopology} in a tight loop. Keyed at
+ * {@link StreamsGroup#STORED_TOPOLOGY_EPOCH_UNCERTAIN} — the group's stored epoch after the
+ * barrier write and never a real push epoch — so heartbeat/push windows are not disturbed;
+ * a stale real-epoch entry left from before the group emptied is replaced. The window is
+ * dropped by {@link #clearBackoffGroup} when a conversion or cleanup-cycle delete succeeds.
+ */
+ public void throttleConversionDelete(String groupId) {
+ backoff.armIfNotActive(groupId, StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN);
+ }
+
/**
* Settle the per-group back-off after the bookkeeping write that records a push outcome
* (the stored epoch on success, the failed epoch on a permanent failure) completes, and
@@ -509,7 +532,9 @@ private CompletableFuture maybeAttachOne(
return null;
}
Integer storedEpoch = result.storedDescriptionTopologyEpochs().get(describedGroup.groupId());
- if (storedEpoch == null || storedEpoch == -1 || storedEpoch != describedGroup.topology().epoch()) {
+ if (storedEpoch == null
+ || !StreamsGroup.isReliablyStoredTopologyEpoch(storedEpoch)
+ || storedEpoch != describedGroup.topology().epoch()) {
describedGroup.setTopologyDescriptionStatus(TOPOLOGY_DESCRIPTION_STATUS_NOT_STORED);
return null;
}
diff --git a/group-coordinator/src/main/resources/common/message/StreamsGroupMetadataValue.json b/group-coordinator/src/main/resources/common/message/StreamsGroupMetadataValue.json
index 86501cef7b025..e96d981a32fe3 100644
--- a/group-coordinator/src/main/resources/common/message/StreamsGroupMetadataValue.json
+++ b/group-coordinator/src/main/resources/common/message/StreamsGroupMetadataValue.json
@@ -29,7 +29,7 @@
{ "name": "LastAssignmentConfigs","taggedVersions": "0+", "nullableVersions": "0+","tag": 1, "default": null,
"type": "[]LastAssignmentConfig", "about": "The last used configuration parameters as key-value pairs." },
{ "name": "StoredDescriptionTopologyEpoch", "versions": "0+", "taggedVersions": "0+", "tag": 2, "default": -1, "type": "int32",
- "about": "The topology epoch whose description is currently stored in the topology description plugin, or -1 if none is stored." },
+ "about": "The topology epoch the plugin holds, or -1 if none. -2 (uncertain) marks an interrupted plugin operation: it solicits a push like -1 and is delete-eligible like a stored epoch." },
{ "name": "FailedDescriptionTopologyEpoch", "versions": "0+", "taggedVersions": "0+", "tag": 3, "default": -1, "type": "int32",
"about": "The topology epoch whose description push the plugin permanently rejected (signalled by StreamsTopologyDescriptionPermanentFailureException), or -1 if none. Heartbeat-path solicitation is suppressed while this equals the current topology epoch, to avoid hot-looping." }
],
diff --git a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorServiceTest.java b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorServiceTest.java
index dec4fc51eee60..42fa6adbcd879 100644
--- a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorServiceTest.java
+++ b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorServiceTest.java
@@ -1092,9 +1092,7 @@ public void testJoinGroup() {
ArgumentMatchers.eq("classic-group-join"),
ArgumentMatchers.eq(new TopicPartition(Topic.GROUP_METADATA_TOPIC_NAME, 0)),
ArgumentMatchers.any()
- )).thenReturn(CompletableFuture.completedFuture(
- new JoinGroupResponseData()
- ));
+ )).thenReturn(CompletableFuture.completedFuture(Boolean.FALSE));
CompletableFuture responseFuture = service.joinGroup(
requestContext(ApiKeys.JOIN_GROUP),
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 4edd259855029..944d38a3b229d 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
@@ -23,6 +23,8 @@
import org.apache.kafka.common.errors.UnknownMemberIdException;
import org.apache.kafka.common.internals.Topic;
import org.apache.kafka.common.message.DeleteGroupsResponseData;
+import org.apache.kafka.common.message.JoinGroupRequestData;
+import org.apache.kafka.common.message.JoinGroupResponseData;
import org.apache.kafka.common.message.StreamsGroupDescribeResponseData;
import org.apache.kafka.common.message.StreamsGroupHeartbeatRequestData;
import org.apache.kafka.common.message.StreamsGroupHeartbeatResponseData;
@@ -40,6 +42,7 @@
import org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescriptionPlugin;
import org.apache.kafka.coordinator.group.api.streams.StreamsTopologyDescriptionPermanentFailureException;
import org.apache.kafka.coordinator.group.metrics.GroupCoordinatorMetrics;
+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.StreamsGroupTopologyDescriptionConverter;
@@ -47,11 +50,12 @@
import org.apache.kafka.server.util.timer.MockTimer;
import org.junit.jupiter.api.Test;
+import org.mockito.InOrder;
import org.mockito.MockedStatic;
import java.time.Duration;
import java.util.Collections;
-import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -74,9 +78,11 @@
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -201,6 +207,11 @@ public void testUpdateSuccessPersistsStoredEpoch() throws Exception {
eq(GROUP_TP),
any()
)).thenReturn(CompletableFuture.completedFuture(null));
+ when(runtime.scheduleWriteOperation(
+ eq("mark-topology-uncertain"),
+ any(),
+ any()
+ )).thenReturn(CompletableFuture.completedFuture(Boolean.TRUE));
when(runtime.scheduleWriteOperation(
eq("streams-group-set-stored-topology-epoch"),
eq(GROUP_TP),
@@ -225,6 +236,37 @@ public void testUpdateSuccessPersistsStoredEpoch() throws Exception {
assertTrue(heartbeatTopologyDescriptionRequired(runtime, service, 3, -1, -1));
}
+ @Test
+ public void testUpdateSkipsPluginWhenBarrierWriteFindsGroupGone() throws Exception {
+ // The UNCERTAIN barrier write returns false when the group vanished (or stopped being a
+ // streams group) between the validate read and the write. No barrier record exists, so the
+ // push must not create a plugin entry that no cleanup path would ever reclaim.
+ CoordinatorRuntime runtime = mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin = mock(StreamsGroupTopologyDescriptionPlugin.class);
+ when(runtime.scheduleReadOperation(
+ eq("streams-group-topology-description-validate"),
+ eq(GROUP_TP),
+ any()
+ )).thenReturn(CompletableFuture.completedFuture(null));
+ when(runtime.scheduleWriteOperation(
+ eq("mark-topology-uncertain"),
+ any(),
+ any()
+ )).thenReturn(CompletableFuture.completedFuture(Boolean.FALSE));
+
+ GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
+
+ StreamsGroupTopologyDescriptionUpdateResponseData response = service.streamsGroupTopologyDescriptionUpdate(
+ requestContext(ApiKeys.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_UPDATE),
+ validUpdateRequest()
+ ).get(5, TimeUnit.SECONDS);
+
+ assertEquals(Errors.GROUP_ID_NOT_FOUND.code(), response.errorCode());
+ verify(plugin, never()).setTopology(anyString(), anyInt(), any());
+ verify(runtime, never()).scheduleWriteOperation(
+ eq("streams-group-set-stored-topology-epoch"), any(), any());
+ }
+
@Test
public void testUpdatePermanentFailurePersistsFailedEpoch() throws Exception {
CoordinatorRuntime runtime = mockRuntime();
@@ -237,6 +279,11 @@ public void testUpdatePermanentFailurePersistsFailedEpoch() throws Exception {
eq(GROUP_TP),
any()
)).thenReturn(CompletableFuture.completedFuture(null));
+ when(runtime.scheduleWriteOperation(
+ eq("mark-topology-uncertain"),
+ any(),
+ any()
+ )).thenReturn(CompletableFuture.completedFuture(Boolean.TRUE));
when(runtime.scheduleWriteOperation(
eq("streams-group-set-failed-topology-epoch"),
eq(GROUP_TP),
@@ -267,6 +314,11 @@ public void testUpdateTransientFailureWritesNoRecord() throws Exception {
eq(GROUP_TP),
any()
)).thenReturn(CompletableFuture.completedFuture(null));
+ when(runtime.scheduleWriteOperation(
+ eq("mark-topology-uncertain"),
+ any(),
+ any()
+ )).thenReturn(CompletableFuture.completedFuture(Boolean.TRUE));
GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
@@ -303,6 +355,11 @@ public void testUpdatePluginFutureWithNullCauseIsTreatedAsTransient() throws Exc
eq(GROUP_TP),
any()
)).thenReturn(CompletableFuture.completedFuture(null));
+ when(runtime.scheduleWriteOperation(
+ eq("mark-topology-uncertain"),
+ any(),
+ any()
+ )).thenReturn(CompletableFuture.completedFuture(Boolean.TRUE));
GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
@@ -333,6 +390,11 @@ public void testUpdateBackoffArmedWhenStoredEpochWriteFailsAfterPluginSuccess()
eq(GROUP_TP),
any()
)).thenReturn(CompletableFuture.completedFuture(null));
+ when(runtime.scheduleWriteOperation(
+ eq("mark-topology-uncertain"),
+ any(),
+ any()
+ )).thenReturn(CompletableFuture.completedFuture(Boolean.TRUE));
when(runtime.scheduleWriteOperation(
eq("streams-group-set-stored-topology-epoch"),
eq(GROUP_TP),
@@ -405,6 +467,11 @@ public void testUpdatePostPluginWriteRoutingFailureDoesNotArmBackoff() throws Ex
eq(GROUP_TP),
any()
)).thenReturn(CompletableFuture.completedFuture(null));
+ when(runtime.scheduleWriteOperation(
+ eq("mark-topology-uncertain"),
+ any(),
+ any()
+ )).thenReturn(CompletableFuture.completedFuture(Boolean.TRUE));
when(runtime.scheduleWriteOperation(
eq("streams-group-set-stored-topology-epoch"),
eq(GROUP_TP),
@@ -442,6 +509,11 @@ public void testUpdateGroupDisappearsBetweenPluginSuccessAndWriteDropsBackoffEnt
eq(GROUP_TP),
any()
)).thenReturn(CompletableFuture.completedFuture(null));
+ when(runtime.scheduleWriteOperation(
+ eq("mark-topology-uncertain"),
+ any(),
+ any()
+ )).thenReturn(CompletableFuture.completedFuture(Boolean.TRUE));
when(runtime.scheduleWriteOperation(
eq("streams-group-set-stored-topology-epoch"),
eq(GROUP_TP),
@@ -475,6 +547,11 @@ public void testUpdatePluginReturnsNullFutureIsTreatedAsPermanentFailure() throw
eq(GROUP_TP),
any()
)).thenReturn(CompletableFuture.completedFuture(null));
+ when(runtime.scheduleWriteOperation(
+ eq("mark-topology-uncertain"),
+ any(),
+ any()
+ )).thenReturn(CompletableFuture.completedFuture(Boolean.TRUE));
when(runtime.scheduleWriteOperation(
eq("streams-group-set-failed-topology-epoch"),
eq(GROUP_TP),
@@ -741,6 +818,11 @@ public void testDeleteGroupsPluginFailureReturnsGroupDeletionFailed() throws Exc
eq(GROUP_TP),
any()
)).thenReturn(CompletableFuture.completedFuture(Set.of("foo")));
+ when(runtime.scheduleWriteOperation(
+ eq("mark-topology-uncertain-batch"),
+ any(),
+ any()
+ )).thenReturn(CompletableFuture.completedFuture(Set.of("foo")));
GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
@@ -777,6 +859,16 @@ public void testDeleteGroupsPluginSuccessProceedsToTombstone() throws Exception
eq(GROUP_TP),
any()
)).thenReturn(CompletableFuture.completedFuture(Set.of("foo")));
+ when(runtime.scheduleWriteOperation(
+ eq("mark-topology-uncertain-batch"),
+ any(),
+ any()
+ )).thenReturn(CompletableFuture.completedFuture(Set.of("foo")));
+ when(runtime.scheduleWriteOperation(
+ eq("finalize-stored-topology-epoch-after-delete-batch"),
+ eq(GROUP_TP),
+ any()
+ )).thenReturn(CompletableFuture.completedFuture(null));
DeleteGroupsResponseData.DeletableGroupResultCollection tombstoneResult =
new DeleteGroupsResponseData.DeletableGroupResultCollection();
@@ -801,6 +893,10 @@ public void testDeleteGroupsPluginSuccessProceedsToTombstone() throws Exception
assertEquals(Errors.NONE.code(), result.errorCode());
assertNull(result.errorMessage());
verify(plugin, times(1)).deleteTopology("foo");
+ // The successful plugin delete is smart-finalized before the tombstone, so a group that
+ // was revived past the tombstone cannot keep a raced push's epoch over the emptied plugin.
+ verify(runtime, times(1)).scheduleWriteOperation(
+ eq("finalize-stored-topology-epoch-after-delete-batch"), eq(GROUP_TP), any());
verify(runtime, times(1)).scheduleWriteOperation(
eq("delete-groups"), eq(GROUP_TP), any());
}
@@ -879,6 +975,10 @@ public void testDeleteGroupsSkipsPluginCallWhenNoStoredTopology() throws Excepti
assertNotNull(result);
assertEquals(Errors.NONE.code(), result.errorCode());
verify(plugin, never()).deleteTopology(anyString());
+ // No stored topology in the batch: the UNCERTAIN mark write must be skipped entirely,
+ // not scheduled as a no-op on the shard's event loop.
+ verify(runtime, never()).scheduleWriteOperation(
+ eq("mark-topology-uncertain-batch"), any(), any());
verify(runtime, times(1)).scheduleWriteOperation(
eq("delete-groups"), eq(GROUP_TP), any());
}
@@ -905,6 +1005,16 @@ public void testDeleteGroupsMixedPluginOutcome() throws Exception {
eq(GROUP_TP),
any()
)).thenReturn(CompletableFuture.completedFuture(Set.of("good", "bad")));
+ when(runtime.scheduleWriteOperation(
+ eq("mark-topology-uncertain-batch"),
+ any(),
+ any()
+ )).thenReturn(CompletableFuture.completedFuture(Set.of("good", "bad")));
+ when(runtime.scheduleWriteOperation(
+ eq("finalize-stored-topology-epoch-after-delete-batch"),
+ any(),
+ any()
+ )).thenReturn(CompletableFuture.completedFuture(null));
DeleteGroupsResponseData.DeletableGroupResultCollection tombstoneResult =
new DeleteGroupsResponseData.DeletableGroupResultCollection();
@@ -934,6 +1044,141 @@ public void testDeleteGroupsMixedPluginOutcome() throws Exception {
assertEquals("Topology description plugin failed to delete the topology.", badResult.errorMessage());
}
+ @Test
+ public void testDeleteGroupsPreservesBackoffOnPluginFailure() throws Exception {
+ // Symmetric with the cleanup cycle (testCleanupCyclePreservesBackoffOnPluginFailure): a
+ // failed plugin.deleteTopology in DeleteGroups leaves the group at UNCERTAIN(-2) — not
+ // tombstoned — which re-solicits a push on the next heartbeat. The back-off entry must
+ // survive so a rejoining member does not immediately re-attack the still-broken plugin.
+ CoordinatorRuntime runtime = mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin = mock(StreamsGroupTopologyDescriptionPlugin.class);
+ when(plugin.deleteTopology("foo"))
+ .thenReturn(CompletableFuture.failedFuture(new RuntimeException("plugin offline")));
+ when(runtime.scheduleWriteOperation(eq("delete-share-groups"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Map.of()));
+ when(runtime.scheduleReadOperation(eq("streams-group-topology-pre-delete"), eq(GROUP_TP), any()))
+ .thenReturn(CompletableFuture.completedFuture(Set.of("foo")));
+ when(runtime.scheduleWriteOperation(eq("mark-topology-uncertain-batch"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Set.of("foo")));
+
+ GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
+ service.streamsGroupTopologyDescriptionManager().armBackoff("foo", 4);
+ service.deleteGroups(
+ requestContext(ApiKeys.DELETE_GROUPS), List.of("foo"), BufferSupplier.NO_CACHING
+ ).get(5, TimeUnit.SECONDS);
+
+ assertFalse(heartbeatTopologyDescriptionRequired(runtime, service, 4, 2, -1),
+ "failed plugin delete in DeleteGroups must not clear the back-off entry");
+ }
+
+ @Test
+ public void testDeleteGroupsClearsBackoffOnPluginSuccess() throws Exception {
+ // Counterpart: a successful plugin.deleteTopology means the group is on its way to
+ // tombstone, so its back-off entry is no longer load-bearing and is cleared.
+ CoordinatorRuntime runtime = mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin = mock(StreamsGroupTopologyDescriptionPlugin.class);
+ when(plugin.deleteTopology("foo")).thenReturn(CompletableFuture.completedFuture(null));
+ when(runtime.scheduleWriteOperation(eq("delete-share-groups"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Map.of()));
+ when(runtime.scheduleReadOperation(eq("streams-group-topology-pre-delete"), eq(GROUP_TP), any()))
+ .thenReturn(CompletableFuture.completedFuture(Set.of("foo")));
+ when(runtime.scheduleWriteOperation(eq("mark-topology-uncertain-batch"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Set.of("foo")));
+ when(runtime.scheduleWriteOperation(eq("finalize-stored-topology-epoch-after-delete-batch"), eq(GROUP_TP), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+ DeleteGroupsResponseData.DeletableGroupResultCollection tombstoneResult =
+ new DeleteGroupsResponseData.DeletableGroupResultCollection();
+ tombstoneResult.add(new DeleteGroupsResponseData.DeletableGroupResult().setGroupId("foo"));
+ when(runtime.scheduleWriteOperation(eq("delete-groups"), eq(GROUP_TP), any()))
+ .thenReturn(CompletableFuture.completedFuture(tombstoneResult));
+
+ GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
+ service.streamsGroupTopologyDescriptionManager().armBackoff("foo", 4);
+ service.deleteGroups(
+ requestContext(ApiKeys.DELETE_GROUPS), List.of("foo"), BufferSupplier.NO_CACHING
+ ).get(5, TimeUnit.SECONDS);
+
+ assertTrue(heartbeatTopologyDescriptionRequired(runtime, service, 4, 2, -1),
+ "successful plugin delete in DeleteGroups must clear the back-off entry");
+ }
+
+ @Test
+ public void testDeleteGroupsMarksUncertainBeforePluginDelete() throws Exception {
+ CoordinatorRuntime runtime = mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin = mock(StreamsGroupTopologyDescriptionPlugin.class);
+ when(plugin.deleteTopology("g")).thenReturn(CompletableFuture.completedFuture(null));
+
+ when(runtime.scheduleWriteOperation(eq("delete-share-groups"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Map.of()));
+ when(runtime.scheduleReadOperation(eq("streams-group-topology-pre-delete"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Set.of("g")));
+ when(runtime.scheduleWriteOperation(eq("mark-topology-uncertain-batch"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Set.of("g")));
+ when(runtime.scheduleWriteOperation(eq("finalize-stored-topology-epoch-after-delete-batch"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ DeleteGroupsResponseData.DeletableGroupResultCollection tombstoneResult =
+ new DeleteGroupsResponseData.DeletableGroupResultCollection();
+ tombstoneResult.add(new DeleteGroupsResponseData.DeletableGroupResult().setGroupId("g"));
+ when(runtime.scheduleWriteOperation(eq("delete-groups"), eq(GROUP_TP), any()))
+ .thenReturn(CompletableFuture.completedFuture(tombstoneResult));
+
+ GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
+ service.deleteGroups(
+ requestContext(ApiKeys.DELETE_GROUPS),
+ List.of("g"),
+ BufferSupplier.NO_CACHING
+ ).get(5, TimeUnit.SECONDS);
+
+ InOrder inOrder = inOrder(runtime, plugin);
+ inOrder.verify(runtime).scheduleWriteOperation(eq("mark-topology-uncertain-batch"), any(), any());
+ inOrder.verify(plugin).deleteTopology("g");
+ inOrder.verify(runtime).scheduleWriteOperation(eq("finalize-stored-topology-epoch-after-delete-batch"), any(), any());
+ inOrder.verify(runtime).scheduleWriteOperation(eq("delete-groups"), any(), any());
+ }
+
+ @Test
+ public void testDeleteGroupsSkipsPluginDeleteForRevivedGroup() throws Exception {
+ // The pre-delete read finds "g" with a stored topology, but the mark batch returns an
+ // empty subset: "g" was revived between the committed read and the barrier write. The
+ // plugin delete must be skipped (no barrier was written), no plugin failure recorded for
+ // it, and the pipeline must still proceed to the tombstone write, which reports the
+ // group's fate (NON_EMPTY_GROUP for a revived group).
+ CoordinatorRuntime runtime = mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin = mock(StreamsGroupTopologyDescriptionPlugin.class);
+
+ when(runtime.scheduleWriteOperation(eq("delete-share-groups"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Map.of()));
+ when(runtime.scheduleReadOperation(eq("streams-group-topology-pre-delete"), eq(GROUP_TP), any()))
+ .thenReturn(CompletableFuture.completedFuture(Set.of("g")));
+ when(runtime.scheduleWriteOperation(eq("mark-topology-uncertain-batch"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Set.of()));
+
+ DeleteGroupsResponseData.DeletableGroupResultCollection tombstoneResult =
+ new DeleteGroupsResponseData.DeletableGroupResultCollection();
+ tombstoneResult.add(new DeleteGroupsResponseData.DeletableGroupResult()
+ .setGroupId("g")
+ .setErrorCode(Errors.NON_EMPTY_GROUP.code()));
+ when(runtime.scheduleWriteOperation(eq("delete-groups"), eq(GROUP_TP), any()))
+ .thenReturn(CompletableFuture.completedFuture(tombstoneResult));
+
+ GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
+ DeleteGroupsResponseData.DeletableGroupResultCollection results = service.deleteGroups(
+ requestContext(ApiKeys.DELETE_GROUPS),
+ List.of("g"),
+ BufferSupplier.NO_CACHING
+ ).get(5, TimeUnit.SECONDS);
+
+ DeleteGroupsResponseData.DeletableGroupResult result = results.find("g");
+ assertNotNull(result);
+ assertEquals(Errors.NON_EMPTY_GROUP.code(), result.errorCode());
+ verify(plugin, never()).deleteTopology(anyString());
+ // No plugin delete ran, so there is nothing to smart-finalize either.
+ verify(runtime, never()).scheduleWriteOperation(
+ eq("finalize-stored-topology-epoch-after-delete-batch"), any(), any());
+ verify(runtime, times(1)).scheduleWriteOperation(eq("delete-groups"), eq(GROUP_TP), any());
+ }
+
@Test
public void testCleanupCycleNoOpWhenNoPlugin() {
// No plugin configured -> the cycle must not even touch the runtime.
@@ -943,44 +1188,110 @@ public void testCleanupCycleNoOpWhenNoPlugin() {
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());
+ verify(runtime, never()).scheduleWriteOperation(eq("mark-topology-uncertain-batch"), any(), any());
+ }
+
+ @Test
+ public void testCleanupCycleMarksUncertainThenDeletesThenFinalizes() {
+ // The cycle must write the UNCERTAIN barrier batch before the plugin delete and run the
+ // smart finalize after it, in that order.
+ CoordinatorRuntime runtime = mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin = mock(StreamsGroupTopologyDescriptionPlugin.class);
+ when(plugin.deleteTopology("foo")).thenReturn(CompletableFuture.completedFuture(null));
+
+ Set eligible = Set.of("foo");
+ when(runtime.scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"), any()))
+ .thenReturn(List.of(CompletableFuture.completedFuture(eligible)));
+ when(runtime.scheduleWriteOperation(eq("mark-topology-uncertain-batch"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Set.of("foo")));
+ when(runtime.scheduleWriteOperation(eq("finalize-stored-topology-epoch-after-delete-batch"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
+ service.runOneStreamsTopologyCleanupCycle();
+
+ InOrder inOrder = inOrder(runtime, plugin);
+ inOrder.verify(runtime).scheduleWriteOperation(eq("mark-topology-uncertain-batch"), any(), any());
+ inOrder.verify(plugin).deleteTopology("foo");
+ inOrder.verify(runtime).scheduleWriteOperation(eq("finalize-stored-topology-epoch-after-delete-batch"), any(), any());
+ }
+
+ @Test
+ public void testCleanupCycleSkipsDeleteForRevivedGroup() {
+ // The mark batch returns an empty subset: "foo" was revived (or converted) between the
+ // committed scan and the mark write. The cycle must not delete it nor finalize.
+ CoordinatorRuntime runtime = mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin = mock(StreamsGroupTopologyDescriptionPlugin.class);
+
+ Set eligible = Set.of("foo");
+ when(runtime.scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"), any()))
+ .thenReturn(List.of(CompletableFuture.completedFuture(eligible)));
+ when(runtime.scheduleWriteOperation(eq("mark-topology-uncertain-batch"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Set.of()));
+
+ GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
+ service.runOneStreamsTopologyCleanupCycle();
+
+ verify(plugin, never()).deleteTopology(any());
+ verify(runtime, never()).scheduleWriteOperation(eq("finalize-stored-topology-epoch-after-delete-batch"), any(), any());
+ }
+
+ @Test
+ public void testCleanupCycleSwallowsMarkWriteFailure() throws Exception {
+ // A routine write failure (e.g. NOT_COORDINATOR during a shard move) on the UNCERTAIN
+ // mark must not fail the whole cycle's allOf: the partition is skipped with a targeted
+ // warn and the next cycle retries. No plugin delete and no finalize may run for it.
+ CoordinatorRuntime runtime = mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin = mock(StreamsGroupTopologyDescriptionPlugin.class);
+ when(runtime.scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"), any()))
+ .thenReturn(List.of(CompletableFuture.completedFuture(Set.of("foo"))));
+ when(runtime.scheduleWriteOperation(eq("mark-topology-uncertain-batch"), any(), any()))
+ .thenReturn(CompletableFuture.failedFuture(Errors.NOT_COORDINATOR.exception()));
+
+ GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
+ service.runOneStreamsTopologyCleanupCycle().get(5, TimeUnit.SECONDS);
+
+ verify(plugin, never()).deleteTopology(any());
+ verify(runtime, never()).scheduleWriteOperation(eq("finalize-stored-topology-epoch-after-delete-batch"), 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.
+ // Eligibility scan returns one group at storedEpoch=4; the mark batch marks it UNCERTAIN;
+ // plugin succeeds; the cycle must schedule the smart finalize so a concurrent setTopology
+ // that has advanced the field is re-solicited rather than silently undone.
CoordinatorRuntime 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(List.of(CompletableFuture.completedFuture(Set.of("foo"))));
+ when(runtime.scheduleWriteOperation(eq("mark-topology-uncertain-batch"), eq(GROUP_TP), any()))
+ .thenReturn(CompletableFuture.completedFuture(Set.of("foo")));
+ when(runtime.scheduleWriteOperation(eq("finalize-stored-topology-epoch-after-delete-batch"), 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());
+ verify(runtime, times(1)).scheduleWriteOperation(eq("finalize-stored-topology-epoch-after-delete-batch"), eq(GROUP_TP), any());
}
@Test
public void testCleanupCycleBatchesClearWritesPerPartition() {
// Two eligible groups land on the same partition's eligibility read — they must trigger
- // exactly one scheduleWriteOperation carrying both conditional clears, not one write per
+ // exactly one finalize scheduleWriteOperation carrying both groups, not one write per
// group.
CoordinatorRuntime runtime = mockRuntime();
StreamsGroupTopologyDescriptionPlugin plugin = mock(StreamsGroupTopologyDescriptionPlugin.class);
when(plugin.deleteTopology("foo")).thenReturn(CompletableFuture.completedFuture(null));
when(plugin.deleteTopology("bar")).thenReturn(CompletableFuture.completedFuture(null));
- Map eligible = new LinkedHashMap<>();
- eligible.put("foo", 4);
- eligible.put("bar", 9);
+ Set eligible = new LinkedHashSet<>(List.of("foo", "bar"));
when(runtime.scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"), any()))
.thenReturn(List.of(CompletableFuture.completedFuture(eligible)));
- when(runtime.scheduleWriteOperation(eq("clear-stored-topology-epoch"), any(), any()))
+ when(runtime.scheduleWriteOperation(eq("mark-topology-uncertain-batch"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Set.of("foo", "bar")));
+ when(runtime.scheduleWriteOperation(eq("finalize-stored-topology-epoch-after-delete-batch"), any(), any()))
.thenReturn(CompletableFuture.completedFuture(null));
GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
@@ -988,26 +1299,28 @@ public void testCleanupCycleBatchesClearWritesPerPartition() {
verify(plugin, times(1)).deleteTopology("foo");
verify(plugin, times(1)).deleteTopology("bar");
- // One write covers both groups; not two per-group writes.
- verify(runtime, times(1)).scheduleWriteOperation(eq("clear-stored-topology-epoch"), any(), any());
+ // One finalize write covers both groups; not two per-group writes.
+ verify(runtime, times(1)).scheduleWriteOperation(eq("finalize-stored-topology-epoch-after-delete-batch"), any(), 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.
+ // Plugin delete fails -> the cycle must NOT finalize; the group is left at -2 so it stays
+ // delete-eligible and re-soliciting, and the next cycle retries the plugin call.
CoordinatorRuntime 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))));
+ .thenReturn(List.of(CompletableFuture.completedFuture(Set.of("foo"))));
+ when(runtime.scheduleWriteOperation(eq("mark-topology-uncertain-batch"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Set.of("foo")));
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());
+ verify(runtime, never()).scheduleWriteOperation(eq("finalize-stored-topology-epoch-after-delete-batch"), any(), any());
}
@Test
@@ -1023,7 +1336,9 @@ public void testCleanupCyclePreservesBackoffOnPluginFailure() throws Exception {
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))));
+ .thenReturn(List.of(CompletableFuture.completedFuture(Set.of("foo"))));
+ when(runtime.scheduleWriteOperation(eq("mark-topology-uncertain-batch"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Set.of("foo")));
GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
// Arm a back-off entry at the same currentEpoch we will probe with the heartbeat helper,
@@ -1047,8 +1362,10 @@ public void testCleanupCycleClearsBackoffOnPluginSuccess() throws Exception {
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(List.of(CompletableFuture.completedFuture(Set.of("foo"))));
+ when(runtime.scheduleWriteOperation(eq("mark-topology-uncertain-batch"), eq(GROUP_TP), any()))
+ .thenReturn(CompletableFuture.completedFuture(Set.of("foo")));
+ when(runtime.scheduleWriteOperation(eq("finalize-stored-topology-epoch-after-delete-batch"), eq(GROUP_TP), any()))
.thenReturn(CompletableFuture.completedFuture(null));
GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
@@ -1061,17 +1378,17 @@ public void testCleanupCycleClearsBackoffOnPluginSuccess() throws Exception {
@Test
public void testCleanupCycleEmptyEligibility() {
- // No groups eligible -> plugin is not called and no clear write is scheduled.
+ // No groups eligible -> the mark batch is not scheduled and the plugin is not called.
CoordinatorRuntime 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())));
+ .thenReturn(List.of(CompletableFuture.completedFuture(Set.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());
+ verify(runtime, never()).scheduleWriteOperation(eq("mark-topology-uncertain-batch"), any(), any());
}
@Test
@@ -1093,29 +1410,31 @@ public void testCleanupCycleSingleFlightSkipsConcurrentCycle() {
@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.
+ // completes synchronously, but the smart-finalize 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 finalize 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 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 parkedClearWrite = new CompletableFuture<>();
- when(runtime.scheduleWriteOperation(eq("clear-stored-topology-epoch"), eq(GROUP_TP), any()))
- .thenReturn(parkedClearWrite);
+ .thenReturn(List.of(CompletableFuture.completedFuture(Set.of("foo"))));
+ when(runtime.scheduleWriteOperation(eq("mark-topology-uncertain-batch"), eq(GROUP_TP), any()))
+ .thenReturn(CompletableFuture.completedFuture(Set.of("foo")));
+ CompletableFuture parkedFinalizeWrite = new CompletableFuture<>();
+ when(runtime.scheduleWriteOperation(eq("finalize-stored-topology-epoch-after-delete-batch"), eq(GROUP_TP), any()))
+ .thenReturn(parkedFinalizeWrite);
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.
+ // Plugin call resolved synchronously but finalize-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);
+ // Settle the finalize-write: flag should now release, next cycle scans afresh.
+ parkedFinalizeWrite.complete(null);
service.streamsGroupTopologyDescriptionManager().runOnce(service::runOneStreamsTopologyCleanupCycle);
verify(runtime, times(2)).scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"), any());
}
@@ -1144,14 +1463,16 @@ 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
+ // plugin delete settles, smart finalize write settles), then issue a second cycle
// and verify it observes the released flag by scheduling a fresh read.
CoordinatorRuntime 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(List.of(CompletableFuture.completedFuture(Set.of("foo"))));
+ when(runtime.scheduleWriteOperation(eq("mark-topology-uncertain-batch"), eq(GROUP_TP), any()))
+ .thenReturn(CompletableFuture.completedFuture(Set.of("foo")));
+ when(runtime.scheduleWriteOperation(eq("finalize-stored-topology-epoch-after-delete-batch"), eq(GROUP_TP), any()))
.thenReturn(CompletableFuture.completedFuture(null));
GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
@@ -1172,8 +1493,8 @@ public void testCleanupCycleSkipsFollowUpWorkOncePastShutdown() throws Exception
// behavior.
CoordinatorRuntime runtime = mockRuntime();
StreamsGroupTopologyDescriptionPlugin plugin = mock(StreamsGroupTopologyDescriptionPlugin.class);
- CompletableFuture> parkedRead = new CompletableFuture<>();
- when(runtime.>scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"), any()))
+ CompletableFuture> parkedRead = new CompletableFuture<>();
+ when(runtime.>scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"), any()))
.thenReturn(List.of(parkedRead));
GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
@@ -1184,11 +1505,11 @@ public void testCleanupCycleSkipsFollowUpWorkOncePastShutdown() throws Exception
// 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));
+ // mark batch, plugin dispatch, and finalize write that would have followed.
+ parkedRead.complete(Set.of("foo"));
verify(plugin, never()).deleteTopology(anyString());
- verify(runtime, never()).scheduleWriteOperation(eq("clear-stored-topology-epoch"), any(), any());
+ verify(runtime, never()).scheduleWriteOperation(eq("mark-topology-uncertain-batch"), any(), any());
}
@Test
@@ -1462,6 +1783,8 @@ public void testUpdateSuccessRecordsSetSuccessSensor() throws Exception {
.thenReturn(CompletableFuture.completedFuture(null));
when(runtime.scheduleReadOperation(eq("streams-group-topology-description-validate"), eq(GROUP_TP), any()))
.thenReturn(CompletableFuture.completedFuture(null));
+ when(runtime.scheduleWriteOperation(eq("mark-topology-uncertain"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Boolean.TRUE));
when(runtime.scheduleWriteOperation(eq("streams-group-set-stored-topology-epoch"), eq(GROUP_TP), any()))
.thenReturn(CompletableFuture.completedFuture(null));
@@ -1485,6 +1808,8 @@ public void testUpdatePermanentFailureRecordsSetErrorSensor() throws Exception {
new StreamsTopologyDescriptionPermanentFailureException("too large")));
when(runtime.scheduleReadOperation(eq("streams-group-topology-description-validate"), eq(GROUP_TP), any()))
.thenReturn(CompletableFuture.completedFuture(null));
+ when(runtime.scheduleWriteOperation(eq("mark-topology-uncertain"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Boolean.TRUE));
when(runtime.scheduleWriteOperation(eq("streams-group-set-failed-topology-epoch"), eq(GROUP_TP), any()))
.thenReturn(CompletableFuture.completedFuture(null));
@@ -1507,6 +1832,8 @@ public void testUpdateTransientFailureRecordsSetErrorSensor() throws Exception {
.thenReturn(CompletableFuture.failedFuture(new RuntimeException("backend offline")));
when(runtime.scheduleReadOperation(eq("streams-group-topology-description-validate"), eq(GROUP_TP), any()))
.thenReturn(CompletableFuture.completedFuture(null));
+ when(runtime.scheduleWriteOperation(eq("mark-topology-uncertain"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Boolean.TRUE));
GroupCoordinatorMetrics metrics = mock(GroupCoordinatorMetrics.class);
GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true, new MockTimer(), metrics);
@@ -1739,4 +2066,252 @@ private static boolean heartbeatTopologyDescriptionRequired(
).get(5, TimeUnit.SECONDS);
return result.data().topologyDescriptionRequired();
}
+
+ @Test
+ public void testPushMarksUncertainBeforePluginSetThenAdvances() throws Exception {
+ CoordinatorRuntime runtime = mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin = mock(StreamsGroupTopologyDescriptionPlugin.class);
+ when(plugin.setTopology(eq("foo"), eq(3), any())).thenReturn(CompletableFuture.completedFuture(null));
+
+ when(runtime.scheduleReadOperation(eq("streams-group-topology-description-validate"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+ when(runtime.scheduleWriteOperation(eq("mark-topology-uncertain"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Boolean.TRUE));
+ when(runtime.scheduleWriteOperation(eq("streams-group-set-stored-topology-epoch"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
+ service.streamsGroupTopologyDescriptionUpdate(
+ requestContext(ApiKeys.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_UPDATE),
+ validUpdateRequest()
+ ).get(5, TimeUnit.SECONDS);
+
+ InOrder inOrder = inOrder(runtime, plugin);
+ inOrder.verify(runtime).scheduleWriteOperation(eq("mark-topology-uncertain"), any(), any());
+ inOrder.verify(plugin).setTopology(eq("foo"), eq(3), any());
+ inOrder.verify(runtime).scheduleWriteOperation(eq("streams-group-set-stored-topology-epoch"), any(), any());
+ }
+
+ private static JoinGroupRequestData classicJoinRequest(String groupId) {
+ return new JoinGroupRequestData()
+ .setGroupId(groupId)
+ .setSessionTimeoutMs(30000);
+ }
+
+ @Test
+ public void testClassicJoinDeletesTopologyBeforeConvertingEmptyStreamsGroup() throws Exception {
+ CoordinatorRuntime runtime = mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin = mock(StreamsGroupTopologyDescriptionPlugin.class);
+ when(plugin.deleteTopology("g")).thenReturn(CompletableFuture.completedFuture(null));
+ when(runtime.scheduleWriteOperation(eq("mark-topology-uncertain"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Boolean.TRUE));
+ when(runtime.scheduleWriteOperation(eq("finalize-stored-topology-epoch-after-delete-batch"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+ // The first classic-group-join detects the empty streams group with a stored topology and
+ // returns true (cleanup needed, no conversion); the second runs after cleanup and converts,
+ // returning false.
+ when(runtime.scheduleWriteOperation(eq("classic-group-join"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Boolean.TRUE))
+ .thenReturn(CompletableFuture.completedFuture(Boolean.FALSE));
+
+ GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
+ // classicGroupJoin completes responseFuture internally; with a mock runtime that operation
+ // never runs, so we await the scheduling of the join writes rather than the response.
+ service.joinGroup(requestContext(ApiKeys.JOIN_GROUP), classicJoinRequest("g"), BufferSupplier.NO_CACHING);
+
+ verify(runtime, timeout(5000).times(2)).scheduleWriteOperation(eq("classic-group-join"), any(), any());
+ // The successful conversion delete is smart-finalized after the re-join: a no-op for the
+ // converted group, but it heals a raced push if the group was revived in between.
+ verify(runtime, timeout(5000).times(1)).scheduleWriteOperation(
+ eq("finalize-stored-topology-epoch-after-delete-batch"), any(), any());
+ InOrder inOrder = inOrder(runtime, plugin);
+ inOrder.verify(runtime).scheduleWriteOperation(eq("classic-group-join"), any(), any());
+ inOrder.verify(runtime).scheduleWriteOperation(eq("mark-topology-uncertain"), any(), any());
+ inOrder.verify(plugin).deleteTopology("g");
+ inOrder.verify(runtime).scheduleWriteOperation(eq("classic-group-join"), any(), any());
+ inOrder.verify(runtime).scheduleWriteOperation(eq("finalize-stored-topology-epoch-after-delete-batch"), any(), any());
+ }
+
+ @Test
+ public void testClassicJoinFailsWhenPluginDeleteFailsAndDoesNotConvert() throws Exception {
+ CoordinatorRuntime runtime = mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin = mock(StreamsGroupTopologyDescriptionPlugin.class);
+ when(plugin.deleteTopology("g"))
+ .thenReturn(CompletableFuture.failedFuture(new RuntimeException("boom")));
+ when(runtime.scheduleWriteOperation(eq("mark-topology-uncertain"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Boolean.TRUE));
+ // The single classic-group-join detects the empty streams group and returns true; the
+ // post-cleanup conversion never runs because the plugin delete fails.
+ when(runtime.scheduleWriteOperation(eq("classic-group-join"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Boolean.TRUE));
+
+ GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
+ JoinGroupResponseData resp = service.joinGroup(
+ requestContext(ApiKeys.JOIN_GROUP), classicJoinRequest("g"), BufferSupplier.NO_CACHING)
+ .get(5, TimeUnit.SECONDS);
+
+ assertEquals(Errors.REBALANCE_IN_PROGRESS.code(), resp.errorCode());
+ verify(runtime, times(1)).scheduleWriteOperation(eq("classic-group-join"), any(), any());
+ }
+
+ @Test
+ public void testClassicJoinThrottlesPluginDeleteAfterFailure() throws Exception {
+ // First join: the conversion delete fails -> REBALANCE_IN_PROGRESS and the throttle is
+ // armed. The classic client retries the join immediately (RebalanceInProgressException
+ // skips its retry back-off), so the retry must fail fast without re-invoking the plugin
+ // or re-scheduling the mark write while the throttle window is in effect.
+ CoordinatorRuntime runtime = mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin = mock(StreamsGroupTopologyDescriptionPlugin.class);
+ when(plugin.deleteTopology("g"))
+ .thenReturn(CompletableFuture.failedFuture(new RuntimeException("boom")));
+ when(runtime.scheduleWriteOperation(eq("mark-topology-uncertain"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Boolean.TRUE));
+ // Every join detects the empty streams group with a stored topology (cleanup needed).
+ when(runtime.scheduleWriteOperation(eq("classic-group-join"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Boolean.TRUE));
+
+ GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
+ JoinGroupResponseData first = service.joinGroup(
+ requestContext(ApiKeys.JOIN_GROUP), classicJoinRequest("g"), BufferSupplier.NO_CACHING)
+ .get(5, TimeUnit.SECONDS);
+ assertEquals(Errors.REBALANCE_IN_PROGRESS.code(), first.errorCode());
+
+ JoinGroupResponseData second = service.joinGroup(
+ requestContext(ApiKeys.JOIN_GROUP), classicJoinRequest("g"), BufferSupplier.NO_CACHING)
+ .get(5, TimeUnit.SECONDS);
+
+ assertEquals(Errors.REBALANCE_IN_PROGRESS.code(), second.errorCode());
+ verify(plugin, times(1)).deleteTopology("g");
+ verify(runtime, times(1)).scheduleWriteOperation(eq("mark-topology-uncertain"), any(), any());
+ }
+
+ @Test
+ public void testClassicJoinSkipsPluginDeleteWhenBarrierWriteFindsGroupChanged() throws Exception {
+ // The barrier write returns false when the group changed underneath the join (revived,
+ // converted, or removed) between the cleanup check and the mark: no barrier exists, so the
+ // plugin delete must be skipped — it could wipe a live group's topology — and the join
+ // fails with a retriable error so the client retries against the latest state.
+ CoordinatorRuntime runtime = mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin = mock(StreamsGroupTopologyDescriptionPlugin.class);
+ when(runtime.scheduleWriteOperation(eq("mark-topology-uncertain"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Boolean.FALSE));
+ when(runtime.scheduleWriteOperation(eq("classic-group-join"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Boolean.TRUE));
+
+ GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
+ JoinGroupResponseData resp = service.joinGroup(
+ requestContext(ApiKeys.JOIN_GROUP), classicJoinRequest("g"), BufferSupplier.NO_CACHING)
+ .get(5, TimeUnit.SECONDS);
+
+ assertEquals(Errors.REBALANCE_IN_PROGRESS.code(), resp.errorCode());
+ verify(plugin, never()).deleteTopology(any());
+ verify(runtime, times(1)).scheduleWriteOperation(eq("classic-group-join"), any(), any());
+ }
+
+ @Test
+ public void testClassicJoinSkipsPluginDeleteWhenGroupAlreadyClassic() throws Exception {
+ CoordinatorRuntime runtime = mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin = mock(StreamsGroupTopologyDescriptionPlugin.class);
+ // The single classic-group-join finds an already-classic group, completes the response and
+ // returns false (no cleanup needed).
+ when(runtime.scheduleWriteOperation(eq("classic-group-join"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Boolean.FALSE));
+
+ GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
+ service.joinGroup(requestContext(ApiKeys.JOIN_GROUP), classicJoinRequest("g"), BufferSupplier.NO_CACHING);
+
+ verify(runtime, timeout(5000)).scheduleWriteOperation(eq("classic-group-join"), any(), any());
+ verify(runtime, times(1)).scheduleWriteOperation(eq("classic-group-join"), any(), any());
+ verify(runtime, never()).scheduleWriteOperation(eq("mark-topology-uncertain"), any(), any());
+ verify(plugin, never()).deleteTopology(any());
+ }
+
+ // -----------------------------------------------------------------------
+ // Regression tests for the UNCERTAIN-epoch gap closure (Task 7)
+ // -----------------------------------------------------------------------
+
+ @Test
+ public void testHalfPushedGroupIsDeleteEligibleViaUncertain() {
+ // Regression: plugin.setTopology succeeded but the epoch-advance write failed, leaving
+ // storedEpoch at UNCERTAIN (-2). The cleanup cycle must still call plugin.deleteTopology
+ // rather than skipping the group because storedEpoch != NONE.
+ CoordinatorRuntime runtime = mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin = mock(StreamsGroupTopologyDescriptionPlugin.class);
+ when(plugin.deleteTopology("g")).thenReturn(CompletableFuture.completedFuture(null));
+ Set eligible = Set.of("g");
+ when(runtime.scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"), any()))
+ .thenReturn(List.of(CompletableFuture.completedFuture(eligible)));
+ when(runtime.scheduleWriteOperation(eq("mark-topology-uncertain-batch"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Set.of("g")));
+ when(runtime.scheduleWriteOperation(eq("finalize-stored-topology-epoch-after-delete-batch"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
+ service.runOneStreamsTopologyCleanupCycle();
+
+ verify(plugin).deleteTopology("g");
+ }
+
+ @Test
+ public void testCleanupCycleFinalizesAfterDeleteNotPlainClear() {
+ // Regression: after a successful plugin.deleteTopology the cycle must write the smart-
+ // finalize batch op ("finalize-stored-topology-epoch-after-delete-batch") so that any
+ // racing push that advanced storedEpoch past UNCERTAIN is re-written to UNCERTAIN for
+ // re-solicitation. A plain clear would leave a stranded real epoch.
+ CoordinatorRuntime runtime = mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin = mock(StreamsGroupTopologyDescriptionPlugin.class);
+ when(plugin.deleteTopology("g")).thenReturn(CompletableFuture.completedFuture(null));
+ Set eligible = Set.of("g");
+ when(runtime.scheduleReadAllOperation(eq("list-streams-groups-needing-topology-cleanup"), any()))
+ .thenReturn(List.of(CompletableFuture.completedFuture(eligible)));
+ when(runtime.scheduleWriteOperation(eq("mark-topology-uncertain-batch"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(Set.of("g")));
+ when(runtime.scheduleWriteOperation(eq("finalize-stored-topology-epoch-after-delete-batch"), any(), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
+ service.runOneStreamsTopologyCleanupCycle();
+
+ verify(runtime).scheduleWriteOperation(eq("finalize-stored-topology-epoch-after-delete-batch"), any(), any());
+ }
+
+ @Test
+ public void testUncertainStoredEpochSolicitsOnHeartbeat() throws Exception {
+ // Regression (loss direction): a group whose storedEpoch is UNCERTAIN (-2) — left by a
+ // delete that wrote -2 but whose finalize write then failed — must still solicit a push on
+ // the next heartbeat. UNCERTAIN != currentEpoch, so the back-off gate does not suppress
+ // solicitation and TopologyDescriptionRequired is set.
+ CoordinatorRuntime runtime = mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin = mock(StreamsGroupTopologyDescriptionPlugin.class);
+ GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
+
+ assertTrue(heartbeatTopologyDescriptionRequired(runtime, service, 3, StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN, -1),
+ "storedEpoch == UNCERTAIN must still solicit a push on heartbeat");
+ }
+
+ @Test
+ public void testDescribeAtUncertainEpochYieldsNotStored() throws Exception {
+ // Regression (loss direction): a group at storedEpoch == UNCERTAIN (-2) must be described
+ // as NOT_STORED because the broker cannot confirm the plugin holds a valid copy. The
+ // describe gate uses `storedEpoch <= NONE` (i.e. <= -1), which also covers -2.
+ CoordinatorRuntime runtime = mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin = mock(StreamsGroupTopologyDescriptionPlugin.class);
+
+ StreamsGroupDescribeResponseData.DescribedGroup describedGroup = describedGroupWithTopology("g", 3);
+ // The storedEpoch map carries UNCERTAIN (-2): the plugin has not confirmed it holds epoch 3.
+ when(runtime.scheduleReadOperation(eq("streams-group-describe"), eq(GROUP_TP), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ new StreamsGroupDescribeResult(List.of(describedGroup),
+ Map.of("g", StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN))));
+
+ GroupCoordinatorService service = buildService(runtime, Optional.of(plugin), true);
+ List result = service.streamsGroupDescribe(
+ requestContext(ApiKeys.STREAMS_GROUP_DESCRIBE), List.of("g"), true
+ ).get(5, TimeUnit.SECONDS);
+
+ assertEquals(StreamsGroupDescribeResponse.TOPOLOGY_DESCRIPTION_STATUS_NOT_STORED,
+ result.get(0).topologyDescriptionStatus());
+ assertNull(result.get(0).topologyDescription());
+ verify(plugin, never()).getTopology(anyString(), anyInt());
+ }
}
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 80340c376f2da..79c4143e077d0 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
@@ -115,7 +115,7 @@
import java.util.ArrayList;
import java.util.Arrays;
-import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -1580,7 +1580,7 @@ public void testListStreamsGroupsNeedingTopologyCleanupFiltersByAllPredicates()
when(groupMetadataManager.maybeGroup("unexpired-offsets", committedOffset)).thenReturn(unexpired);
when(offsetMetadataManager.groupHasNoOffsets(eq("unexpired-offsets"), anyLong())).thenReturn(false);
- // eligible: passes every predicate; storedEpoch=7 must appear in the result map.
+ // eligible: passes every predicate; only its group id must appear in the result set.
StreamsGroup eligible = mock(StreamsGroup.class);
when(eligible.type()).thenReturn(Group.GroupType.STREAMS);
when(eligible.isEmpty(committedOffset)).thenReturn(true);
@@ -1588,9 +1588,35 @@ public void testListStreamsGroupsNeedingTopologyCleanupFiltersByAllPredicates()
when(groupMetadataManager.maybeGroup("eligible", committedOffset)).thenReturn(eligible);
when(offsetMetadataManager.groupHasNoOffsets(eq("eligible"), anyLong())).thenReturn(true);
- Map result = coordinator.listStreamsGroupsNeedingTopologyCleanup(committedOffset);
+ Set result = coordinator.listStreamsGroupsNeedingTopologyCleanup(committedOffset);
- assertEquals(Map.of("eligible", 7), result);
+ assertEquals(Set.of("eligible"), result);
+ }
+
+ @Test
+ public void testListStreamsGroupsNeedingTopologyCleanupIncludesUncertainEpoch() {
+ GroupMetadataManager groupMetadataManager = mock(GroupMetadataManager.class);
+ OffsetMetadataManager offsetMetadataManager = mock(OffsetMetadataManager.class);
+ GroupCoordinatorConfig config = mock(GroupCoordinatorConfig.class);
+ when(config.offsetsRetentionCheckIntervalMs()).thenReturn(60 * 60 * 1000L);
+ GroupCoordinatorShard coordinator = new GroupCoordinatorShard(
+ new LogContext(), groupMetadataManager, offsetMetadataManager, new MockTime(),
+ new MockCoordinatorTimer<>(new MockTime()), config,
+ mock(CoordinatorMetrics.class), mock(CoordinatorMetricsShard.class));
+
+ long committedOffset = 1000L;
+ when(groupMetadataManager.groupIds(committedOffset)).thenReturn(Set.of("uncertain"));
+ StreamsGroup uncertain = mock(StreamsGroup.class);
+ when(uncertain.type()).thenReturn(Group.GroupType.STREAMS);
+ when(uncertain.isEmpty(committedOffset)).thenReturn(true);
+ when(uncertain.storedDescriptionTopologyEpoch(committedOffset))
+ .thenReturn(StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN);
+ when(groupMetadataManager.maybeGroup("uncertain", committedOffset)).thenReturn(uncertain);
+ when(offsetMetadataManager.groupHasNoOffsets(eq("uncertain"), anyLong())).thenReturn(true);
+
+ Set result = coordinator.listStreamsGroupsNeedingTopologyCleanup(committedOffset);
+
+ assertEquals(Set.of("uncertain"), result);
}
@Test
@@ -1630,17 +1656,14 @@ public void testListStreamsGroupsNeedingTopologyCleanupSwallowsPerGroupError() {
when(groupMetadataManager.maybeGroup("good", committedOffset)).thenReturn(good);
when(offsetMetadataManager.groupHasNoOffsets(eq("good"), anyLong())).thenReturn(true);
- Map result = coordinator.listStreamsGroupsNeedingTopologyCleanup(committedOffset);
+ Set result = coordinator.listStreamsGroupsNeedingTopologyCleanup(committedOffset);
- assertEquals(Map.of("good", 3), result);
+ assertEquals(Set.of("good"), 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.
+ public void testMarkUncertainEmitsRecordForStreamsGroupAndReturnsTrue() {
+ // The shard wrapper must delegate to the GMM and return its CoordinatorResult verbatim.
GroupMetadataManager groupMetadataManager = mock(GroupMetadataManager.class);
OffsetMetadataManager offsetMetadataManager = mock(OffsetMetadataManager.class);
GroupCoordinatorConfig config = mock(GroupCoordinatorConfig.class);
@@ -1657,20 +1680,22 @@ public void testClearStoredDescriptionTopologyEpochDelegatesToGroupMetadataManag
mock(CoordinatorMetrics.class),
mock(CoordinatorMetricsShard.class)
);
+ CoordinatorResult gmmResult =
+ new CoordinatorResult<>(List.of(mock(CoordinatorRecord.class)), Boolean.TRUE);
+ when(groupMetadataManager.markStoredDescriptionTopologyEpochUncertain("g", true)).thenReturn(gmmResult);
- CoordinatorResult expected = new CoordinatorResult<>(List.of());
- when(groupMetadataManager.clearStoredDescriptionTopologyEpoch("group-id", 7)).thenReturn(expected);
+ CoordinatorResult r =
+ coordinator.markStoredDescriptionTopologyEpochUncertain("g", true);
- assertSame(expected, coordinator.clearStoredDescriptionTopologyEpoch("group-id", 7));
- verify(groupMetadataManager).clearStoredDescriptionTopologyEpoch("group-id", 7);
+ assertSame(gmmResult, r);
+ verify(groupMetadataManager).markStoredDescriptionTopologyEpochUncertain("g", true);
}
@Test
- public void testClearStoredDescriptionTopologyEpochBatchConcatenatesGMMRecords() {
- // The batch shard method must invoke the GMM's single-group clear once per entry and
- // fold the resulting records into one CoordinatorResult. The cycle relies on this so a
- // partition with N eligible groups produces one scheduleWriteOperation carrying N
- // conditional clear records, not N separate writes.
+ public void testMarkUncertainBatchReturnsOnlyEligibleSubset() {
+ // The batch mark shard wrapper is a pure delegation: it returns the GMM's CoordinatorResult
+ // verbatim (the eligible subset and its records), which the cycle reads to decide which
+ // groups to delete.
GroupMetadataManager groupMetadataManager = mock(GroupMetadataManager.class);
OffsetMetadataManager offsetMetadataManager = mock(OffsetMetadataManager.class);
GroupCoordinatorConfig config = mock(GroupCoordinatorConfig.class);
@@ -1687,32 +1712,17 @@ public void testClearStoredDescriptionTopologyEpochBatchConcatenatesGMMRecords()
mock(CoordinatorMetrics.class),
mock(CoordinatorMetricsShard.class)
);
+ LinkedHashSet groupIds = new LinkedHashSet<>(List.of("a", "b"));
+ CoordinatorResult, CoordinatorRecord> gmmResult =
+ new CoordinatorResult<>(List.of(mock(CoordinatorRecord.class)), Set.of("a"));
+ when(groupMetadataManager.markStoredDescriptionTopologyEpochUncertainBatch(groupIds))
+ .thenReturn(gmmResult);
+
+ CoordinatorResult, CoordinatorRecord> r =
+ coordinator.markStoredDescriptionTopologyEpochUncertainBatch(groupIds);
- CoordinatorRecord recordA = mock(CoordinatorRecord.class);
- CoordinatorRecord recordB = mock(CoordinatorRecord.class);
- // "matched" group: GMM returns one record. "mismatched" group: GMM returns empty, so
- // the batched result still contains only the matched group's record.
- when(groupMetadataManager.clearStoredDescriptionTopologyEpoch("matched", 3))
- .thenReturn(new CoordinatorResult<>(List.of(recordA)));
- when(groupMetadataManager.clearStoredDescriptionTopologyEpoch("mismatched", 5))
- .thenReturn(new CoordinatorResult<>(List.of()));
- when(groupMetadataManager.clearStoredDescriptionTopologyEpoch("matched-2", 4))
- .thenReturn(new CoordinatorResult<>(List.of(recordB)));
-
- Map input = new LinkedHashMap<>();
- input.put("matched", 3);
- input.put("mismatched", 5);
- input.put("matched-2", 4);
-
- CoordinatorResult result = coordinator.clearStoredDescriptionTopologyEpochBatch(input);
-
- assertEquals(List.of(recordA, recordB), result.records());
- // Non-atomic: the per-group conditional clears are independent, so the runtime is free
- // to split them across multiple log batches if a single batch would exceed the limit.
- assertFalse(result.isAtomic());
- verify(groupMetadataManager).clearStoredDescriptionTopologyEpoch("matched", 3);
- verify(groupMetadataManager).clearStoredDescriptionTopologyEpoch("mismatched", 5);
- verify(groupMetadataManager).clearStoredDescriptionTopologyEpoch("matched-2", 4);
+ assertSame(gmmResult, r);
+ verify(groupMetadataManager).markStoredDescriptionTopologyEpochUncertainBatch(groupIds);
}
@Test
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 cb9f7bd5fc25a..04841537e421d 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
@@ -11167,52 +11167,131 @@ public void testStreamsGroupDescribeBeforeAndAfterCommittingOffset() {
}
@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";
+ public void testFinalizeAfterDeleteClearsUncertainToNone() {
+ // The plugin.deleteTopology completed and stored is still UNCERTAIN: no push raced, the
+ // plugin is empty, so the smart finalize clears stored to NONE while preserving the
+ // other tagged fields (FailedDescriptionTopologyEpoch in particular).
+ String groupId = "s";
GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();
context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
- groupId, 1, 0L, -1, Map.of(), 7, 3));
+ groupId, 3, 0L, -1, Map.of(), StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN, -1));
- CoordinatorResult result =
- context.groupMetadataManager.clearStoredDescriptionTopologyEpoch(groupId, 7);
+ CoordinatorResult r =
+ context.groupMetadataManager.finalizeStoredDescriptionTopologyEpochAfterDelete(groupId);
- 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());
+ StreamsGroupMetadataValue v = (StreamsGroupMetadataValue) r.records().get(0).value().message();
+ assertEquals(StreamsGroup.STORED_TOPOLOGY_EPOCH_NONE, v.storedDescriptionTopologyEpoch());
}
@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";
+ public void testFinalizeAfterDeleteForcesUncertainWhenEpochRacedPush() {
+ // A push advanced stored to 9 while our plugin.deleteTopology was in flight. The plugin op
+ // order is unknown, so 9 may be orphaned over an empty plugin -> force UNCERTAIN to re-solicit.
+ String groupId = "s";
GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();
context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
- groupId, 1, 0L, -1, Map.of(), 9, -1));
+ groupId, 3, 0L, -1, Map.of(), 9, -1));
- CoordinatorResult result =
- context.groupMetadataManager.clearStoredDescriptionTopologyEpoch(groupId, 7);
+ CoordinatorResult r =
+ context.groupMetadataManager.finalizeStoredDescriptionTopologyEpochAfterDelete(groupId);
- assertEquals(List.of(), result.records());
+ StreamsGroupMetadataValue v = (StreamsGroupMetadataValue) r.records().get(0).value().message();
+ assertEquals(StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN, v.storedDescriptionTopologyEpoch());
}
@Test
- public void testClearStoredDescriptionTopologyEpochNoOpsForMissingGroup() {
- // Missing groups must not throw — the next cycle will simply not see them again.
+ public void testFinalizeAfterDeleteNoOpWhenAlreadyNone() {
+ // Stored already NONE (e.g. a concurrent path cleared it): the smart finalize is a no-op.
+ String groupId = "s";
GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();
+ context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
+ groupId, 3, 0L, -1, Map.of(), StreamsGroup.STORED_TOPOLOGY_EPOCH_NONE, -1));
- CoordinatorResult result =
- context.groupMetadataManager.clearStoredDescriptionTopologyEpoch("missing-group", 7);
+ CoordinatorResult r =
+ context.groupMetadataManager.finalizeStoredDescriptionTopologyEpochAfterDelete(groupId);
- assertEquals(List.of(), result.records());
+ assertEquals(List.of(), r.records());
+ }
+
+ @Test
+ public void testFinalizeAfterDeleteNoOpForMissingGroup() {
+ // No such group: the smart finalize returns no records. The join-conversion path relies on
+ // this — after a group is tombstoned, its finalize must not resurrect a metadata record.
+ GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();
+
+ CoordinatorResult r =
+ context.groupMetadataManager.finalizeStoredDescriptionTopologyEpochAfterDelete("missing");
+
+ assertEquals(List.of(), r.records());
+ }
+
+ @Test
+ public void testFinalizeAfterDeleteNoOpForNonStreamsGroup() {
+ // The group was converted to classic before the finalize ran: it is no longer a streams
+ // group, so the finalize is a no-op. This is exactly the "no-op once the group has been
+ // converted" behavior the classic-join conversion path depends on.
+ String groupId = "classic-group";
+ GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();
+ context.createClassicGroup(groupId);
+
+ CoordinatorResult r =
+ context.groupMetadataManager.finalizeStoredDescriptionTopologyEpochAfterDelete(groupId);
+
+ assertEquals(List.of(), r.records());
+ }
+
+ @Test
+ public void testSetStoredEpochWritesPushedEpochWhenBarrierPreserved() {
+ // Mainline success path: the push committed the UNCERTAIN barrier before its plugin op, the
+ // plugin succeeded, and no delete raced. setStoredDescriptionTopologyEpoch must advance
+ // stored from UNCERTAIN to the pushed epoch via the max branch.
+ String groupId = "s";
+ GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();
+ context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
+ groupId, 3, 0L, -1, Map.of(), StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN, -1));
+
+ CoordinatorResult r =
+ context.groupMetadataManager.setStoredDescriptionTopologyEpoch(groupId, 9);
+
+ StreamsGroupMetadataValue v = (StreamsGroupMetadataValue) r.records().get(0).value().message();
+ assertEquals(9, v.storedDescriptionTopologyEpoch());
+ assertEquals(-1, v.failedDescriptionTopologyEpoch());
+ }
+
+ @Test
+ public void testSetFailedEpochPreservesUncertainBarrier() {
+ // Permanent-failure arm with the barrier preserved: setFailedDescriptionTopologyEpoch must
+ // advance the failed epoch to the pushed epoch and leave stored at UNCERTAIN (delete still
+ // eligible, re-soliciting), not regress it to a real epoch.
+ String groupId = "s";
+ GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();
+ context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
+ groupId, 3, 0L, -1, Map.of(), StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN, -1));
+
+ CoordinatorResult r =
+ context.groupMetadataManager.setFailedDescriptionTopologyEpoch(groupId, 9);
+
+ StreamsGroupMetadataValue v = (StreamsGroupMetadataValue) r.records().get(0).value().message();
+ assertEquals(StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN, v.storedDescriptionTopologyEpoch());
+ assertEquals(9, v.failedDescriptionTopologyEpoch());
+ }
+
+ @Test
+ public void testSetStoredEpochReArmsUncertainWhenBarrierWasCleared() {
+ // The push marked UNCERTAIN before its plugin op, but by the time its epoch write runs
+ // the stored epoch is NONE: a racing delete's finalize cleared the barrier, meaning
+ // plugin.deleteTopology may have wiped the topology this push just stored. The write must
+ // re-arm UNCERTAIN (re-soliciting a push) instead of recording the pushed epoch.
+ String groupId = "s";
+ GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();
+ context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
+ groupId, 3, 0L, -1, Map.of(), StreamsGroup.STORED_TOPOLOGY_EPOCH_NONE, -1));
+
+ CoordinatorResult r =
+ context.groupMetadataManager.setStoredDescriptionTopologyEpoch(groupId, 9);
+
+ StreamsGroupMetadataValue v = (StreamsGroupMetadataValue) r.records().get(0).value().message();
+ assertEquals(StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN, v.storedDescriptionTopologyEpoch());
}
@Test
@@ -11237,6 +11316,172 @@ public void testStreamsGroupMetadataReplayRoundTripsTopologyDescriptionEpochs()
assertEquals(-1, group.failedDescriptionTopologyEpoch());
}
+ @Test
+ public void testMarkUncertainSkipsNonStreamsGroup() {
+ // A missing or non-streams group must return false without emitting any record —
+ // there is nothing in the plugin for it.
+ GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();
+
+ CoordinatorResult r =
+ context.groupMetadataManager.markStoredDescriptionTopologyEpochUncertain("c", false);
+
+ assertEquals(List.of(), r.records());
+ assertFalse(r.response());
+ }
+
+ @Test
+ public void testMarkUncertainSkipsNoneWhenMarkWhenNoneFalse() {
+ // A streams group with stored == NONE and markWhenNone=false must return false without
+ // emitting a record — a delete caller uses this to skip the plugin op when the plugin
+ // entry is definitely absent.
+ String groupId = "streams-group";
+ GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();
+ context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
+ groupId, 1, 0L, -1, Map.of(), StreamsGroup.STORED_TOPOLOGY_EPOCH_NONE, -1));
+
+ CoordinatorResult r =
+ context.groupMetadataManager.markStoredDescriptionTopologyEpochUncertain(groupId, false);
+
+ assertEquals(List.of(), r.records());
+ assertFalse(r.response());
+ }
+
+ @Test
+ public void testMarkUncertainMarksNoneWhenMarkWhenNoneTrue() {
+ // A push caller passes markWhenNone=true so the UNCERTAIN barrier is written even when
+ // stored == NONE: the push is about to put data in the plugin, so we need the barrier.
+ String groupId = "streams-group";
+ GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();
+ context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
+ groupId, 3, 0L, -1, Map.of(), StreamsGroup.STORED_TOPOLOGY_EPOCH_NONE, -1));
+
+ CoordinatorResult r =
+ context.groupMetadataManager.markStoredDescriptionTopologyEpochUncertain(groupId, true);
+
+ assertEquals(1, r.records().size());
+ assertTrue(r.response());
+ StreamsGroupMetadataValue v = (StreamsGroupMetadataValue) r.records().get(0).value().message();
+ assertEquals(StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN, v.storedDescriptionTopologyEpoch());
+ }
+
+ @Test
+ public void testMarkUncertainIdempotentWhenAlreadyUncertain() {
+ // If the group is already UNCERTAIN, no additional record is emitted, but the caller
+ // still gets true — the plugin op is still required.
+ String groupId = "streams-group";
+ GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();
+ context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
+ groupId, 1, 0L, -1, Map.of(), StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN, -1));
+
+ CoordinatorResult r =
+ context.groupMetadataManager.markStoredDescriptionTopologyEpochUncertain(groupId, false);
+
+ assertEquals(List.of(), r.records());
+ assertTrue(r.response());
+ }
+
+ @Test
+ public void testMarkUncertainMarksStoredEpochAndReturnsTrue() {
+ // When stored == 5 (a real pushed epoch), the method must emit one record setting
+ // stored to UNCERTAIN (-2) and return true.
+ String groupId = "streams-group";
+ GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();
+ context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
+ groupId, 3, 0L, -1, Map.of(), 5, -1));
+
+ CoordinatorResult r =
+ context.groupMetadataManager.markStoredDescriptionTopologyEpochUncertain(groupId, false);
+
+ assertTrue(r.response());
+ assertEquals(1, r.records().size());
+ StreamsGroupMetadataValue v = (StreamsGroupMetadataValue) r.records().get(0).value().message();
+ assertEquals(StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN, v.storedDescriptionTopologyEpoch());
+ }
+
+ @Test
+ public void testMarkUncertainSkipsRevivedGroupForDeleteCallers() {
+ // markWhenNone=false is the delete callers' mode and they only ever target empty groups.
+ // A group revived (a member joined) between the caller's committed read and this write
+ // must return false without a record so its plugin data is not deleted underneath the
+ // active member.
+ String groupId = "streams-group";
+ GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();
+ context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMemberRecord(
+ groupId, streamsGroupMemberBuilderWithDefaults("member-1").build()));
+ context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
+ groupId, 3, 0L, -1, Map.of(), 5, -1));
+
+ CoordinatorResult r =
+ context.groupMetadataManager.markStoredDescriptionTopologyEpochUncertain(groupId, false);
+
+ assertEquals(List.of(), r.records());
+ assertFalse(r.response());
+ }
+
+ @Test
+ public void testMarkUncertainMarksNonEmptyGroupForPushCallers() {
+ // markWhenNone=true is the push path, which operates on live (non-empty) groups: the
+ // emptiness re-check must not apply there — the barrier is still required before the
+ // plugin setTopology.
+ String groupId = "streams-group";
+ GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();
+ context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMemberRecord(
+ groupId, streamsGroupMemberBuilderWithDefaults("member-1").build()));
+ context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
+ groupId, 3, 0L, -1, Map.of(), 5, -1));
+
+ CoordinatorResult r =
+ context.groupMetadataManager.markStoredDescriptionTopologyEpochUncertain(groupId, true);
+
+ assertTrue(r.response());
+ assertEquals(1, r.records().size());
+ StreamsGroupMetadataValue v = (StreamsGroupMetadataValue) r.records().get(0).value().message();
+ assertEquals(StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN, v.storedDescriptionTopologyEpoch());
+ }
+
+ @Test
+ public void testMarkUncertainBatchReturnsOnlyEligibleSubset() {
+ // The batch mark folds the per-group mark with markWhenNone=false and returns only the
+ // subset that was actually marked UNCERTAIN. A streams group at a real epoch is marked and
+ // contributes a record; a group at NONE (nothing in the plugin) and a missing/non-streams
+ // group both drop out so the cycle does not delete them.
+ GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();
+ context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
+ "a", 3, 0L, -1, Map.of(), 5, -1));
+ context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
+ "b", 1, 0L, -1, Map.of(), StreamsGroup.STORED_TOPOLOGY_EPOCH_NONE, -1));
+
+ CoordinatorResult, CoordinatorRecord> r =
+ context.groupMetadataManager.markStoredDescriptionTopologyEpochUncertainBatch(
+ new LinkedHashSet<>(List.of("a", "b", "missing")));
+
+ assertEquals(Set.of("a"), r.response());
+ assertEquals(1, r.records().size());
+ StreamsGroupMetadataValue v = (StreamsGroupMetadataValue) r.records().get(0).value().message();
+ assertEquals(StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN, v.storedDescriptionTopologyEpoch());
+ // The per-group records are independent, so a large shard's batch must be splittable
+ // across log batches rather than failing on the atomic batch-size limit.
+ assertFalse(r.isAtomic());
+ }
+
+ @Test
+ public void testFinalizeAfterDeleteBatchIsNonAtomic() {
+ // Same independence argument as the mark batch: the smart-finalize records for different
+ // groups may be split across log batches on a shard with many cleaned-up groups.
+ GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder().build();
+ context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
+ "a", 3, 0L, -1, Map.of(), StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN, -1));
+ context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
+ "b", 1, 0L, -1, Map.of(), StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN, -1));
+
+ CoordinatorResult r =
+ context.groupMetadataManager.finalizeStoredDescriptionTopologyEpochAfterDeleteBatch(
+ new LinkedHashSet<>(List.of("a", "b")));
+
+ assertEquals(2, r.records().size());
+ assertFalse(r.isAtomic());
+ }
+
@Test
public void testStreamsGroupDescribeSurfacesStoredDescriptionTopologyEpoch() {
// KIP-1331: describe bundles per-group storedDescriptionTopologyEpoch so the service layer can decide
@@ -22434,6 +22679,118 @@ public void testClassicGroupJoinWithNonEmptyStreamsGroup() throws Exception {
assertEquals(Errors.INCONSISTENT_GROUP_PROTOCOL.code(), joinResult.joinFuture.get().errorCode());
}
+ @Test
+ public void testClassicGroupJoinDetectsEmptyStreamsGroupWithStoredTopology() {
+ // classicGroupJoin must signal topology cleanup when an empty streams group has a stored
+ // description topology epoch set (i.e. the plugin may still hold topology data). The caller
+ // is expected to run the plugin cleanup before re-invoking with topologyCleanupHandled=true.
+ String groupId = "streams-group-id";
+ GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
+ .withStreamsGroup(new StreamsGroupBuilder(groupId, 10))
+ .build();
+ // Set storedDescriptionTopologyEpoch to UNCERTAIN (not NONE) so the predicate fires.
+ context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
+ groupId, 10, 0L, -1, Map.of(), StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN, -1));
+
+ JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
+ .withGroupId(groupId)
+ .withMemberId(UNKNOWN_MEMBER_ID)
+ .withDefaultProtocolTypeAndProtocols()
+ .build();
+
+ GroupMetadataManagerTestContext.JoinResult result =
+ context.sendClassicGroupJoin(request, false, false, false);
+
+ // Detection fired: needsTopologyCleanup is true, no coordinator records emitted, and
+ // responseFuture is left untouched (the join is not completed — cleanup must happen first).
+ assertTrue(result.needsTopologyCleanup);
+ assertEquals(List.of(), result.records);
+ assertFalse(result.joinFuture.isDone());
+ }
+
+ @Test
+ public void testClassicGroupJoinDoesNotDetectWhenTopologyCleanupHandled() {
+ // When topologyCleanupHandled=true the detection predicate is suppressed, so classicGroupJoin
+ // must proceed with the conversion even when storedDescriptionTopologyEpoch is set. This is
+ // the re-invocation path after the caller has already run the plugin cleanup.
+ String groupId = "streams-group-id";
+ GroupMetadataManagerTestContext context = new GroupMetadataManagerTestContext.Builder()
+ .withStreamsGroup(new StreamsGroupBuilder(groupId, 10))
+ .build();
+ context.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
+ groupId, 10, 0L, -1, Map.of(), StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN, -1));
+
+ JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
+ .withGroupId(groupId)
+ .withMemberId(UNKNOWN_MEMBER_ID)
+ .withDefaultProtocolTypeAndProtocols()
+ .build();
+
+ // topologyCleanupHandled=true suppresses detection; the join proceeds to conversion.
+ GroupMetadataManagerTestContext.JoinResult result =
+ context.sendClassicGroupJoin(request, false, false, true);
+
+ // No cleanup signal: needsTopologyCleanup is false and conversion records were emitted.
+ assertFalse(result.needsTopologyCleanup);
+ assertFalse(result.records.isEmpty());
+ }
+
+ @Test
+ public void testClassicGroupJoinDoesNotDetectForNonEligibleGroups() {
+ // The detection predicate must fire only for an empty streams group whose stored epoch is
+ // non-NONE. Under topologyCleanupHandled=false (the value production passes on every classic
+ // join with a plugin configured) each of the following must fall through to a normal join —
+ // needsTopologyCleanup=false — rather than signalling cleanup:
+ // (a) no such group,
+ // (b) a non-streams (classic) group,
+ // (c) a non-empty streams group,
+ // (d) an empty streams group whose stored epoch is NONE (nothing in the plugin).
+ String groupId = "group-id";
+
+ // (a) Missing group.
+ GroupMetadataManagerTestContext missing = new GroupMetadataManagerTestContext.Builder().build();
+ assertFalse(sendJoinForCleanupDetection(missing, groupId).needsTopologyCleanup);
+
+ // (b) Classic group.
+ GroupMetadataManagerTestContext classic = new GroupMetadataManagerTestContext.Builder().build();
+ classic.createClassicGroup(groupId);
+ assertFalse(sendJoinForCleanupDetection(classic, groupId).needsTopologyCleanup);
+
+ // (c) Non-empty streams group (a live member), even with a stored epoch set.
+ String memberId = Uuid.randomUuid().toString();
+ GroupMetadataManagerTestContext nonEmpty = new GroupMetadataManagerTestContext.Builder()
+ .withStreamsGroup(new StreamsGroupBuilder(groupId, 10)
+ .withMember(StreamsGroupMember.Builder.withDefaults(memberId)
+ .setState(org.apache.kafka.coordinator.group.streams.MemberState.STABLE)
+ .setMemberEpoch(10)
+ .setPreviousMemberEpoch(10)
+ .build()))
+ .build();
+ nonEmpty.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
+ groupId, 10, 0L, -1, Map.of(), StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN, -1));
+ assertFalse(sendJoinForCleanupDetection(nonEmpty, groupId).needsTopologyCleanup);
+
+ // (d) Empty streams group at stored == NONE (nothing in the plugin to clean up).
+ GroupMetadataManagerTestContext storedNone = new GroupMetadataManagerTestContext.Builder()
+ .withStreamsGroup(new StreamsGroupBuilder(groupId, 10))
+ .build();
+ storedNone.replay(StreamsCoordinatorRecordHelpers.newStreamsGroupMetadataRecord(
+ groupId, 10, 0L, -1, Map.of(), StreamsGroup.STORED_TOPOLOGY_EPOCH_NONE, -1));
+ assertFalse(sendJoinForCleanupDetection(storedNone, groupId).needsTopologyCleanup);
+ }
+
+ private static GroupMetadataManagerTestContext.JoinResult sendJoinForCleanupDetection(
+ GroupMetadataManagerTestContext context,
+ String groupId
+ ) {
+ JoinGroupRequestData request = new GroupMetadataManagerTestContext.JoinGroupRequestBuilder()
+ .withGroupId(groupId)
+ .withMemberId(UNKNOWN_MEMBER_ID)
+ .withDefaultProtocolTypeAndProtocols()
+ .build();
+ return context.sendClassicGroupJoin(request, false, false, false);
+ }
+
@Test
public void testConsumerGroupDynamicConfigs() {
String groupId = "fooup";
diff --git a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupMetadataManagerTestContext.java b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupMetadataManagerTestContext.java
index 6c81bcb03ec25..0b8ea094c2da8 100644
--- a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupMetadataManagerTestContext.java
+++ b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupMetadataManagerTestContext.java
@@ -300,14 +300,17 @@ public static class JoinResult {
CompletableFuture joinFuture;
List records;
CompletableFuture appendFuture;
+ // Whether classicGroupJoin signalled that streams-topology cleanup is needed before conversion.
+ boolean needsTopologyCleanup;
public JoinResult(
CompletableFuture joinFuture,
- CoordinatorResult coordinatorResult
+ CoordinatorResult coordinatorResult
) {
this.joinFuture = joinFuture;
this.records = coordinatorResult.records();
this.appendFuture = coordinatorResult.appendFuture();
+ this.needsTopologyCleanup = coordinatorResult.response();
}
}
@@ -912,6 +915,15 @@ public JoinResult sendClassicGroupJoin(
JoinGroupRequestData request,
boolean requireKnownMemberId,
boolean supportSkippingAssignment
+ ) {
+ return sendClassicGroupJoin(request, requireKnownMemberId, supportSkippingAssignment, true);
+ }
+
+ public JoinResult sendClassicGroupJoin(
+ JoinGroupRequestData request,
+ boolean requireKnownMemberId,
+ boolean supportSkippingAssignment,
+ boolean topologyCleanupHandled
) {
// requireKnownMemberId is true: version >= 4 (See JoinGroupRequest#requiresKnownMemberId())
// supportSkippingAssignment is true: version >= 9 (See JoinGroupRequest#supportsSkippingAssignment())
@@ -941,10 +953,11 @@ public JoinResult sendClassicGroupJoin(
);
CompletableFuture responseFuture = new CompletableFuture<>();
- CoordinatorResult coordinatorResult = groupMetadataManager.classicGroupJoin(
+ CoordinatorResult coordinatorResult = groupMetadataManager.classicGroupJoin(
context,
request,
- responseFuture
+ responseFuture,
+ topologyCleanupHandled
);
if (coordinatorResult.replayRecords()) {
diff --git a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTest.java b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTest.java
index a75c108314998..8d79f8e1dedcf 100644
--- a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTest.java
+++ b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTest.java
@@ -1432,6 +1432,18 @@ public void testShouldExpireFalseWhenPluginConfiguredAndEpochStored() {
assertFalse(streamsGroup.shouldExpire(config));
}
+ @Test
+ public void testShouldExpireFalseWhenStoredEpochUncertain() {
+ // UNCERTAIN (-2) also defers the tombstone: the plugin may still hold data for this
+ // group, so it must stay reclaimable by the cleanup cycle until the epoch is cleared.
+ StreamsGroup streamsGroup = createStreamsGroup("group-id");
+ streamsGroup.setStoredDescriptionTopologyEpoch(StreamsGroup.STORED_TOPOLOGY_EPOCH_UNCERTAIN);
+ GroupCoordinatorConfig config = mock(GroupCoordinatorConfig.class);
+ when(config.isStreamsGroupTopologyDescriptionPluginConfigured()).thenReturn(true);
+
+ assertFalse(streamsGroup.shouldExpire(config));
+ }
+
@Test
public void testShouldExpireTrueWhenStoredEpochIsDefault() {
// storedDescriptionTopologyEpoch == -1: no plugin row exists for this group (either the