This is an automated email from the ASF dual-hosted git repository.
AndrewJSchofield pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/kafka.git
The following commit(s) were added to refs/heads/trunk by this push:
new 9dc859e957e KAFKA-20710: Share coordinator - Fence stale periodic
jobs (#22603)
9dc859e957e is described below
commit 9dc859e957e0760bd290b35645d37a165940548e
Author: Shekhar Prasad Rajak <[email protected]>
AuthorDate: Tue Jun 30 14:00:25 2026 +0530
KAFKA-20710: Share coordinator - Fence stale periodic jobs (#22603)
Ref https://issues.apache.org/jira/browse/KAFKA-20710
`ShareCoordinatorService` with a periodic-job generation guard so stale
timer tasks and stale async completions cannot reschedule after
disable/re-enable.
Reviewers: Andrew Schofield <[email protected]>, Sushant Mahajan
<[email protected]>
---
.../coordinator/share/ShareCoordinatorService.java | 53 ++++++----
.../share/ShareCoordinatorServiceTest.java | 109 +++++++++++++++++++--
2 files changed, 134 insertions(+), 28 deletions(-)
diff --git
a/share-coordinator/src/main/java/org/apache/kafka/coordinator/share/ShareCoordinatorService.java
b/share-coordinator/src/main/java/org/apache/kafka/coordinator/share/ShareCoordinatorService.java
index 702d27b857e..33c30af8c42 100644
---
a/share-coordinator/src/main/java/org/apache/kafka/coordinator/share/ShareCoordinatorService.java
+++
b/share-coordinator/src/main/java/org/apache/kafka/coordinator/share/ShareCoordinatorService.java
@@ -76,6 +76,7 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
import java.util.function.IntSupplier;
import static
org.apache.kafka.coordinator.common.runtime.CoordinatorOperationExceptionHelper.handleOperationException;
@@ -139,7 +140,9 @@ public class ShareCoordinatorService implements
ShareCoordinator {
/**
* Config based sentinel used to start or eventually stop defined periodic
job tasks.
*/
- private volatile boolean shouldRunPeriodicJob;
+ private volatile boolean periodicJobsEnabled;
+
+ private final AtomicLong periodicJobGeneration = new AtomicLong();
public static class Builder {
private final int nodeId;
@@ -319,9 +322,9 @@ public class ShareCoordinatorService implements
ShareCoordinator {
log.info("Startup complete.");
}
- private void setupPeriodicJobs() {
- setupRecordPruning();
- setupSnapshotColdPartitions();
+ private void setupPeriodicJobs(long generation) {
+ setupRecordPruning(generation);
+ setupSnapshotColdPartitions(generation);
}
/**
@@ -331,11 +334,15 @@ public class ShareCoordinatorService implements
ShareCoordinator {
*/
// Visibility for tests
void setupRecordPruning() {
+ setupRecordPruning(periodicJobGeneration.get());
+ }
+
+ private void setupRecordPruning(long generation) {
log.debug("Scheduling share-group state topic prune job.");
timer.add(new TimerTask(config.shareCoordinatorTopicPruneIntervalMs())
{
@Override
public void run() {
- if (!shouldRunPeriodicJob) {
+ if (!shouldRunPeriodicJobForGeneration(generation)) {
return;
}
List<CompletableFuture<Void>> futures = new ArrayList<>();
@@ -346,8 +353,11 @@ public class ShareCoordinatorService implements
ShareCoordinator {
if (exp != null) {
log.error("Received error in share-group state
topic prune.", exp);
}
+ if (!shouldRunPeriodicJobForGeneration(generation)) {
+ return;
+ }
// Perpetual recursion, failure or not.
- setupRecordPruning();
+ setupRecordPruning(generation);
});
}
});
@@ -415,11 +425,15 @@ public class ShareCoordinatorService implements
ShareCoordinator {
*/
// Visibility for tests
void setupSnapshotColdPartitions() {
+ setupSnapshotColdPartitions(periodicJobGeneration.get());
+ }
+
+ private void setupSnapshotColdPartitions(long generation) {
log.debug("Scheduling cold share-partition snapshotting.");
timer.add(new
TimerTask(config.shareCoordinatorColdPartitionSnapshotIntervalMs()) {
@Override
public void run() {
- if (!shouldRunPeriodicJob) {
+ if (!shouldRunPeriodicJobForGeneration(generation)) {
return;
}
List<CompletableFuture<Void>> futures =
runtime.scheduleWriteAllOperation(
@@ -432,7 +446,10 @@ public class ShareCoordinatorService implements
ShareCoordinator {
if (exp != null) {
log.error("Received error while snapshotting cold
partitions.", exp);
}
- setupSnapshotColdPartitions();
+ if (!shouldRunPeriodicJobForGeneration(generation)) {
+ return;
+ }
+ setupSnapshotColdPartitions(generation);
});
}
});
@@ -1130,15 +1147,14 @@ public class ShareCoordinatorService implements
ShareCoordinator {
}
boolean enabled = isShareGroupsEnabled(newImage);
- // enabled shouldRunJob result (XOR)
- // 0 0 no op on flag, do not call jobs
- // 0 1 disable flag, do not call jobs
=> action
- // 1 0 enable flag, call jobs as they are not
recursing => action
- // 1 1 no op on flag, do not call jobs
- if (enabled ^ shouldRunPeriodicJob) {
- shouldRunPeriodicJob = enabled;
+ // Only act when the enabled state actually changes. Each change bumps
the
+ // generation, which fences any jobs from the previous generation so
they
+ // stop rescheduling. A fresh set of jobs is started only when
enabling.
+ if (enabled != periodicJobsEnabled) {
+ periodicJobsEnabled = enabled;
+ long generation = periodicJobGeneration.incrementAndGet();
if (enabled) {
- setupPeriodicJobs();
+ setupPeriodicJobs(generation);
}
}
}
@@ -1178,8 +1194,7 @@ public class ShareCoordinatorService implements
ShareCoordinator {
).supportsShareGroups();
}
- // Visibility for tests
- boolean shouldRunPeriodicJob() {
- return shouldRunPeriodicJob;
+ private boolean shouldRunPeriodicJobForGeneration(long generation) {
+ return periodicJobsEnabled && periodicJobGeneration.get() ==
generation;
}
}
diff --git
a/share-coordinator/src/test/java/org/apache/kafka/coordinator/share/ShareCoordinatorServiceTest.java
b/share-coordinator/src/test/java/org/apache/kafka/coordinator/share/ShareCoordinatorServiceTest.java
index 4479d76d515..a107cb4d7cd 100644
---
a/share-coordinator/src/test/java/org/apache/kafka/coordinator/share/ShareCoordinatorServiceTest.java
+++
b/share-coordinator/src/test/java/org/apache/kafka/coordinator/share/ShareCoordinatorServiceTest.java
@@ -71,7 +71,6 @@ import java.util.concurrent.TimeoutException;
import static
org.apache.kafka.coordinator.common.runtime.TestUtil.requestContext;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -2002,8 +2001,6 @@ class ShareCoordinatorServiceTest {
any()
)).thenReturn(List.of());
- assertFalse(service.shouldRunPeriodicJob());
-
service.startup(() -> 1);
MetadataImage mockedImage = mock(MetadataImage.class,
RETURNS_DEEP_STUBS);
@@ -2022,8 +2019,6 @@ class ShareCoordinatorServiceTest {
eq("snapshot-cold-partitions"),
any()
);
- assertFalse(service.shouldRunPeriodicJob());
-
// Enable feature.
Mockito.reset(mockedImage);
when(mockedImage.features().finalizedVersions().getOrDefault(eq(ShareVersion.FEATURE_NAME),
anyShort())).thenReturn((short) 1);
@@ -2040,8 +2035,6 @@ class ShareCoordinatorServiceTest {
eq("snapshot-cold-partitions"),
any()
);
- assertTrue(service.shouldRunPeriodicJob());
-
// Disable feature
Mockito.reset(mockedImage);
when(mockedImage.features().finalizedVersions().getOrDefault(eq(ShareVersion.FEATURE_NAME),
anyShort())).thenReturn((short) 0);
@@ -2058,14 +2051,112 @@ class ShareCoordinatorServiceTest {
eq("snapshot-cold-partitions"),
any()
);
- assertFalse(service.shouldRunPeriodicJob());
-
timer.advanceClock(30001L);
verify(timer, times(4)).add(any()); // No new additions.
service.shutdown();
}
+ @Test
+ public void
testPeriodicJobsDoNotDuplicateAfterDisableEnableWithInFlightJobs() throws
InterruptedException {
+ CoordinatorRuntime<ShareCoordinatorShard, CoordinatorRecord> runtime =
mockRuntime();
+ PartitionWriter writer = mock(PartitionWriter.class);
+ MockTime time = new MockTime();
+ MockTimer timer = spy(new MockTimer(time));
+
+ Metrics metrics = new Metrics();
+
+ ShareCoordinatorService service = spy(new ShareCoordinatorService(
+ new LogContext(),
+ ShareCoordinatorTestConfig.testConfig(),
+ runtime,
+ new ShareCoordinatorMetrics(metrics),
+ time,
+ timer,
+ writer
+ ));
+
+ CompletableFuture<Optional<Long>> firstPruneFuture = new
CompletableFuture<>();
+ CompletableFuture<Optional<Long>> secondPruneFuture = new
CompletableFuture<>();
+ CompletableFuture<Void> firstSnapshotFuture = new
CompletableFuture<>();
+ CompletableFuture<Void> secondSnapshotFuture = new
CompletableFuture<>();
+
+ when(runtime.<Optional<Long>>scheduleWriteOperation(
+ eq("write-state-record-prune"),
+ any(),
+ any()
+ ))
+ .thenReturn(firstPruneFuture)
+ .thenReturn(secondPruneFuture);
+
+ when(runtime.<Void>scheduleWriteAllOperation(
+ eq("snapshot-cold-partitions"),
+ any()
+ ))
+ .thenReturn(List.of(firstSnapshotFuture))
+ .thenReturn(List.of(secondSnapshotFuture));
+
+ service.startup(() -> 1);
+
+ MetadataImage disabledImage = mock(MetadataImage.class,
RETURNS_DEEP_STUBS);
+
when(disabledImage.features().finalizedVersions().getOrDefault(eq(ShareVersion.FEATURE_NAME),
anyShort())).thenReturn((short) 0);
+
+ MetadataImage enabledImage = mockMetadataImageWithShareGroupsEnabled();
+
+ // Enable the feature: schedules the prune and snapshot jobs
(generation 1).
+ service.onMetadataUpdate(mock(MetadataDelta.class), enabledImage);
+ verify(timer, times(2)).add(any());
+
+ // Fire both jobs. Their futures stay pending, so they are still in
flight.
+ timer.advanceClock(30001L);
+ verify(runtime, times(1)).scheduleWriteOperation(
+ eq("write-state-record-prune"),
+ any(),
+ any()
+ );
+ verify(runtime, times(1)).scheduleWriteAllOperation(
+ eq("snapshot-cold-partitions"),
+ any()
+ );
+
+ // Disable then re-enable while the generation 1 jobs are still in
flight.
+ // Re-enabling bumps the generation and schedules a fresh pair of jobs.
+ service.onMetadataUpdate(mock(MetadataDelta.class), disabledImage);
+ service.onMetadataUpdate(mock(MetadataDelta.class), enabledImage);
+ verify(timer, times(4)).add(any());
+
+ // The stale generation 1 jobs now complete. They are fenced, so they
must
+ // not reschedule themselves: the timer count stays at 4.
+ firstPruneFuture.complete(Optional.empty());
+ firstSnapshotFuture.complete(null);
+ verify(timer, times(4)).add(any());
+
+ // Fire the current generation jobs. This is their second run overall
+ // (hence times(2)), and their futures are again left in flight.
+ timer.advanceClock(30001L);
+ verify(runtime, times(2)).scheduleWriteOperation(
+ eq("write-state-record-prune"),
+ any(),
+ any()
+ );
+ verify(runtime, times(2)).scheduleWriteAllOperation(
+ eq("snapshot-cold-partitions"),
+ any()
+ );
+
+ // Completing each current generation job reschedules itself, so the
timer
+ // count grows one at a time: 4 -> 5 (prune) -> 6 (snapshot).
+ secondPruneFuture.complete(Optional.empty());
+ verify(timer, times(5)).add(any());
+
+ secondSnapshotFuture.complete(null);
+ verify(timer, times(6)).add(any());
+
+ checkMetrics(metrics);
+
+ service.shutdown();
+ }
+
@Test
public void testShareStateTopicConfigs() {
CoordinatorRuntime<ShareCoordinatorShard, CoordinatorRecord> runtime =
mockRuntime();