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

bbejeck pushed a commit to branch 4.2
in repository https://gitbox.apache.org/repos/asf/kafka.git


The following commit(s) were added to refs/heads/4.2 by this push:
     new 895dc49560f KAFKA-20827: don't re-create an already-owned task on 
assignment (4.2) (#22933)
895dc49560f is described below

commit 895dc49560f5b41dc44ce8ddbbba7eabc2ae8b33
Author: Alan Lau <[email protected]>
AuthorDate: Tue Aug 4 08:54:23 2026 -0400

    KAFKA-20827: don't re-create an already-owned task on assignment (4.2) 
(#22933)
    
    Issue: https://issues.apache.org/jira/browse/KAFKA-20827
    
    Backport of #22925 to 4.2, adapted for the older branch:
    - createNewTasks keeps its Collection<Task> locals; trunk narrowed
    ActiveTaskCreator/StandbyTaskCreator.createTasks to
    Collection<StreamTask>/Collection<StandbyTask>, but on 4.2 they still
    return Collection<Task>.
    - The regression tests build the TaskManager with
    setUpTaskManagerWithStateUpdater(...); 4.2's default test setup uses a
    null state updater that checkStateUpdater would dereference
    
    A task that fails to (re)initialize is left owned (registered and
    flagged failed) to be reconciled via the corruption/failed-task path. It
    is excluded from handleAssignment's rectify-existing pass (which
    iterates only non-failed tasks), so the next assignment builds a SECOND
    representation of it in createNewTasks; that duplicate fails init and
    trips the single-owner invariant in Tasks.addStandbyTask/addActiveTask
    (IllegalStateException: 'Attempted to create an standby task that we
    already own'), killing the StreamThread from an otherwise recoverable
    path.
    
    createNewTasks now skips creating any task the registry already owns
    (including failed ones), so the duplicate is never built and the
    single-owner invariant stays strict. Adds a TaskManagerTest regression
    test.
    
    [Systems Tests
    
Passing](https://confluent-open-source-kafka-branch-builder-system-test-results.s3-us-west-2.amazonaws.com/trunk/2026-07-27--001.2d2524ae-1188-4a8f-acb4-6f03b9a5bc6d--1785187224--alanlau28--4.2-KAFKA-20827--0e15adbd26/report.html)
    
    Reveiwers: Bill Bejeck <[email protected]>
---
 .../streams/processor/internals/TaskManager.java   |  6 +++
 .../processor/internals/TaskManagerTest.java       | 49 ++++++++++++++++++++++
 2 files changed, 55 insertions(+)

diff --git 
a/streams/src/main/java/org/apache/kafka/streams/processor/internals/TaskManager.java
 
b/streams/src/main/java/org/apache/kafka/streams/processor/internals/TaskManager.java
index 6486e9e6e99..4e5947a9df4 100644
--- 
a/streams/src/main/java/org/apache/kafka/streams/processor/internals/TaskManager.java
+++ 
b/streams/src/main/java/org/apache/kafka/streams/processor/internals/TaskManager.java
@@ -448,6 +448,12 @@ public class TaskManager {
 
     private void createNewTasks(final Map<TaskId, Set<TopicPartition>> 
activeTasksToCreate,
                                 final Map<TaskId, Set<TopicPartition>> 
standbyTasksToCreate) {
+        // A task that failed to initialize is left owned (registered and 
flagged failed) for the corruption/
+        // failed-task path to reconcile, and the rectify pass above skips 
failed tasks -- so without this guard
+        // the assignment would build a second representation here and trip 
the single-owner invariant in Tasks.
+        activeTasksToCreate.keySet().removeIf(tasks::containsInitialized);
+        standbyTasksToCreate.keySet().removeIf(tasks::containsInitialized);
+
         final Collection<Task> newActiveTasks = 
activeTaskCreator.createTasks(mainConsumer, activeTasksToCreate);
         final Collection<Task> newStandbyTasks = 
standbyTaskCreator.createTasks(standbyTasksToCreate);
 
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/processor/internals/TaskManagerTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/processor/internals/TaskManagerTest.java
index 579b5e1fc6a..b4ba69e01c8 100644
--- 
a/streams/src/test/java/org/apache/kafka/streams/processor/internals/TaskManagerTest.java
+++ 
b/streams/src/test/java/org/apache/kafka/streams/processor/internals/TaskManagerTest.java
@@ -319,6 +319,55 @@ public class TaskManagerTest {
         verify(schedulingTaskManager).unlockTasks(Set.of(taskId00, taskId01));
     }
 
+    @Test
+    public void shouldNotRecreateActiveTasksThatAreAlreadyOwned() {
+        // Real-registry regression test reproducing the crash end-to-end on 
the active path (reached with
+        // num.standby.replicas=0). A task whose init throws is left 
owned-and-failed; handleAssignment's rectify
+        // pass skips failed tasks, so a second assignment used to build a 
second representation in createNewTasks
+        // that, once it also failed init, tripped the single-owner invariant 
in Tasks and killed the StreamThread.
+        // checkStateUpdater drives the pending-init drain, so build with a 
state updater (the shared field has none).
+        final TaskManager taskManager = 
setUpTaskManagerWithStateUpdater(ProcessingMode.AT_LEAST_ONCE, null);
+        final StreamTask task00 = statefulTask(taskId00, 
taskId00ChangelogPartitions)
+            .withInputPartitions(taskId00Partitions)
+            .inState(State.CREATED).build();
+        when(activeTaskCreator.createTasks(consumer, 
taskId00Assignment)).thenReturn(singletonList(task00));
+        doThrow(new 
RuntimeException("KABOOM!")).when(task00).initializeIfNeeded();
+
+        // first assignment: 0_0 is created, its init fails, and it is left 
owned-but-failed in the registry
+        taskManager.handleAssignment(taskId00Assignment, emptyMap());
+        assertThrows(StreamsException.class, () -> 
taskManager.checkStateUpdater(time.milliseconds(), noOpResetter));
+
+        // second assignment of the same owned-but-failed task must not build 
a second representation
+        taskManager.handleAssignment(taskId00Assignment, emptyMap());
+        taskManager.checkStateUpdater(time.milliseconds(), noOpResetter); // 
must not throw "already own: 0_0"
+
+        verify(activeTaskCreator, times(1)).createTasks(consumer, 
taskId00Assignment);
+    }
+
+    @Test
+    public void shouldNotRecreateStandbyTasksThatAreAlreadyOwned() {
+        // The same guard must protect the standby side -- the original 
standby/active recycle crash, reached with
+        // num.standby.replicas>=1. An owned-but-failed standby that is 
re-assigned must not be rebuilt, or the
+        // duplicate trips "Attempted to create an standby task that we 
already own".
+        // checkStateUpdater drives the pending-init drain, so build with a 
state updater (the shared field has none).
+        final TaskManager taskManager = 
setUpTaskManagerWithStateUpdater(ProcessingMode.AT_LEAST_ONCE, null);
+        final StandbyTask task00 = standbyTask(taskId00, 
taskId00ChangelogPartitions)
+            .withInputPartitions(taskId00Partitions)
+            .inState(State.CREATED).build();
+        
when(standbyTaskCreator.createTasks(taskId00Assignment)).thenReturn(singletonList(task00));
+        doThrow(new 
RuntimeException("KABOOM!")).when(task00).initializeIfNeeded();
+
+        // first assignment: 0_0 standby is created, its init fails, and it is 
left owned-but-failed
+        taskManager.handleAssignment(emptyMap(), taskId00Assignment);
+        assertThrows(StreamsException.class, () -> 
taskManager.checkStateUpdater(time.milliseconds(), noOpResetter));
+
+        // second assignment of the same owned-but-failed standby must not 
build a second representation
+        taskManager.handleAssignment(emptyMap(), taskId00Assignment);
+        taskManager.checkStateUpdater(time.milliseconds(), noOpResetter); // 
must not throw "already own" (standby)
+
+        verify(standbyTaskCreator, times(1)).createTasks(taskId00Assignment);
+    }
+
     @Test
     public void shouldLockAffectedTasksOnHandleRevocation() {
         final StreamTask activeTask1 = statefulTask(taskId00, 
taskId00ChangelogPartitions)

Reply via email to