This is an automated email from the ASF dual-hosted git repository.

aho135 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git


The following commit(s) were added to refs/heads/master by this push:
     new 294fe36329e fix: stop replica top-up churn for completed bounded task 
groups (#20387)
294fe36329e is described below

commit 294fe36329e6b4882a33d39f4e73f6779873f0c4
Author: aho135 <[email protected]>
AuthorDate: Wed Sep 23 13:59:55 2026 -0700

    fix: stop replica top-up churn for completed bounded task groups (#20387)
    
    * fix(kafka,kinesis): stop replica top-up churn for completed bounded task 
groups
    
    In bounded (backfill) mode with replicas > 1, once a task group's committed
    offsets reached the configured end, exiting replicas dropped the group below
    the configured replica count. The replica top-up loop in createNewTasks() 
then
    kept creating replacement replicas whose start offset was already at the 
bounded
    end, so they completed instantly and re-triggered the top-up, churning tasks
    endlessly.
    
    Guard the top-up loop with hasTaskGroupReachedBoundedEnd(), mirroring the
    existing guard on the task-group recreation path in the same method.
    
    * Address review: log bounded top-up skip at info level
    
    * Move bounded top-up-guard test to indexing-service and make it fail 
without the fix
    
    The prior KafkaSupervisorTest case passed even without the guard (it never
    reached the replica top-up loop). Replace it with a 
SeekableStreamSupervisorStateTest
    case that flows through runInternal(): it injects an actively-reading 
bounded task
    group that has reached its end offsets with fewer tasks than replicas, and 
asserts
    no replacement replica is submitted. Verified this fails without the guard. 
This also
    provides in-module coverage of the new code path.
    
    * Address review: only check bounded-end inside the replica top-up branch
    
    Move the hasTaskGroupReachedBoundedEnd() guard inside the
    replicas > tasks.size() branch so the metadata-store lookup runs only when a
    top-up would otherwise be issued, rather than for every active group on 
every
    supervisor run.
    
    * Address review: exercise the metadata completion path in the top-up guard 
test
    
    Use a non-empty configured bounded range [0, 100) and a metadata store 
reporting
    committed offsets that have reached the end plus a matching bounded config, 
so the
    test drives hasTaskGroupReachedBoundedEnd()'s metadata-offset path rather 
than the
    empty-range short-circuit. Verified it still fails without the guard.
    
    * Address review: trim redundant javadoc on the top-up guard test
---
 .../supervisor/SeekableStreamSupervisor.java       |  13 +++
 .../SeekableStreamSupervisorStateTest.java         | 124 +++++++++++++++++++++
 2 files changed, 137 insertions(+)

diff --git 
a/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisor.java
 
b/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisor.java
index 6efd24d4b09..ab204a63acf 100644
--- 
a/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisor.java
+++ 
b/indexing-service/src/main/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisor.java
@@ -4489,6 +4489,19 @@ public abstract class 
SeekableStreamSupervisor<PartitionIdType, SequenceOffsetTy
       }
 
       if (ioConfig.getReplicas() > taskGroup.tasks.size()) {
+        // In bounded mode, do not top up replicas for a task group that has 
already reached its end offsets.
+        // Otherwise, as completed replicas exit, this loop keeps recreating 
replacement replicas whose start
+        // offsets are already at the bounded end, producing an endless churn 
of tasks that complete instantly.
+        // This mirrors the completion guard on the task-group recreation path 
in this method. The bounded-end
+        // check (a metadata-store lookup) is intentionally kept inside this 
branch so it only runs when a top-up
+        // would otherwise happen, not for every active group on every run.
+        if (ioConfig.isBounded() && hasTaskGroupReachedBoundedEnd(groupId)) {
+          log.info(
+              "Bounded taskGroup[%d] has reached end offsets, skipping replica 
top-up",
+              groupId
+          );
+          continue;
+        }
         log.info(
             "Number of tasks[%d] does not match configured numReplicas[%d] in 
taskGroup[%d], creating more tasks.",
             taskGroup.tasks.size(), ioConfig.getReplicas(), groupId
diff --git 
a/indexing-service/src/test/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisorStateTest.java
 
b/indexing-service/src/test/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisorStateTest.java
index 540b58049b9..7aace3d7540 100644
--- 
a/indexing-service/src/test/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisorStateTest.java
+++ 
b/indexing-service/src/test/java/org/apache/druid/indexing/seekablestream/supervisor/SeekableStreamSupervisorStateTest.java
@@ -2961,6 +2961,122 @@ public class SeekableStreamSupervisorStateTest extends 
EasyMockSupport
     verifyAll();
   }
 
+  /**
+   * A bounded task group that has reached its configured end offsets is not 
topped up with replacement replicas,
+   * exercising the metadata-based completion path (non-empty range with 
committed offsets at the end).
+   */
+  @Test
+  public void testCreateNewTasks_boundedGroupReachedEnd_doesNotTopUpReplicas()
+  {
+    // replicas = 2, taskCount = 1, non-empty bounded range [0, 100).
+    final BoundedStreamConfig boundedConfig = new BoundedStreamConfig(
+        ImmutableMap.of("0", "0"),
+        ImmutableMap.of("0", "100")
+    );
+    final SeekableStreamSupervisorIOConfig ioConfig =
+        new SupervisorIOConfigBuilder.DefaultSupervisorIOConfigBuilder()
+            .withStream(STREAM)
+            .withInputFormat(new JsonInputFormat(new JSONPathSpec(true, 
List.of()), Map.of(), false, false, false))
+            .withReplicas(2)
+            .withTaskCount(1)
+            .withTaskDuration(new Period("PT1H"))
+            .withStartDelay(new Period("P1D"))
+            .withSupervisorRunPeriod(new Period("PT30S"))
+            .withUseEarliestSequenceNumber(false)
+            .withCompletionTimeout(new Period("PT30M"))
+            .withLagAggregator(LagAggregator.DEFAULT)
+            .withBoundedStreamConfig(boundedConfig)
+            .build();
+
+    // A single already-running task for group 0 whose committed offset 
("100") has reached the configured end, so the
+    // group has one task, i.e. fewer than the configured replica count (2). 
That would normally trigger a top-up.
+    // minMsgTime/maxMsgTime and start sequences must match the injected task 
group so isTaskCurrent() keeps the task.
+    final SeekableStreamIndexTaskIOConfig taskIoConfig = createTaskIoConfigExt(
+        0,
+        Map.of("0", "100"),
+        Map.of("0", "100"),
+        "test",
+        null,
+        null,
+        Set.of(),
+        ioConfig
+    );
+    final TestSeekableStreamIndexTask task1 = createTestTask("task1", "0", 
null, taskIoConfig, recordSupplier);
+
+    EasyMock.reset(spec);
+    EasyMock.expect(spec.getId()).andReturn(SUPERVISOR_ID).anyTimes();
+    
EasyMock.expect(spec.getSupervisorStateManagerConfig()).andReturn(supervisorConfig).anyTimes();
+    
EasyMock.expect(spec.getDataSchema()).andReturn(getDataSchema()).anyTimes();
+    EasyMock.expect(spec.getIoConfig()).andReturn(ioConfig).anyTimes();
+    
EasyMock.expect(spec.getTuningConfig()).andReturn(getTuningConfig()).anyTimes();
+    EasyMock.expect(spec.getEmitter()).andReturn(emitter).anyTimes();
+    
EasyMock.expect(spec.getContextValue(DruidMetrics.TAGS)).andReturn(METRIC_TAGS).anyTimes();
+    EasyMock.expect(spec.isSuspended()).andReturn(false).anyTimes();
+
+    // Metadata store reports committed offsets that have reached the end, 
with a bounded config matching the
+    // supervisor's, so hasTaskGroupReachedBoundedEnd() returns true through 
its metadata-offset path.
+    EasyMock.reset(indexerMetadataStorageCoordinator);
+    
EasyMock.expect(indexerMetadataStorageCoordinator.retrieveDataSourceMetadata(SUPERVISOR_ID))
+            .andReturn(new TestSeekableStreamDataSourceMetadata(
+                new SeekableStreamEndSequenceNumbers<>(STREAM, 
ImmutableMap.of("0", "100")),
+                boundedConfig
+            ))
+            .anyTimes();
+
+    
EasyMock.expect(recordSupplier.getPartitionIds(STREAM)).andReturn(ImmutableSet.of("0")).anyTimes();
+    
EasyMock.expect(taskStorage.getStatus("task1")).andReturn(Optional.of(TaskStatus.running("task1"))).anyTimes();
+    
EasyMock.expect(taskStorage.getTask("task1")).andReturn(Optional.of(task1)).anyTimes();
+    EasyMock.expect(taskQueue.getActiveTasksForDatasource(DATASOURCE))
+            .andReturn(Map.of(task1.getId(), task1))
+            .anyTimes();
+    // Task checkpoints match the reached-end committed offsets so the task is 
not killed as inconsistent.
+    final TreeMap<Integer, Map<String, String>> task1Checkpoints = new 
TreeMap<>();
+    task1Checkpoints.put(0, ImmutableMap.of("0", "100"));
+    EasyMock.expect(indexTaskClient.getCheckpointsAsync(EasyMock.anyString(), 
EasyMock.anyBoolean()))
+            .andReturn(Futures.immediateFuture(task1Checkpoints))
+            .anyTimes();
+    EasyMock.expect(indexTaskClient.getStatusAsync(EasyMock.anyString()))
+            
.andReturn(Futures.immediateFuture(SeekableStreamIndexTaskRunner.Status.READING))
+            .anyTimes();
+    EasyMock.expect(indexTaskClient.getStartTimeAsync(EasyMock.anyString()))
+            .andReturn(Futures.immediateFuture(DateTimes.nowUtc()))
+            .anyTimes();
+    
EasyMock.expect(indexTaskClient.getCurrentOffsetsAsync(EasyMock.anyString(), 
EasyMock.anyBoolean()))
+            .andReturn(Futures.immediateFuture(ImmutableMap.of("0", "100")))
+            .anyTimes();
+    
EasyMock.expect(taskRunner.getRunningTasks()).andReturn(ImmutableList.of()).anyTimes();
+
+    final Capture<Task> submittedTasks = Capture.newInstance(CaptureType.ALL);
+    
EasyMock.expect(taskQueue.add(EasyMock.capture(submittedTasks))).andReturn(true).anyTimes();
+
+    replayAll();
+
+    final TestSeekableStreamSupervisor supervisor = new 
TestSeekableStreamSupervisor();
+    supervisor.start();
+    // Pre-populate an actively-reading task group with the single running 
task. This keeps isBoundedWorkComplete()
+    // false (there is an active group), so runInternal() flows through to 
createNewTasks() rather than short-circuiting
+    // on completion, and it models the real churn scenario where a completed 
replica has exited leaving fewer tasks
+    // than the configured replica count.
+    supervisor.addTaskGroupToActivelyReadingTaskGroup(
+        0,
+        ImmutableMap.of("0", "100"),
+        null,
+        null,
+        Set.of("task1"),
+        Set.of(),
+        Map.of()
+    );
+    supervisor.runInternal();
+
+    Assertions.assertFalse(
+        submittedTasks.hasCaptured(),
+        "No replica top-up task should be submitted for a bounded task group 
that has reached its end offsets"
+    );
+    Assertions.assertEquals(1, supervisor.getActiveTaskGroup(0).tasks.size());
+
+    verifyAll();
+  }
+
   /**
    * Verifies that when N stored offsets are all outside stream retention, all 
N partitions are collected in a single
    * pass and reset together in one {@code resetInternal()} call, producing 
exactly one exception event.
@@ -3746,6 +3862,14 @@ public class SeekableStreamSupervisorStateTest extends 
EasyMockSupport
       super(partitions);
     }
 
+    public TestSeekableStreamDataSourceMetadata(
+            SeekableStreamSequenceNumbers<String, String> partitions,
+            BoundedStreamConfig boundedStreamConfig
+    )
+    {
+      super(partitions, boundedStreamConfig);
+    }
+
     @Override
     public DataSourceMetadata asStartMetadata()
     {


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to