You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@kafka.apache.org by bb...@apache.org on 2026/09/29 14:03:23 UTC

(kafka) branch trunk updated: KAFKA-21159: Clear next offsets to consume on task close to avoid stale commit after revive (#23584)

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

bbejeck 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 7a6029541a0 KAFKA-21159: Clear next offsets to consume on task close to avoid stale commit after revive (#23584)
7a6029541a0 is described below

commit 7a6029541a0538d8529ad69e2bce6012cc1fb229
Author: Bill Bejeck <bb...@apache.org>
AuthorDate: Tue Sep 29 10:03:06 2026 -0400

    KAFKA-21159: Clear next offsets to consume on task close to avoid stale commit after revive (#23584)
    
    A revived active task (`closeDirtyAndRevive`) kept
    `nextOffsetsAndMetadataToBeConsumed`  from before the corruption. After
    the consumer is seeked back to its committed offset, a  commit driven by
    processor metadata (`needsCommit`, which commits all input partitions)
    could fall back to that stale next offset and commit past records the
    task hasn't  reprocessed.
    
    Fix: clear `nextOffsetsAndMetadataToBeConsumed` in `StreamTask.close()`,
    alongside  `consumedOffsets`, so a revived task has no stale fallback
    for a partition it hasn't  re-read.
    
    Testing: added
    
    `StreamTaskTest.shouldNotCommitStaleNextOffsetForPartitionNotReReadAfterRevive`,
    which reproduces the revive scenario and fails without the fix. Full
    `StreamTaskTest` passes.
    
    Reviewers: Rahul <ra...@gmail.com>, Lucas Brutschy
     <lb...@confluent.io>
---
 .../streams/processor/internals/StreamTask.java    |  2 +
 .../processor/internals/StreamTaskTest.java        | 46 ++++++++++++++++++++++
 2 files changed, 48 insertions(+)

diff --git a/streams/src/main/java/org/apache/kafka/streams/processor/internals/StreamTask.java b/streams/src/main/java/org/apache/kafka/streams/processor/internals/StreamTask.java
index 39fd0e56b4e..22b8f9bfff1 100644
--- a/streams/src/main/java/org/apache/kafka/streams/processor/internals/StreamTask.java
+++ b/streams/src/main/java/org/apache/kafka/streams/processor/internals/StreamTask.java
@@ -713,6 +713,8 @@ public class StreamTask extends AbstractTask implements ProcessorNodePunctuator,
         closeTaskSensor.record();
         partitionsToResume.clear();
         consumedOffsets.clear();
+        // Clear so a revived task has no stale fallback for a partition it has not re-read.
+        nextOffsetsAndMetadataToBeConsumed.clear();
 
         transitionTo(State.CLOSED);
     }
diff --git a/streams/src/test/java/org/apache/kafka/streams/processor/internals/StreamTaskTest.java b/streams/src/test/java/org/apache/kafka/streams/processor/internals/StreamTaskTest.java
index b0bd2a737bd..ad528c29744 100644
--- a/streams/src/test/java/org/apache/kafka/streams/processor/internals/StreamTaskTest.java
+++ b/streams/src/test/java/org/apache/kafka/streams/processor/internals/StreamTaskTest.java
@@ -2805,6 +2805,52 @@ public class StreamTaskTest {
         assertFalse(task.commitRequested());
     }
 
+    @Test
+    public void shouldNotCommitStaleNextOffsetForPartitionNotReReadAfterRevive() {
+        // A revived task must not commit a stale "next offset to consume" for a partition it has not re-read.
+        when(stateManager.taskId()).thenReturn(taskId);
+        when(stateManager.taskType()).thenReturn(TaskType.ACTIVE);
+        // idle disabled (-1) so the revived task can process partition2 without waiting on empty partition1
+        task = createStatelessTask(createConfig(AT_LEAST_ONCE, "-1"));
+
+        // committed offset partition1 will be seeked back to when the revived task restores
+        consumer.commitSync(Map.of(partition1, new OffsetAndMetadata(5L)));
+
+        task.initializeIfNeeded();
+        task.completeRestoration(noOpResetter -> { });
+
+        // a poll advanced partition1 past its committed offset before the corruption
+        task.updateNextOffsets(partition1, new OffsetAndMetadata(10L, Optional.of(0), ""));
+
+        // closeDirtyAndRevive: suspend, close dirty, mark inputs for offset reset, revive
+        task.suspend();
+        task.closeDirty();
+        task.addPartitionsForOffsetReset(Set.of(partition1));
+        task.revive();
+
+        // restoration seeks partition1 back to its committed offset; partition1 is now NOT re-read
+        task.initializeIfNeeded();
+        task.completeRestoration(noOpResetter -> { });
+        assertEquals(5L, consumer.position(partition1));
+
+        // the revived task re-reads partition2 only, and processor metadata forces a commit of all inputs
+        task.addRecords(partition2, singletonList(getConsumerRecordWithOffsetAsTimestampWithLeaderEpoch(partition2, 0L, 0)));
+        task.process(0L);
+        task.updateNextOffsets(partition2, new OffsetAndMetadata(1L, Optional.of(0), ""));
+        processorStreamTime.mockProcessor.addProcessorMetadata("key1", 100L);
+
+        assertTrue(task.commitNeeded());
+        final Map<TopicPartition, OffsetAndMetadata> committed = task.prepareCommit(true);
+
+        // partition1 must be left out: committing its stale next offset (10) would skip records 5..9
+        assertFalse(
+            committed.containsKey(partition1),
+            "revived task committed a stale next offset for a partition it did not re-read"
+        );
+        // partition2, which was re-read, is still committed
+        assertTrue(committed.containsKey(partition2));
+    }
+
     @Test
     public void closeShouldBeIdempotent() {
         when(stateManager.taskId()).thenReturn(taskId);