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

MartijnVisser pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-connector-kafka.git


The following commit(s) were added to refs/heads/main by this push:
     new 8ddcc090 [FLINK-33484][Connectors/Kafka] Correct offsets committed to 
Kafka
8ddcc090 is described below

commit 8ddcc0901850bb69baaf4bf6801632947985cb7c
Author: Dale Lane <[email protected]>
AuthorDate: Thu Oct 1 23:15:40 2026 +0100

    [FLINK-33484][Connectors/Kafka] Correct offsets committed to Kafka
    
    Offsets that the Kafka source commits back to Kafka can lag the
    consumer's true position, because entries that the consumer reads but
    never delivers as records (e.g. transaction commit / abort markers, and
    records of aborted transactions) were not accounted for. The result is
    that the committed offset was stopping at the last delivered record,
    while the consumer had advanced past that.
    
    This wasn't impacting Flink processing guarantees, as restarts restore
    from checkpointed offsets, not offsets committed to Kafka. External
    tooling that uses the Kafka committed offset for monitoring would give
    the appearance of consumer lag. On low-traffic or bursty transactional
    topics, this would report lag that does not exist until new events are
    received. A partition holding only aborted transactions, read from the
    earliest offset, never had an offset committed at all.
    
    This is now changed to reconcile the offset to commit with the
    consumer's position:
    
    - KafkaPartitionSplitReader now tracks, per partition, the offset of
      the last record handed to the reader, and the consumer position
      observed at the end of each fetch()
    - At checkpoint commit, reconcileOffsetsToCommit advances the offset to
      commit to the consumer position when the reader is caught up to what
      it delivered, so the committed offset will reflect the non-record
      entries that the consumer read past. Reconciliation is skipped for
      partitions that aren't assigned and for running bounded splits
    - Splits that have not emitted a record still hold a starting offset
      placeholder such as EARLIEST_OFFSET, so KafkaSourceReader has no
      offset to commit for them. Their partitions are passed to the split
      reader separately, which commits the consumer position for them
      unless records have been fetched that the checkpoint may not include
    - The split reader remembers the offset it last committed for each
      assigned partition, and leaves a partition out of a commit rather
      than move its committed offset backwards. This can otherwise happen
      when a record is fetched between a checkpoint and its commit, so the
      offset can no longer be advanced. The offset is recorded when the
      commit is sent, so that a later commit cannot overtake one that is
      still in flight
    - A finished bounded split is committed at its stopping offset, rather
      than after its last emitted record, as it only finishes once the
      consumer position has reached the stopping offset. This only applies
      once the split holds a real offset: a split that is still at a
      starting offset placeholder may have been empty when it was assigned,
      with the consumer already past its stopping offset, so committing the
      stopping offset could move the offset backwards
    - Per-partition tracking state is cleaned up when partitions are
      unassigned
    - KafkaSourceReader records the committed-offset metric from the
      offsets that are committed (rather than those requested) so Kafka and
      Flink's committedOffsets metrics agree.
    
    Generated-by: Claude Code (Sonnet 5)
    Signed-off-by: Dale Lane <[email protected]>
---
 .../source/reader/KafkaPartitionSplitReader.java   | 138 +++-
 .../kafka/source/reader/KafkaSourceReader.java     |  57 +-
 .../reader/fetcher/KafkaSourceFetcherManager.java  |  22 +-
 .../reader/KafkaPartitionSplitReaderTest.java      | 315 +++++++++
 .../kafka/source/reader/KafkaSourceReaderTest.java | 729 ++++++++++++++++++++-
 5 files changed, 1244 insertions(+), 17 deletions(-)

diff --git 
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/source/reader/KafkaPartitionSplitReader.java
 
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/source/reader/KafkaPartitionSplitReader.java
index b87ce1ce..d58a2f3d 100644
--- 
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/source/reader/KafkaPartitionSplitReader.java
+++ 
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/source/reader/KafkaPartitionSplitReader.java
@@ -86,6 +86,19 @@ public class KafkaPartitionSplitReader
     // Tracking empty splits that has not been added to finished splits in 
fetch()
     private final Set<String> emptySplits = new HashSet<>();
 
+    // Offset of the last record that fetch() handed to the source reader, per 
partition
+    final Map<TopicPartition, Long> lastFetchedOffsets = new HashMap<>();
+
+    // Consumer position observed at the end of the most recent fetch(), per 
partition.
+    final Map<TopicPartition, Long> lastKnownPositions = new HashMap<>();
+
+    // Offset most recently sent to Kafka in a commit, per assigned partition. 
Recorded when the
+    // commit is sent (instead of when it completes) so later commits won't 
overtake commits in
+    // flight with a lower offset.
+    // If a commit fails, a later checkpoint with an offset lower than this 
won't be committed, so
+    // the committed offset is only updated again once a checkpoint reaches it.
+    final Map<TopicPartition, Long> lastCommittedOffsets = new HashMap<>();
+
     public KafkaPartitionSplitReader(
             Properties props,
             SourceReaderContext context,
@@ -173,6 +186,7 @@ public class KafkaPartitionSplitReader
             long stoppingOffset = getStoppingOffset(tp);
             long consumerPosition =
                     getConsumerPosition(consumer, tp, "retrieving consumer 
position");
+            lastKnownPositions.put(tp, consumerPosition);
             // Stop fetching when the consumer's position reaches the 
stoppingOffset.
             // Control messages may follow the last record; therefore, using 
the last record's
             // offset as a stopping condition could result in indefinite 
blocking.
@@ -196,6 +210,8 @@ public class KafkaPartitionSplitReader
                             
kafkaSourceReaderMetrics.maybeAddRecordsLagMetric(consumer, trackTp);
                         });
 
+        trackLastFetchedRecordOffsets(consumerRecords);
+
         markEmptySplitsAsFinished(recordsBySplits);
 
         // Unassign the partitions that has finished.
@@ -335,10 +351,35 @@ public class KafkaPartitionSplitReader
 
     // ---------------
 
+    /**
+     * Commits the offsets of a completed checkpoint to Kafka.
+     *
+     * @param offsetsToCommit offsets that the checkpoint holds for its 
partitions
+     * @param partitionsWithoutOffset partitions whose split had not emitted 
any record when the
+     *     checkpoint was taken, so the checkpoint holds no offset for them, 
only a starting offset
+     *     placeholder such as {@link KafkaPartitionSplit#EARLIEST_OFFSET}
+     * @param offsetCommitCallback called with offsets that were committed
+     */
     public void notifyCheckpointComplete(
             Map<TopicPartition, OffsetAndMetadata> offsetsToCommit,
+            Set<TopicPartition> partitionsWithoutOffset,
             OffsetCommitCallback offsetCommitCallback) {
-        ensureConsumer().commitAsync(offsetsToCommit, offsetCommitCallback);
+        final KafkaConsumer<byte[], byte[]> consumer = ensureConsumer();
+        final Map<TopicPartition, OffsetAndMetadata> reconciled =
+                reconcileOffsetsToCommit(consumer, offsetsToCommit, 
partitionsWithoutOffset);
+        if (reconciled.isEmpty()) {
+            // nothing left to commit after reconciling with the consumer 
position
+            offsetCommitCallback.onComplete(reconciled, null);
+            return;
+        }
+        final Set<TopicPartition> assignment = consumer.assignment();
+        reconciled.forEach(
+                (tp, offsetAndMetadata) -> {
+                    if (assignment.contains(tp)) {
+                        lastCommittedOffsets.put(tp, 
offsetAndMetadata.offset());
+                    }
+                });
+        consumer.commitAsync(reconciled, offsetCommitCallback);
     }
 
     @VisibleForTesting
@@ -389,6 +430,95 @@ public class KafkaPartitionSplitReader
         return retryOnWakeup(() -> consumer.position(tp), msg);
     }
 
+    private void trackLastFetchedRecordOffsets(ConsumerRecords<byte[], byte[]> 
consumerRecords) {
+        for (TopicPartition tp : consumerRecords.partitions()) {
+            List<ConsumerRecord<byte[], byte[]>> partitionRecords = 
consumerRecords.records(tp);
+            if (!partitionRecords.isEmpty()) {
+                lastFetchedOffsets.put(
+                        tp, partitionRecords.get(partitionRecords.size() - 
1).offset());
+            }
+        }
+    }
+
+    /**
+     * Advances the offsets to commit over the entries that the Kafka consumer 
read but never
+     * delivered, such as transaction control markers and records of aborted 
transactions.
+     *
+     * <p>{@link KafkaRecordEmitter} derives the offset to commit from the 
records it receives, so
+     * the offset stops at the first entry that Kafka does not deliver, for as 
long as the partition
+     * is idle. The consumer's own position accounts for those entries, so it 
is the offset that
+     * external tooling expects to see.
+     *
+     * <p>This relies on {@link #lastKnownPositions}, populated as a side 
effect of the regular
+     * {@link #fetch()} poll loop, rather than querying the consumer for the 
position again here.
+     * {@link #notifyCheckpointComplete} runs on this same split fetcher 
thread, but at a point
+     * outside that poll loop, so this allows us to avoid a separate blocking 
call to the consumer.
+     *
+     * <p>A partition that has never delivered a record (e.g. a partition with 
only aborted
+     * transactions) never gets an offset in the checkpoint when its split 
starts from a placeholder
+     * such as {@link KafkaPartitionSplit#EARLIEST_OFFSET}. Such partitions 
are committed at the
+     * consumer position, as long as no record has been fetched for them. The 
checkpoint may not
+     * include a fetched record yet, and committing the position would skip 
over it.
+     *
+     * <p>Offsets are only progressed while no newer records have been 
fetched, so later checkpoints
+     * can hold the same offset as an earlier one and yet not be advanced as 
far. Offsets lower than
+     * the one last committed for a partition will be left out, so that the 
committed offset never
+     * moves backwards.
+     */
+    private Map<TopicPartition, OffsetAndMetadata> reconcileOffsetsToCommit(
+            KafkaConsumer<byte[], byte[]> consumer,
+            Map<TopicPartition, OffsetAndMetadata> offsetsToCommit,
+            Set<TopicPartition> partitionsWithoutOffset) {
+        Map<TopicPartition, OffsetAndMetadata> reconciled = new 
HashMap<>(offsetsToCommit);
+        Set<TopicPartition> assignment = consumer.assignment();
+        offsetsToCommit.forEach(
+                (tp, offsetAndMetadata) -> {
+                    if (!assignment.contains(tp) || 
stoppingOffsets.containsKey(tp)) {
+                        return;
+                    }
+                    Long lastFetchedOffset = lastFetchedOffsets.get(tp);
+                    if (lastFetchedOffset != null
+                            && offsetAndMetadata.offset() != lastFetchedOffset 
+ 1) {
+                        return;
+                    }
+                    Long position = lastKnownPositions.get(tp);
+                    if (position != null && position > 
offsetAndMetadata.offset()) {
+                        LOG.debug(
+                                "Advancing offset to commit for {} from {} to 
the consumer position {}.",
+                                tp,
+                                offsetAndMetadata.offset(),
+                                position);
+                        reconciled.put(
+                                tp, new OffsetAndMetadata(position, 
offsetAndMetadata.metadata()));
+                    }
+                });
+        partitionsWithoutOffset.forEach(
+                tp -> {
+                    if (!assignment.contains(tp)
+                            || stoppingOffsets.containsKey(tp)
+                            || lastFetchedOffsets.containsKey(tp)) {
+                        return;
+                    }
+                    Long position = lastKnownPositions.get(tp);
+                    if (position != null) {
+                        LOG.debug(
+                                "Committing the consumer position {} for {} 
which has not delivered any records.",
+                                position,
+                                tp);
+                        reconciled.put(tp, new OffsetAndMetadata(position));
+                    }
+                });
+        reconciled
+                .entrySet()
+                .removeIf(
+                        entry -> {
+                            Long lastCommittedOffset = 
lastCommittedOffsets.get(entry.getKey());
+                            return lastCommittedOffset != null
+                                    && entry.getValue().offset() < 
lastCommittedOffset;
+                        });
+        return reconciled;
+    }
+
     private void parseStartingOffsets(
             KafkaPartitionSplit split,
             List<TopicPartition> partitionsStartingFromEarliest,
@@ -535,6 +665,12 @@ public class KafkaPartitionSplitReader
         Collection<TopicPartition> newAssignment = new 
HashSet<>(consumer.assignment());
         newAssignment.removeAll(partitionsToUnassign);
         consumer.assign(newAssignment);
+        partitionsToUnassign.forEach(
+                tp -> {
+                    lastFetchedOffsets.remove(tp);
+                    lastKnownPositions.remove(tp);
+                    lastCommittedOffsets.remove(tp);
+                });
     }
 
     private String createConsumerClientId(Properties props) {
diff --git 
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/source/reader/KafkaSourceReader.java
 
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/source/reader/KafkaSourceReader.java
index 317fc809..581d2d7b 100644
--- 
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/source/reader/KafkaSourceReader.java
+++ 
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/source/reader/KafkaSourceReader.java
@@ -40,8 +40,10 @@ import org.slf4j.LoggerFactory;
 
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.SortedMap;
 import java.util.TreeMap;
 import java.util.concurrent.ConcurrentHashMap;
@@ -56,6 +58,9 @@ public class KafkaSourceReader<T>
     // These maps need to be concurrent because it will be accessed by both 
the main thread
     // and the split fetcher thread in the callback.
     private final SortedMap<Long, Map<TopicPartition, OffsetAndMetadata>> 
offsetsToCommit;
+    // Partitions of splits that had not emitted a record when a checkpoint 
was taken, so the
+    // checkpoint has no offset to commit for them. (The split reader resolves 
them instead.)
+    private final SortedMap<Long, Set<TopicPartition>> 
partitionsWithoutOffsetToCommit;
     private final ConcurrentMap<TopicPartition, OffsetAndMetadata> 
offsetsOfFinishedSplits;
     private final KafkaSourceReaderMetrics kafkaSourceReaderMetrics;
     private final boolean commitOffsetsOnCheckpoint;
@@ -71,6 +76,7 @@ public class KafkaSourceReader<T>
             KafkaSourceReaderMetrics kafkaSourceReaderMetrics) {
         super(kafkaSourceFetcherManager, recordEmitter, config, context);
         this.offsetsToCommit = Collections.synchronizedSortedMap(new 
TreeMap<>());
+        this.partitionsWithoutOffsetToCommit = 
Collections.synchronizedSortedMap(new TreeMap<>());
         this.offsetsOfFinishedSplits = new ConcurrentHashMap<>();
         this.kafkaSourceReaderMetrics = kafkaSourceReaderMetrics;
         this.commitOffsetsOnCheckpoint =
@@ -86,10 +92,24 @@ public class KafkaSourceReader<T>
     protected void onSplitFinished(Map<String, KafkaPartitionSplitState> 
finishedSplitIds) {
         finishedSplitIds.forEach(
                 (ignored, splitState) -> {
-                    if (splitState.getCurrentOffset() >= 0) {
+                    long offsetToCommit = splitState.getCurrentOffset();
+                    // A split read to its stopping offset has read every 
entry before it (including
+                    // non-record events that were not emitted). A split still 
holding a starting
+                    // offset placeholder may instead have been empty when it 
was assigned, with the
+                    // consumer already past its stopping offset, so 
committing that would move the
+                    // offset backwards.
+                    // A stopping offset that the split reader resolves 
itself, such as
+                    // LATEST_OFFSET, is negative here and is never chosen.
+                    if (offsetToCommit >= 0) {
+                        offsetToCommit =
+                                Math.max(
+                                        offsetToCommit,
+                                        splitState
+                                                .getStoppingOffset()
+                                                
.orElse(KafkaPartitionSplit.NO_STOPPING_OFFSET));
                         offsetsOfFinishedSplits.put(
                                 splitState.getTopicPartition(),
-                                new 
OffsetAndMetadata(splitState.getCurrentOffset()));
+                                new OffsetAndMetadata(offsetToCommit));
                     }
                 });
     }
@@ -114,6 +134,10 @@ public class KafkaSourceReader<T>
                     offsetsMap.put(
                             split.getTopicPartition(),
                             new OffsetAndMetadata(split.getStartingOffset()));
+                } else {
+                    partitionsWithoutOffsetToCommit
+                            .computeIfAbsent(checkpointId, id -> new 
HashSet<>())
+                            .add(split.getTopicPartition());
                 }
             }
             // Put offsets of all the finished splits.
@@ -136,7 +160,9 @@ public class KafkaSourceReader<T>
             return;
         }
 
-        if (committedPartitions.isEmpty()) {
+        final Set<TopicPartition> partitionsWithoutOffset =
+                partitionsWithoutOffsetToCommit.getOrDefault(checkpointId, 
Collections.emptySet());
+        if (committedPartitions.isEmpty() && 
partitionsWithoutOffset.isEmpty()) {
             LOG.debug("There are no offsets to commit for checkpoint {}.", 
checkpointId);
             removeAllOffsetsToCommitUpToCheckpoint(checkpointId);
             return;
@@ -145,7 +171,8 @@ public class KafkaSourceReader<T>
         ((KafkaSourceFetcherManager) splitFetcherManager)
                 .commitOffsets(
                         committedPartitions,
-                        (ignored, e) -> {
+                        partitionsWithoutOffset,
+                        (committedOffsets, e) -> {
                             // The offset commit here is needed by the 
external monitoring. It won't
                             // break Flink job's correctness if we fail to 
commit the offset here.
                             if (e != null) {
@@ -158,13 +185,19 @@ public class KafkaSourceReader<T>
                                 LOG.debug(
                                         "Successfully committed offsets for 
checkpoint {}",
                                         checkpointId);
-                                
kafkaSourceReaderMetrics.recordSucceededCommit();
-                                // If the finished topic partition has been 
committed, we remove it
-                                // from the offsets of the finished splits map.
-                                committedPartitions.forEach(
+                                // nothing is sent to Kafka when none of the 
partitions without
+                                // an offset could be resolved
+                                if (!committedOffsets.isEmpty()) {
+                                    
kafkaSourceReaderMetrics.recordSucceededCommit();
+                                }
+                                // offsets committed to Kafka can differ from 
what was requested,
+                                // as the split reader reconciles them with 
the consumer position
+                                committedOffsets.forEach(
                                         (tp, offset) ->
                                                 
kafkaSourceReaderMetrics.recordCommittedOffset(
                                                         tp, offset.offset()));
+                                // If the finished topic partition has been 
committed, remove it
+                                // from the offsets of the finished splits
                                 offsetsOfFinishedSplits
                                         .entrySet()
                                         .removeIf(
@@ -180,6 +213,10 @@ public class KafkaSourceReader<T>
         while (!offsetsToCommit.isEmpty() && offsetsToCommit.firstKey() <= 
checkpointId) {
             offsetsToCommit.remove(offsetsToCommit.firstKey());
         }
+        while (!partitionsWithoutOffsetToCommit.isEmpty()
+                && partitionsWithoutOffsetToCommit.firstKey() <= checkpointId) 
{
+            
partitionsWithoutOffsetToCommit.remove(partitionsWithoutOffsetToCommit.firstKey());
+        }
     }
 
     @Override
@@ -199,6 +236,10 @@ public class KafkaSourceReader<T>
         return offsetsToCommit;
     }
 
+    SortedMap<Long, Set<TopicPartition>> getPartitionsWithoutOffsetToCommit() {
+        return partitionsWithoutOffsetToCommit;
+    }
+
     @VisibleForTesting
     int getNumAliveFetchers() {
         return splitFetcherManager.getNumAliveFetchers();
diff --git 
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/source/reader/fetcher/KafkaSourceFetcherManager.java
 
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/source/reader/fetcher/KafkaSourceFetcherManager.java
index 115a0e8b..d7751751 100644
--- 
a/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/source/reader/fetcher/KafkaSourceFetcherManager.java
+++ 
b/flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/source/reader/fetcher/KafkaSourceFetcherManager.java
@@ -40,6 +40,7 @@ import org.slf4j.LoggerFactory;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Map;
+import java.util.Set;
 import java.util.function.Consumer;
 import java.util.function.Supplier;
 
@@ -73,19 +74,26 @@ public class KafkaSourceFetcherManager
     }
 
     public void commitOffsets(
-            Map<TopicPartition, OffsetAndMetadata> offsetsToCommit, 
OffsetCommitCallback callback) {
-        LOG.debug("Committing offsets {}", offsetsToCommit);
-        if (offsetsToCommit.isEmpty()) {
+            Map<TopicPartition, OffsetAndMetadata> offsetsToCommit,
+            Set<TopicPartition> partitionsWithoutOffset,
+            OffsetCommitCallback callback) {
+        LOG.debug(
+                "Committing offsets {} and positions of {}",
+                offsetsToCommit,
+                partitionsWithoutOffset);
+        if (offsetsToCommit.isEmpty() && partitionsWithoutOffset.isEmpty()) {
             return;
         }
         SplitFetcher<ConsumerRecord<byte[], byte[]>, KafkaPartitionSplit> 
splitFetcher =
                 fetchers.get(0);
         if (splitFetcher != null) {
             // The fetcher thread is still running. This should be the 
majority of the cases.
-            enqueueOffsetsCommitTask(splitFetcher, offsetsToCommit, callback);
+            enqueueOffsetsCommitTask(
+                    splitFetcher, offsetsToCommit, partitionsWithoutOffset, 
callback);
         } else {
             splitFetcher = createSplitFetcher();
-            enqueueOffsetsCommitTask(splitFetcher, offsetsToCommit, callback);
+            enqueueOffsetsCommitTask(
+                    splitFetcher, offsetsToCommit, partitionsWithoutOffset, 
callback);
             startFetcher(splitFetcher);
         }
     }
@@ -93,6 +101,7 @@ public class KafkaSourceFetcherManager
     private void enqueueOffsetsCommitTask(
             SplitFetcher<ConsumerRecord<byte[], byte[]>, KafkaPartitionSplit> 
splitFetcher,
             Map<TopicPartition, OffsetAndMetadata> offsetsToCommit,
+            Set<TopicPartition> partitionsWithoutOffset,
             OffsetCommitCallback callback) {
         KafkaPartitionSplitReader kafkaReader =
                 (KafkaPartitionSplitReader) splitFetcher.getSplitReader();
@@ -101,7 +110,8 @@ public class KafkaSourceFetcherManager
                 new SplitFetcherTask() {
                     @Override
                     public boolean run() throws IOException {
-                        kafkaReader.notifyCheckpointComplete(offsetsToCommit, 
callback);
+                        kafkaReader.notifyCheckpointComplete(
+                                offsetsToCommit, partitionsWithoutOffset, 
callback);
                         return true;
                     }
 
diff --git 
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/source/reader/KafkaPartitionSplitReaderTest.java
 
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/source/reader/KafkaPartitionSplitReaderTest.java
index 95502959..0b5e5335 100644
--- 
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/source/reader/KafkaPartitionSplitReaderTest.java
+++ 
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/source/reader/KafkaPartitionSplitReaderTest.java
@@ -37,8 +37,11 @@ import org.apache.flink.runtime.metrics.MetricNames;
 import org.apache.flink.runtime.metrics.groups.InternalSourceReaderMetricGroup;
 import org.apache.flink.runtime.metrics.groups.UnregisteredMetricGroups;
 
+import org.apache.kafka.clients.admin.AdminClient;
 import org.apache.kafka.clients.consumer.ConsumerConfig;
 import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.clients.consumer.OffsetAndMetadata;
+import org.apache.kafka.clients.producer.ProducerRecord;
 import org.apache.kafka.common.KafkaException;
 import org.apache.kafka.common.TopicPartition;
 import org.apache.kafka.common.serialization.ByteArrayDeserializer;
@@ -54,6 +57,7 @@ import org.junit.jupiter.params.provider.NullAndEmptySource;
 import org.junit.jupiter.params.provider.ValueSource;
 
 import java.io.IOException;
+import java.io.UncheckedIOException;
 import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -66,11 +70,16 @@ import java.util.Map;
 import java.util.Optional;
 import java.util.Properties;
 import java.util.Set;
+import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
 
 import static 
org.apache.flink.connector.kafka.testutils.KafkaSourceTestEnv.NUM_RECORDS_PER_PARTITION;
+import static org.apache.flink.core.testutils.CommonTestUtils.waitUtil;
+import static 
org.apache.flink.streaming.connectors.kafka.KafkaTestBase.kafkaServer;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.entry;
 
 /** Unit tests for {@link KafkaPartitionSplitReader}. */
 @ResourceLock("KafkaTestBase")
@@ -425,6 +434,223 @@ public class KafkaPartitionSplitReaderTest {
                                 KafkaSourceOptions.POLL_TIMEOUT_MS.key()));
     }
 
+    @Test
+    void testTrackedOffsetsAreRemovedWhenPartitionsAreUnassigned() throws 
Exception {
+        KafkaPartitionSplitReader reader = createReaderForCommits();
+        final TopicPartition finishingPartition = new TopicPartition(TOPIC1, 
0);
+        // a second partition is kept assigned throughout, which prevents 
consumer.poll()
+        // blocking, and proves that only offsets of the unassigned partition 
are removed
+        final TopicPartition remainingPartition = new TopicPartition(TOPIC1, 
1);
+        reader.handleSplitsChanges(
+                new SplitsAddition<>(
+                        Arrays.asList(
+                                new KafkaPartitionSplit(
+                                        finishingPartition,
+                                        
earliestOffsets.get(finishingPartition),
+                                        NUM_RECORDS_PER_PARTITION),
+                                new KafkaPartitionSplit(
+                                        remainingPartition,
+                                        
earliestOffsets.get(remainingPartition),
+                                        
KafkaPartitionSplit.NO_STOPPING_OFFSET))));
+
+        // a commit while both partitions are assigned records their committed 
offsets
+        final Map<TopicPartition, OffsetAndMetadata> offsetsToCommit = new 
HashMap<>();
+        offsetsToCommit.put(
+                finishingPartition, new 
OffsetAndMetadata(earliestOffsets.get(finishingPartition)));
+        offsetsToCommit.put(
+                remainingPartition, new 
OffsetAndMetadata(earliestOffsets.get(remainingPartition)));
+        commitOffsets(reader, offsetsToCommit, Collections.emptySet());
+
+        // fetch until the bounded split has been finished and unassigned
+        fetchUntil(
+                reader,
+                () -> 
!reader.consumer().assignment().contains(finishingPartition),
+                "The bounded split was not finished.");
+        
assertThat(reader.consumer().assignment()).contains(remainingPartition);
+
+        assertThat(reader.lastFetchedOffsets)
+                .as("Offsets tracked for committing")
+                .doesNotContainKey(finishingPartition)
+                .containsKey(remainingPartition);
+        assertThat(reader.lastKnownPositions)
+                .as("Consumer positions tracked for committing")
+                .doesNotContainKey(finishingPartition)
+                .containsKey(remainingPartition);
+        assertThat(reader.lastCommittedOffsets)
+                .as("Offsets last committed")
+                .doesNotContainKey(finishingPartition)
+                .containsKey(remainingPartition);
+    }
+
+    @Test
+    void testPartitionWithoutOffsetIsCommittedAtConsumerPosition() throws 
Exception {
+        final KafkaPartitionSplitReader reader = createReaderForCommits();
+        // an empty partition, so the split never emits a record
+        final TopicPartition tp = new TopicPartition(TOPIC3, 0);
+        assignSplit(reader, new KafkaPartitionSplit(tp, 
KafkaPartitionSplit.EARLIEST_OFFSET));
+        reader.fetch();
+
+        assertThat(commitPartitionsWithoutOffset(reader, tp))
+                .containsExactly(entry(tp, new OffsetAndMetadata(0L)));
+    }
+
+    @Test
+    void testPartitionWithoutOffsetIsNotCommittedOnceRecordsAreFetched() 
throws Exception {
+        final KafkaPartitionSplitReader reader = createReaderForCommits();
+        final TopicPartition tp = new TopicPartition(TOPIC1, 0);
+        assignSplit(reader, new KafkaPartitionSplit(tp, 
KafkaPartitionSplit.EARLIEST_OFFSET));
+        fetchUntil(
+                reader,
+                () -> reader.lastFetchedOffsets.containsKey(tp),
+                "No records were fetched.");
+
+        // the checkpoint does not include the fetched records, so the 
position would skip them
+        assertThat(commitPartitionsWithoutOffset(reader, tp)).isEmpty();
+    }
+
+    @Test
+    void testPartitionWithoutOffsetIsNotCommittedForBoundedSplit() throws 
Exception {
+        final KafkaPartitionSplitReader reader = createReaderForCommits();
+        final TopicPartition tp = new TopicPartition(TOPIC3, 1);
+        assignSplit(reader, new KafkaPartitionSplit(tp, 
KafkaPartitionSplit.EARLIEST_OFFSET, 5L));
+        reader.fetch();
+
+        assertThat(commitPartitionsWithoutOffset(reader, tp)).isEmpty();
+    }
+
+    /**
+     * Reproduces a checkpoint whose offset is followed by a commit marker 
being committed twice:
+     * first while the partition is idle (so the offset progresses over the 
marker), and then second
+     * after a new record has been fetched (which must not move the committed 
offset back).
+     */
+    @Test
+    void 
testCommittedOffsetDoesNotGoBackwardsWhenRecordIsFetchedBeforeCommit() throws 
Throwable {
+        final String topic = "CommittedOffsetDoesNotGoBackwards";
+        final String groupId = topic + "Group";
+        final TopicPartition tp = new TopicPartition(topic, 0);
+        KafkaSourceTestEnv.createTestTopic(topic, 1, 1);
+
+        // offset 0 = record
+        // offset 1 = commit marker
+        produceRecordInTransaction(tp);
+
+        final Properties props = new Properties();
+        props.setProperty(ConsumerConfig.GROUP_ID_CONFIG, groupId);
+        props.setProperty(ConsumerConfig.ISOLATION_LEVEL_CONFIG, 
"read_committed");
+        props.setProperty(KafkaSourceOptions.POLL_TIMEOUT_MS.key(), "100");
+        final KafkaPartitionSplitReader reader =
+                createReader(props, 
UnregisteredMetricsGroup.createSourceReaderMetricGroup());
+        assignSplit(reader, new KafkaPartitionSplit(tp, 0L));
+
+        // both checkpoints are taken after the record at offset 0 is emitted
+        final Map<TopicPartition, OffsetAndMetadata> snapshot =
+                Collections.singletonMap(tp, new OffsetAndMetadata(1L));
+
+        // checkpoint 1 is committed while the partition is idle after the 
commit marker
+        fetchUntil(
+                reader,
+                () -> 
Long.valueOf(2L).equals(reader.lastKnownPositions.get(tp)),
+                "The consumer did not move past the commit marker.");
+        commitOffsets(reader, snapshot, Collections.emptySet());
+        assertThat(getCommittedOffset(tp, groupId))
+                .as("The committed offset after checkpoint 1")
+                .isEqualTo(2L);
+
+        // offset 2 = record
+        // offset 3 = commit marker
+        produceRecordInTransaction(tp);
+
+        // checkpoint 2 is committed after the record at offset 2 is fetched, 
but not emitted
+        fetchUntil(
+                reader,
+                () -> 
Long.valueOf(2L).equals(reader.lastFetchedOffsets.get(tp)),
+                "The record at offset 2 was not fetched.");
+        commitOffsets(reader, snapshot, Collections.emptySet());
+        assertThat(getCommittedOffset(tp, groupId))
+                .as("The committed offset after checkpoint 2")
+                .isEqualTo(2L);
+    }
+
+    /**
+     * The consumer only completes a commit on a later poll, so a commit for a 
later checkpoint can
+     * be sent while an earlier one is still in flight.
+     */
+    @Test
+    void testCommitInFlightIsNotOvertakenByLowerOffset() throws Exception {
+        final String groupId = "CommitInFlightGroup";
+        final Properties props = new Properties();
+        props.setProperty(ConsumerConfig.GROUP_ID_CONFIG, groupId);
+        props.setProperty(KafkaSourceOptions.POLL_TIMEOUT_MS.key(), "100");
+        final KafkaPartitionSplitReader reader =
+                createReader(props, 
UnregisteredMetricsGroup.createSourceReaderMetricGroup());
+        final TopicPartition tp = new TopicPartition(TOPIC1, 0);
+        // not fetched, so there is no consumer position to reconcile the 
offsets with
+        assignSplit(reader, new KafkaPartitionSplit(tp, 
KafkaPartitionSplit.EARLIEST_OFFSET));
+
+        final CompletableFuture<Map<TopicPartition, OffsetAndMetadata>> first =
+                startCommit(
+                        reader,
+                        Collections.singletonMap(tp, new 
OffsetAndMetadata(5L)),
+                        Collections.emptySet());
+        assertThat(first).as("The first commit is still in 
flight").isNotDone();
+        final CompletableFuture<Map<TopicPartition, OffsetAndMetadata>> second 
=
+                startCommit(
+                        reader,
+                        Collections.singletonMap(tp, new 
OffsetAndMetadata(3L)),
+                        Collections.emptySet());
+        fetchUntil(
+                reader,
+                () -> first.isDone() && second.isDone(),
+                "The offset commits did not complete.");
+
+        assertThat(first.get()).containsExactly(entry(tp, new 
OffsetAndMetadata(5L)));
+        assertThat(second.get()).isEmpty();
+        assertThat(getCommittedOffset(tp, groupId)).isEqualTo(5L);
+    }
+
+    /**
+     * The consumer would otherwise only run the callback on a later poll, 
which on an idle
+     * partition is a poll timeout away.
+     */
+    @Test
+    void testCommitCompletesImmediatelyWhenThereIsNothingToCommit() {
+        final KafkaPartitionSplitReader reader = createReaderForCommits();
+        final TopicPartition tp = new TopicPartition(TOPIC3, 1);
+        // a bounded split is not committed at the consumer position, so 
nothing is committed
+        assignSplit(reader, new KafkaPartitionSplit(tp, 
KafkaPartitionSplit.EARLIEST_OFFSET, 5L));
+
+        assertThat(startCommit(reader, Collections.emptyMap(), 
Collections.singleton(tp)))
+                .isCompletedWithValue(Collections.emptyMap());
+    }
+
+    /**
+     * Partitions are only unassigned when their split finishes, which also 
removes their tracked
+     * consumer position. A stale position is injected here, so that the 
assignment is checked on
+     * its own.
+     */
+    @Test
+    void testOffsetsOfUnassignedPartitionsAreNotReconciled() throws Exception {
+        final KafkaPartitionSplitReader reader = createReaderForCommits();
+        // keeps the consumer assigned, so that it can poll for the commit to 
complete
+        assignSplit(
+                reader,
+                new KafkaPartitionSplit(
+                        new TopicPartition(TOPIC3, 0), 
KafkaPartitionSplit.EARLIEST_OFFSET));
+        final TopicPartition unassignedWithOffset = new TopicPartition(TOPIC1, 
0);
+        final TopicPartition unassignedWithoutOffset = new 
TopicPartition(TOPIC1, 1);
+        reader.lastKnownPositions.put(unassignedWithOffset, 5L);
+        reader.lastKnownPositions.put(unassignedWithoutOffset, 5L);
+
+        assertThat(
+                        commitOffsets(
+                                reader,
+                                Collections.singletonMap(
+                                        unassignedWithOffset, new 
OffsetAndMetadata(2L)),
+                                
Collections.singleton(unassignedWithoutOffset)))
+                .containsExactly(entry(unassignedWithOffset, new 
OffsetAndMetadata(2L)));
+        
assertThat(reader.lastCommittedOffsets).doesNotContainKey(unassignedWithOffset);
+    }
+
     // ------------------
 
     private void assignSplitsAndFetchUntilFinish(KafkaPartitionSplitReader 
reader, int readerId)
@@ -511,6 +737,95 @@ public class KafkaPartitionSplitReaderTest {
                 rackId);
     }
 
+    private KafkaPartitionSplitReader createReaderForCommits() {
+        final Properties props = new Properties();
+        props.setProperty(ConsumerConfig.GROUP_ID_CONFIG, 
"PartitionWithoutOffsetCommitGroup");
+        // the partitions under test have no records to deliver, so do not 
wait for them
+        props.setProperty(KafkaSourceOptions.POLL_TIMEOUT_MS.key(), "100");
+        return createReader(props, 
UnregisteredMetricsGroup.createSourceReaderMetricGroup());
+    }
+
+    private static void assignSplit(KafkaPartitionSplitReader reader, 
KafkaPartitionSplit split) {
+        reader.handleSplitsChanges(new 
SplitsAddition<>(Collections.singletonList(split)));
+    }
+
+    private static Map<TopicPartition, OffsetAndMetadata> 
commitPartitionsWithoutOffset(
+            KafkaPartitionSplitReader reader, TopicPartition tp) throws 
Exception {
+        return commitOffsets(reader, Collections.emptyMap(), 
Collections.singleton(tp));
+    }
+
+    /** Returns the offsets that the reader committed to Kafka for a completed 
checkpoint. */
+    private static Map<TopicPartition, OffsetAndMetadata> commitOffsets(
+            KafkaPartitionSplitReader reader,
+            Map<TopicPartition, OffsetAndMetadata> offsetsToCommit,
+            Set<TopicPartition> partitionsWithoutOffset)
+            throws Exception {
+        final CompletableFuture<Map<TopicPartition, OffsetAndMetadata>> 
committed =
+                startCommit(reader, offsetsToCommit, partitionsWithoutOffset);
+        // the consumer only invokes the callback of a commit sent to Kafka on 
a later poll
+        fetchUntil(reader, committed::isDone, "The offset commit did not 
complete.");
+        return committed.get();
+    }
+
+    /** Returns the offsets that the reader commits to Kafka, without polling 
for them. */
+    private static CompletableFuture<Map<TopicPartition, OffsetAndMetadata>> 
startCommit(
+            KafkaPartitionSplitReader reader,
+            Map<TopicPartition, OffsetAndMetadata> offsetsToCommit,
+            Set<TopicPartition> partitionsWithoutOffset) {
+        final CompletableFuture<Map<TopicPartition, OffsetAndMetadata>> 
committed =
+                new CompletableFuture<>();
+        reader.notifyCheckpointComplete(
+                offsetsToCommit,
+                partitionsWithoutOffset,
+                (offsets, e) -> {
+                    if (e != null) {
+                        committed.completeExceptionally(e);
+                    } else {
+                        committed.complete(offsets);
+                    }
+                });
+        return committed;
+    }
+
+    private static void fetchUntil(
+            KafkaPartitionSplitReader reader, Supplier<Boolean> condition, 
String errorMsg)
+            throws Exception {
+        waitUtil(
+                () -> {
+                    if (!condition.get()) {
+                        try {
+                            reader.fetch();
+                        } catch (IOException e) {
+                            throw new UncheckedIOException(e);
+                        }
+                    }
+                    return condition.get();
+                },
+                Duration.ofSeconds(30),
+                errorMsg);
+    }
+
+    private static long getCommittedOffset(TopicPartition tp, String groupId) 
throws Exception {
+        try (AdminClient adminClient = KafkaSourceTestEnv.getAdminClient()) {
+            final OffsetAndMetadata committed =
+                    adminClient
+                            .listConsumerGroupOffsets(groupId)
+                            .partitionsToOffsetAndMetadata()
+                            .get()
+                            .get(tp);
+            assertThat(committed).as("No offset was committed for %s", 
tp).isNotNull();
+            return committed.offset();
+        }
+    }
+
+    /** Writes a transaction with a single record, which takes two offsets 
with its marker. */
+    private static void produceRecordInTransaction(TopicPartition tp) throws 
Throwable {
+        KafkaSourceTestEnv.produceToKafka(
+                Collections.singletonList(
+                        new ProducerRecord<>(tp.topic(), tp.partition(), 
tp.toString(), 0)),
+                kafkaServer.getTransactionalProducerConfig());
+    }
+
     private Map<String, KafkaPartitionSplit> assignSplits(
             KafkaPartitionSplitReader reader, Map<String, KafkaPartitionSplit> 
splits) {
         SplitsChange<KafkaPartitionSplit> splitsChange =
diff --git 
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/source/reader/KafkaSourceReaderTest.java
 
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/source/reader/KafkaSourceReaderTest.java
index 9b5221f6..f756ef4a 100644
--- 
a/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/source/reader/KafkaSourceReaderTest.java
+++ 
b/flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/source/reader/KafkaSourceReaderTest.java
@@ -43,17 +43,27 @@ import 
org.apache.flink.runtime.metrics.groups.InternalSourceReaderMetricGroup;
 import org.apache.flink.util.function.SerializableSupplier;
 
 import org.apache.kafka.clients.admin.AdminClient;
+import org.apache.kafka.clients.admin.RecordsToDelete;
 import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.clients.consumer.KafkaConsumer;
 import org.apache.kafka.clients.consumer.OffsetAndMetadata;
+import org.apache.kafka.clients.producer.KafkaProducer;
+import org.apache.kafka.clients.producer.ProducerConfig;
 import org.apache.kafka.clients.producer.ProducerRecord;
 import org.apache.kafka.common.TopicPartition;
 import org.apache.kafka.common.serialization.IntegerDeserializer;
 import org.apache.kafka.common.serialization.IntegerSerializer;
+import org.apache.kafka.common.serialization.StringDeserializer;
 import org.apache.kafka.common.serialization.StringSerializer;
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.parallel.ResourceLock;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import javax.annotation.Nullable;
 
 import java.time.Duration;
 import java.util.ArrayList;
@@ -67,9 +77,12 @@ import java.util.Map;
 import java.util.Optional;
 import java.util.Properties;
 import java.util.Set;
+import java.util.UUID;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.Consumer;
 import java.util.function.Supplier;
+import java.util.stream.Stream;
 
 import static 
org.apache.flink.connector.kafka.source.metrics.KafkaSourceReaderMetrics.COMMITS_SUCCEEDED_METRIC_COUNTER;
 import static 
org.apache.flink.connector.kafka.source.metrics.KafkaSourceReaderMetrics.COMMITTED_OFFSET_METRIC_GAUGE;
@@ -81,7 +94,9 @@ import static 
org.apache.flink.connector.kafka.source.metrics.KafkaSourceReaderM
 import static 
org.apache.flink.connector.kafka.source.metrics.KafkaSourceReaderMetrics.TOPIC_GROUP;
 import static 
org.apache.flink.connector.kafka.testutils.KafkaSourceTestEnv.NUM_PARTITIONS;
 import static org.apache.flink.core.testutils.CommonTestUtils.waitUtil;
+import static 
org.apache.flink.streaming.connectors.kafka.KafkaTestBase.kafkaServer;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.entry;
 
 /** Unit tests for {@link KafkaSourceReader}. */
 @ResourceLock("KafkaTestBase")
@@ -240,6 +255,7 @@ public class KafkaSourceReaderTest extends 
SourceReaderTestBase<KafkaPartitionSp
                     () -> {
                         try {
                             reader.notifyCheckpointComplete(lastCheckpointId);
+                            reader.pollNext(output);
                         } catch (Exception exception) {
                             throw new RuntimeException(
                                     "Caught unexpected exception when polling 
from the reader",
@@ -266,17 +282,635 @@ public class KafkaSourceReaderTest extends 
SourceReaderTestBase<KafkaPartitionSp
         }
     }
 
+    /** Writes the records that a {@link #offsetConvergenceScenarios} scenario 
needs. */
+    @FunctionalInterface
+    private interface RecordProducer {
+        void produce(String topic) throws Throwable;
+    }
+
+    private static Stream<Arguments> offsetConvergenceScenarios() {
+        return Stream.of(
+                // no transaction and just one record
+                // offset 0 = record
+                // offset 1 << log end offset
+                Arguments.of(
+                        "NonTransactional",
+                        0L,
+                        1L,
+                        1,
+                        1,
+                        (RecordProducer)
+                                topic ->
+                                        KafkaSourceTestEnv.produceToKafka(
+                                                Collections.singletonList(
+                                                        new ProducerRecord<>(
+                                                                topic, 0, 
topic + "-key", 0)))),
+                // transaction containing one record and a Kafka commit marker
+                // offset 0 = record
+                // offset 1 = commit marker
+                // offset 2 << log end offset
+                Arguments.of(
+                        "Transactional",
+                        0L,
+                        2L,
+                        1,
+                        1,
+                        (RecordProducer)
+                                topic ->
+                                        KafkaSourceTestEnv.produceToKafka(
+                                                Collections.singletonList(
+                                                        new ProducerRecord<>(
+                                                                topic, 0, 
topic + "-key", 0)),
+                                                
kafkaServer.getTransactionalProducerConfig())),
+                // transaction containing three records
+                // offset 0 = record
+                // offset 1 = record
+                // offset 2 = record
+                // offset 3 = commit marker
+                // offset 4 << log end offset
+                Arguments.of(
+                        "MultiRecordTransaction",
+                        0L,
+                        4L,
+                        3,
+                        1,
+                        (RecordProducer) topic -> produceInTransaction(topic, 
0, 3, true)),
+                // committed transaction containing two records
+                // aborted transaction containing three records
+                // offset 0 = record
+                // offset 1 = record
+                // offset 2 = commit marker
+                // offset 3 = record
+                // offset 4 = record
+                // offset 5 = record
+                // offset 6 = abort marker
+                // offset 7 << log end offset
+                Arguments.of(
+                        "AbortedTransaction",
+                        0L,
+                        7L,
+                        2,
+                        1,
+                        (RecordProducer)
+                                topic -> {
+                                    produceInTransaction(topic, 0, 2, true);
+                                    produceInTransaction(topic, 0, 3, false);
+                                }),
+                // three separate transactions each containing one record
+                // offset 0 = record
+                // offset 1 = commit marker
+                // offset 2 = record
+                // offset 3 = commit marker
+                // offset 4 = record
+                // offset 5 = commit marker
+                // offset 6 << log end offset
+                Arguments.of(
+                        "MultipleTransactions",
+                        0L,
+                        6L,
+                        3,
+                        1,
+                        (RecordProducer)
+                                topic -> {
+                                    produceInTransaction(topic, 0, 1, true);
+                                    produceInTransaction(topic, 0, 1, true);
+                                    produceInTransaction(topic, 0, 1, true);
+                                }),
+                // two transactional producers interleaving records on the 
same partition before
+                // either commits, so the two commit markers do not 
immediately follow their own
+                // records
+                // offset 0 = producer A record
+                // offset 1 = producer B record
+                // offset 2 = producer A record
+                // offset 3 = producer B record
+                // offset 4 = producer A commit marker
+                // offset 5 = producer B commit marker
+                // offset 6 << log end offset
+                Arguments.of(
+                        "InterleavedTransactions",
+                        0L,
+                        6L,
+                        4,
+                        1,
+                        (RecordProducer) topic -> 
produceInterleavedTransactions(topic, 0, 2)),
+                // a single aborted transaction
+                // so the partition never delivers a record to the reader
+                // offset 0 = record
+                // offset 1 = record
+                // offset 2 = record
+                // offset 3 = abort marker
+                // offset 4 << log end offset
+                Arguments.of(
+                        "OnlyAbortedTransaction",
+                        0L,
+                        4L,
+                        0,
+                        2,
+                        (RecordProducer) topic -> produceInTransaction(topic, 
0, 3, false)),
+                // the same single aborted transaction, read from the earliest 
offset as
+                // OffsetsInitializer.earliest() does, so the split never 
holds a real offset
+                Arguments.of(
+                        "OnlyAbortedTransactionFromEarliest",
+                        KafkaPartitionSplit.EARLIEST_OFFSET,
+                        4L,
+                        0,
+                        2,
+                        (RecordProducer) topic -> produceInTransaction(topic, 
0, 3, false)));
+    }
+
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("offsetConvergenceScenarios")
+    void testCommittedOffsetConvergesWithLogEndOffset(
+            String scenario,
+            long startingOffset,
+            long expectedLastStableOffset,
+            int expectedReadableRecords,
+            int maxCheckpoints,
+            RecordProducer recordProducer)
+            throws Throwable {
+        final String topic = TOPIC + scenario;
+        final String groupId = scenario + "OffsetCommitGroup";
+        final TopicPartition tp = new TopicPartition(topic, 0);
+        KafkaSourceTestEnv.createTestTopic(topic, 1, 1);
+
+        recordProducer.produce(topic);
+
+        // assert state of Kafka
+        final long lastStableOffset = awaitLastStableOffset(tp, 
expectedLastStableOffset);
+        assertThat(getReadCommittedRecordsCount(tp))
+                .as("Number of readable records")
+                .isEqualTo(expectedReadableRecords);
+
+        // run KafkaSourceReader and check the last offset committed
+        final long committedOffset =
+                readAndCommitOffset(
+                        tp,
+                        startingOffset,
+                        groupId,
+                        expectedReadableRecords,
+                        maxCheckpoints,
+                        lastStableOffset,
+                        new Properties());
+        assertThat(committedOffset)
+                .as("The committed offset should converge with the log end 
offset")
+                .isEqualTo(lastStableOffset);
+    }
+
+    @Test
+    void testCommittedOffsetDoesNotOverrunRecordsInATransaction() throws 
Throwable {
+        final String topic = TOPIC + "PartialTransaction";
+        final String groupId = "PartialTransactionOffsetCommitGroup";
+        final TopicPartition tp = new TopicPartition(topic, 0);
+        KafkaSourceTestEnv.createTestTopic(topic, 1, 1);
+
+        // transaction containing three records
+        // offset 0 = record
+        // offset 1 = record
+        // offset 2 = record
+        // offset 3 = commit marker
+        // offset 4 << log end offset
+        produceInTransaction(topic, 0, 3, true);
+
+        // assert state of Kafka
+        awaitLastStableOffset(tp, 4L);
+        final int numReadableOffsets = getReadCommittedRecordsCount(tp);
+        assertThat(numReadableOffsets).as("Three records should be 
readable").isEqualTo(3);
+
+        // run KafkaSourceReader and check the last offset committed
+        final int recordsToRead = 2;
+        final long committedOffset =
+                readAndCommitOffset(tp, groupId, recordsToRead, 1, 
Long.MIN_VALUE);
+        assertThat(committedOffset)
+                .as("The committed offset should be the next offset after the 
two read records")
+                .isEqualTo(2);
+    }
+
+    /**
+     * Simulates a low-traffic bursty transactional topic, where a burst of 
records are followed by
+     * a commit marker. The result of this could be for a commit marker to be 
returned with no
+     * records in a poll. The intent of this test is to verify that the offset 
converges in such a
+     * scenario, and does not get stuck with an incorrect lag until more 
records arrive on the topic
+     * to allow an update.
+     */
+    @Test
+    void 
testCommittedOffsetConvergesOnIdlePartitionAfterTrailingCommitMarker() throws 
Throwable {
+        final String topic = TOPIC + "IdleAfterTrailingMarker";
+        final String groupId = "IdleAfterTrailingMarkerOffsetCommitGroup";
+        final TopicPartition tp = new TopicPartition(topic, 0);
+        KafkaSourceTestEnv.createTestTopic(topic, 1, 1);
+
+        // transaction containing two records
+        // offset 0 = record
+        // offset 1 = record
+        // offset 2 = commit marker
+        // offset 3 << log end offset
+        produceInTransaction(topic, 0, 2, true);
+
+        // assert state of Kafka
+        final long lastStableOffset = awaitLastStableOffset(tp, 3L);
+        assertThat(getReadCommittedRecordsCount(tp)).as("Number of readable 
records").isEqualTo(2);
+
+        // one record per poll, so that the commit marker is left to a poll of 
its own
+        final Properties oneRecordPerPoll = new Properties();
+        oneRecordPerPoll.setProperty(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 
"1");
+
+        // the poll that skips the commit marker is not the poll that delivers 
the last
+        // record, so allow for the first checkpoint being completed in 
between the two
+        final int maxCheckpoints = 2;
+
+        // run KafkaSourceReader and check the last offset committed
+        final long committedOffset =
+                readAndCommitOffset(
+                        tp, groupId, 2, maxCheckpoints, lastStableOffset, 
oneRecordPerPoll);
+        assertThat(committedOffset)
+                .as("The committed offset should converge with the log end 
offset")
+                .isEqualTo(lastStableOffset);
+    }
+
+    @SuppressWarnings("deprecation")
+    private static Stream<Arguments> emptyBoundedSplitScenarios() {
+        return Stream.of(
+                // the group has already committed past the split's stopping 
offset
+                Arguments.of(
+                        "CommittedStart", 
KafkaPartitionSplit.COMMITTED_OFFSET, 5L, 8L, null, 8L),
+                // retention has already removed the records before the 
split's stopping offset
+                Arguments.of(
+                        "EarliestStartAfterRetention",
+                        KafkaPartitionSplit.EARLIEST_OFFSET,
+                        3L,
+                        null,
+                        6L,
+                        null),
+                // a split restored from older state, which starts from the 
latest offset
+                Arguments.of("LatestStart", KafkaPartitionSplit.LATEST_OFFSET, 
5L, 8L, null, 8L),
+                // a split that starts from a concrete offset past its 
stopping offset
+                Arguments.of("ConcreteStart", 7L, 5L, null, null, 7L));
+    }
+
+    /**
+     * A split that is empty when it is assigned, because the consumer starts 
at or past its
+     * stopping offset, is finished without reading anything. Its stopping 
offset must not be
+     * committed, as the consumer never read up to it.
+     */
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("emptyBoundedSplitScenarios")
+    void testFinishedEmptySplitDoesNotCommitItsStoppingOffset(
+            String scenario,
+            long startingOffset,
+            long stoppingOffset,
+            @Nullable Long committedOffsetBefore,
+            @Nullable Long logStartOffset,
+            @Nullable Long expectedCommittedOffset)
+            throws Throwable {
+        final String topic = TOPIC + "EmptySplit" + scenario;
+        final String groupId = scenario + "EmptySplitOffsetCommitGroup";
+        final TopicPartition tp = new TopicPartition(topic, 0);
+        // second partition to keep the fetcher polling after the empty split 
finishes, so that
+        // the consumer completes the offset commit
+        final TopicPartition idleTp = new TopicPartition(topic, 1);
+        KafkaSourceTestEnv.createTestTopic(topic, 2, 1);
+
+        // offsets 0 - 9 = records
+        //     offset 10 = log end offset
+        final List<ProducerRecord<String, Integer>> records = new 
ArrayList<>();
+        for (int i = 0; i < 10; i++) {
+            records.add(new ProducerRecord<>(topic, 0, topic + "-key", i));
+        }
+        KafkaSourceTestEnv.produceToKafka(records);
+        try (AdminClient adminClient = KafkaSourceTestEnv.getAdminClient()) {
+            if (committedOffsetBefore != null) {
+                adminClient
+                        .alterConsumerGroupOffsets(
+                                groupId,
+                                Collections.singletonMap(
+                                        tp, new 
OffsetAndMetadata(committedOffsetBefore)))
+                        .all()
+                        .get();
+            }
+            if (logStartOffset != null) {
+                adminClient
+                        .deleteRecords(
+                                Collections.singletonMap(
+                                        tp, 
RecordsToDelete.beforeOffset(logStartOffset)))
+                        .all()
+                        .get();
+            }
+        }
+
+        final Properties props = new Properties();
+        props.setProperty(ConsumerConfig.GROUP_ID_CONFIG, groupId);
+        try (KafkaSourceReader<Integer> reader =
+                (KafkaSourceReader<Integer>)
+                        createReader(
+                                Boundedness.CONTINUOUS_UNBOUNDED,
+                                new TestingReaderContext(),
+                                (ignore) -> {},
+                                props,
+                                null)) {
+            reader.addSplits(
+                    Arrays.asList(
+                            new KafkaPartitionSplit(tp, startingOffset, 
stoppingOffset),
+                            new KafkaPartitionSplit(
+                                    idleTp, 0L, 
KafkaPartitionSplit.NO_STOPPING_OFFSET)));
+
+            final TestingReaderOutput<Integer> output = new 
TestingReaderOutput<>();
+            pollUntilSplitFinished(reader, output, tp);
+            assertThat(output.getEmittedRecords()).isEmpty();
+
+            completeCheckpoint(reader, output, 1L);
+            assertThat(
+                            Optional.ofNullable(findCommittedOffset(tp, 
groupId))
+                                    .map(OffsetAndMetadata::offset))
+                    .as("The offset committed for %s", tp)
+                    .isEqualTo(Optional.ofNullable(expectedCommittedOffset));
+        }
+    }
+
+    @Test
+    void testCommittedOffsetForBoundedSplitDoesNotGoBackwards() throws 
Throwable {
+        final String topic = TOPIC + "BoundedTrailingMarkers";
+        final String groupId = "BoundedTrailingMarkersOffsetCommitGroup";
+        final TopicPartition tp = new TopicPartition(topic, 0);
+        // second partition to keep the fetcher polling after the bounded 
split finishes
+        final TopicPartition idleTp = new TopicPartition(topic, 1);
+        KafkaSourceTestEnv.createTestTopic(topic, 2, 1);
+
+        // committed transaction containing two records
+        // offset 0 = record
+        // offset 1 = record
+        // offset 2 = commit marker
+        // offset 3 << log end offset
+        produceInTransaction(topic, 0, 2, true);
+        awaitLastStableOffset(tp, 3L);
+
+        final Properties props = new Properties();
+        props.setProperty(ConsumerConfig.GROUP_ID_CONFIG, groupId);
+        props.setProperty(ConsumerConfig.ISOLATION_LEVEL_CONFIG, 
"read_committed");
+
+        try (KafkaSourceReader<Integer> reader =
+                (KafkaSourceReader<Integer>)
+                        createReader(
+                                Boundedness.CONTINUOUS_UNBOUNDED,
+                                new TestingReaderContext(),
+                                (ignore) -> {},
+                                props,
+                                null)) {
+            reader.addSplits(
+                    Arrays.asList(
+                            new KafkaPartitionSplit(tp, 0L, 10L),
+                            new KafkaPartitionSplit(
+                                    idleTp, 0L, 
KafkaPartitionSplit.NO_STOPPING_OFFSET)));
+
+            final TestingReaderOutput<Integer> output = new 
TestingReaderOutput<>();
+            pollUntil(
+                    reader,
+                    output,
+                    () -> output.getEmittedRecords().size() == 2,
+                    "The reader did not emit all records before timeout.");
+
+            completeCheckpoint(reader, output, 1L);
+            final long offsetWhileRunning = getCommittedOffset(tp, groupId);
+            assertThat(offsetWhileRunning)
+                    .as(
+                            "The committed offset of a running bounded split, 
which is not advanced"
+                                    + " over the trailing commit marker")
+                    .isEqualTo(2L);
+
+            // aborted transaction that takes the partition up to the split's 
stopping offset
+            // offsets 3 - 8 = records
+            //     offset  9 = abort marker
+            //     offset 10 = log end offset
+            produceInTransaction(topic, 0, 6, false);
+            awaitLastStableOffset(tp, 10L);
+            pollUntilSplitFinished(reader, output, tp);
+
+            completeCheckpoint(reader, output, 2L);
+            final long offsetAfterFinishing = getCommittedOffset(tp, groupId);
+
+            assertThat(offsetAfterFinishing)
+                    .as("The committed offset of a finished bounded split is 
its stopping offset")
+                    .isEqualTo(10L);
+        }
+    }
+
+    /**
+     * Snapshots the reader and waits for the resulting offsets to reach 
Kafka, polling the reader
+     * throughout, for consistency with how the mailbox thread interleaves 
pollNext with checkpoint
+     * callbacks.
+     */
+    private void completeCheckpoint(
+            KafkaSourceReader<Integer> reader, ReaderOutput<Integer> output, 
long checkpointId)
+            throws Exception {
+        reader.snapshotState(checkpointId);
+        waitUtil(
+                () -> {
+                    try {
+                        reader.notifyCheckpointComplete(checkpointId);
+                        reader.pollNext(output);
+                    } catch (Exception exception) {
+                        throw new RuntimeException(
+                                "Unexpected exception while committing", 
exception);
+                    }
+                    return reader.getOffsetsToCommit().isEmpty();
+                },
+                Duration.ofSeconds(30),
+                Duration.ofMillis(500),
+                "Offset commit did not finish before timeout.");
+    }
+
+    /**
+     * Reads {@code expectedRecords} records from {@code tp} using a 
read_committed reader, and then
+     * completes checkpoints while the partition is idle until either the 
committed offset reaches
+     * {@code targetOffset} or {@code maxCheckpoints} have been completed. 
Returns the last offset
+     * that the reader committed to Kafka.
+     */
+    private long readAndCommitOffset(
+            TopicPartition tp,
+            String groupId,
+            int expectedRecords,
+            int maxCheckpoints,
+            long targetOffset)
+            throws Exception {
+        return readAndCommitOffset(
+                tp, groupId, expectedRecords, maxCheckpoints, targetOffset, 
new Properties());
+    }
+
+    private long readAndCommitOffset(
+            TopicPartition tp,
+            String groupId,
+            int expectedRecords,
+            int maxCheckpoints,
+            long targetOffset,
+            Properties extraConsumerProps)
+            throws Exception {
+        return readAndCommitOffset(
+                tp, 0L, groupId, expectedRecords, maxCheckpoints, 
targetOffset, extraConsumerProps);
+    }
+
+    private long readAndCommitOffset(
+            TopicPartition tp,
+            long startingOffset,
+            String groupId,
+            int expectedRecords,
+            int maxCheckpoints,
+            long targetOffset,
+            Properties extraConsumerProps)
+            throws Exception {
+        final Properties props = new Properties();
+        props.setProperty(ConsumerConfig.GROUP_ID_CONFIG, groupId);
+        props.setProperty(ConsumerConfig.ISOLATION_LEVEL_CONFIG, 
"read_committed");
+        props.putAll(extraConsumerProps);
+
+        final MetricListener metricListener = new MetricListener();
+
+        try (KafkaSourceReader<Integer> reader =
+                (KafkaSourceReader<Integer>)
+                        createReader(
+                                Boundedness.CONTINUOUS_UNBOUNDED,
+                                new TestingReaderContext(
+                                        new Configuration(),
+                                        InternalSourceReaderMetricGroup.mock(
+                                                
metricListener.getMetricGroup())),
+                                (ignore) -> {},
+                                props,
+                                null)) {
+            // prepare kafka reader
+            reader.addSplits(
+                    Collections.singletonList(
+                            new KafkaPartitionSplit(
+                                    tp, startingOffset, 
KafkaPartitionSplit.NO_STOPPING_OFFSET)));
+
+            // read until partition is idle
+            final TestingReaderOutput<Integer> output = new 
TestingReaderOutput<>();
+            pollUntil(
+                    reader,
+                    output,
+                    () -> output.getEmittedRecords().size() == expectedRecords,
+                    "The reader did not emit all records before timeout.");
+
+            // complete checkpoints to commit offset
+            long committedOffset = Long.MIN_VALUE;
+            for (int checkpointId = 1; checkpointId <= maxCheckpoints; 
checkpointId++) {
+                if (checkpointId > 1) {
+                    reader.pollNext(output);
+                }
+                completeCheckpoint(reader, output, checkpointId);
+                final OffsetAndMetadata committed = findCommittedOffset(tp, 
groupId);
+                if (committed == null && checkpointId < maxCheckpoints) {
+                    // a split that has not emitted a record is committed at 
the consumer
+                    // position, which is unknown until the split reader has 
polled the partition
+                    continue;
+                }
+                assertThat(committed).as("No offset was committed for %s", 
tp).isNotNull();
+                committedOffset = committed.offset();
+                assertThat(getCommittedOffsetMetric(tp, metricListener))
+                        .as("The committedOffsets metric should match the 
offset held by Kafka")
+                        .isEqualTo(committedOffset);
+                if (committedOffset == targetOffset) {
+                    break;
+                }
+            }
+            return committedOffset;
+        }
+    }
+
+    /**
+     * Writes {@code numRecords} records to a partition within a single 
transaction, and then
+     * commits or aborts the transaction before closing the producer.
+     */
+    private static void produceInTransaction(
+            String topic, int partition, int numRecords, boolean commit) {
+        try (KafkaProducer<String, Integer> producer = 
createTransactionalProducer()) {
+            producer.initTransactions();
+            producer.beginTransaction();
+            for (int i = 0; i < numRecords; i++) {
+                producer.send(new ProducerRecord<>(topic, partition, topic + 
"-key", i));
+            }
+            producer.flush();
+            if (commit) {
+                producer.commitTransaction();
+            } else {
+                producer.abortTransaction();
+            }
+        }
+    }
+
+    private static void produceInterleavedTransactions(
+            String topic, int partition, int numRecordsPerProducer) throws 
Exception {
+        try (KafkaProducer<String, Integer> producerA = 
createTransactionalProducer();
+                KafkaProducer<String, Integer> producerB = 
createTransactionalProducer()) {
+            producerA.initTransactions();
+            producerB.initTransactions();
+            producerA.beginTransaction();
+            producerB.beginTransaction();
+            for (int i = 0; i < numRecordsPerProducer; i++) {
+                producerA.send(new ProducerRecord<>(topic, partition, topic + 
"-key", i)).get();
+                producerB
+                        .send(new ProducerRecord<>(topic, partition, topic + 
"-key", 100 + i))
+                        .get();
+            }
+            producerA.commitTransaction();
+            producerB.commitTransaction();
+        }
+    }
+
+    private static KafkaProducer<String, Integer> 
createTransactionalProducer() {
+        final Properties props = new Properties();
+        props.putAll(KafkaSourceTestEnv.standardProps);
+        props.putAll(kafkaServer.getIdempotentProducerConfig());
+        props.putAll(kafkaServer.getTransactionalProducerConfig());
+        props.setProperty(
+                ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, 
StringSerializer.class.getName());
+        props.setProperty(
+                ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, 
IntegerSerializer.class.getName());
+        return new KafkaProducer<>(props);
+    }
+
     @Test
     void testNotCommitOffsetsForUninitializedSplits() throws Exception {
         final long checkpointId = 1234L;
         try (KafkaSourceReader<Integer> reader = (KafkaSourceReader<Integer>) 
createReader()) {
+            final TopicPartition tp = new TopicPartition(TOPIC, 0);
             KafkaPartitionSplit split =
-                    new KafkaPartitionSplit(
-                            new TopicPartition(TOPIC, 0), 
KafkaPartitionSplit.EARLIEST_OFFSET);
+                    new KafkaPartitionSplit(tp, 
KafkaPartitionSplit.EARLIEST_OFFSET);
             reader.addSplits(Collections.singletonList(split));
             reader.snapshotState(checkpointId);
             assertThat(reader.getOffsetsToCommit()).hasSize(1);
             
assertThat(reader.getOffsetsToCommit().get(checkpointId)).isEmpty();
+            // the split reader commits the consumer position for it instead
+            assertThat(reader.getPartitionsWithoutOffsetToCommit())
+                    .containsExactly(entry(checkpointId, 
Collections.singleton(tp)));
+        }
+    }
+
+    @Test
+    void testNoCommitIsCountedWhenThereIsNothingToCommit() throws Exception {
+        final MetricListener metricListener = new MetricListener();
+        final String groupId = "NothingToCommitGroup";
+        final TopicPartition tp = new TopicPartition(TOPIC, 0);
+        try (KafkaSourceReader<Integer> reader =
+                (KafkaSourceReader<Integer>)
+                        createReader(
+                                Boundedness.CONTINUOUS_UNBOUNDED,
+                                groupId,
+                                metricListener.getMetricGroup())) {
+            // the checkpoint is taken before the split emits a record, and a 
bounded split is not
+            // committed at the consumer position, so the commit has nothing 
in it
+            reader.addSplits(
+                    Collections.singletonList(
+                            new KafkaPartitionSplit(
+                                    tp,
+                                    KafkaPartitionSplit.EARLIEST_OFFSET,
+                                    NUM_RECORDS_PER_SPLIT)));
+            completeCheckpoint(reader, new TestingReaderOutput<>(), 1L);
+
+            assertThat(findCommittedOffset(tp, groupId)).isNull();
+            assertThat(
+                            metricListener.getCounter(
+                                    KAFKA_SOURCE_READER_METRIC_GROUP,
+                                    COMMITS_SUCCEEDED_METRIC_COUNTER))
+                    .hasValueSatisfying(counter -> 
assertThat(counter.getCount()).isZero());
+            assertThat(reader.getPartitionsWithoutOffsetToCommit()).isEmpty();
         }
     }
 
@@ -356,6 +990,7 @@ public class KafkaSourceReaderTest extends 
SourceReaderTestBase<KafkaPartitionSp
                     () -> {
                         try {
                             reader.notifyCheckpointComplete(checkpointId);
+                            reader.pollNext(output);
                         } catch (Exception e) {
                             throw new RuntimeException(
                                     "Failed to notify checkpoint complete to 
reader", e);
@@ -626,6 +1261,9 @@ public class KafkaSourceReaderTest extends 
SourceReaderTestBase<KafkaPartitionSp
                                 ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,
                                 KafkaSourceTestEnv.brokerConnectionStrings)
                         .setProperty(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, 
"false")
+                        // Offset commits run between polls, so a short poll 
timeout keeps each
+                        // commit on an idle split from introducing a ~10sec 
test pause
+                        .setProperty(KafkaSourceOptions.POLL_TIMEOUT_MS.key(), 
"100")
                         .setProperties(props);
         if (boundedness == Boundedness.BOUNDED) {
             builder.setBounded(OffsetsInitializer.latest());
@@ -659,6 +1297,23 @@ public class KafkaSourceReaderTest extends 
SourceReaderTestBase<KafkaPartitionSp
                 errorMessage);
     }
 
+    /**
+     * Polls the reader until it has handled the finishing of the split for 
{@code tp}. The
+     * split-finished hook is not enough, as it fires once the finished split 
has been queued for
+     * the reader, so this probes the reader's own state with a snapshot that 
is never committed.
+     */
+    private void pollUntilSplitFinished(
+            KafkaSourceReader<Integer> reader, ReaderOutput<Integer> output, 
TopicPartition tp)
+            throws Exception {
+        pollUntil(
+                reader,
+                output,
+                () ->
+                        reader.snapshotState(0L).stream()
+                                .noneMatch(split -> 
split.getTopicPartition().equals(tp)),
+                "The split did not finish before timeout.");
+    }
+
     private long getKafkaConsumerMetric(String name, MetricListener listener) {
         final Optional<Gauge<Object>> kafkaConsumerGauge =
                 listener.getGauge(
@@ -695,6 +1350,76 @@ public class KafkaSourceReaderTest extends 
SourceReaderTestBase<KafkaPartitionSp
 
     // ---------------------
 
+    private static KafkaConsumer<String, String> createReadCommittedProbe() {
+        final Properties props = new Properties();
+        props.putAll(KafkaSourceTestEnv.standardProps);
+        props.setProperty(ConsumerConfig.GROUP_ID_CONFIG, "read-probe-" + 
UUID.randomUUID());
+        props.setProperty(ConsumerConfig.ISOLATION_LEVEL_CONFIG, 
"read_committed");
+        props.setProperty(
+                ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, 
StringDeserializer.class.getName());
+        props.setProperty(
+                ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, 
StringDeserializer.class.getName());
+        return new KafkaConsumer<>(props);
+    }
+
+    private static int getReadCommittedRecordsCount(TopicPartition tp) throws 
Exception {
+        try (KafkaConsumer<String, String> probe = createReadCommittedProbe()) 
{
+            final List<TopicPartition> partitions = 
Collections.singletonList(tp);
+            probe.assign(partitions);
+            probe.seekToBeginning(partitions);
+            final long lastStableOffset = probe.endOffsets(partitions).get(tp);
+            final AtomicInteger count = new AtomicInteger();
+            waitUtil(
+                    () -> {
+                        
count.addAndGet(probe.poll(Duration.ofMillis(500)).records(tp).size());
+                        return probe.position(tp) >= lastStableOffset;
+                    },
+                    Duration.ofSeconds(30),
+                    String.format(
+                            "The probe did not reach the last stable offset %d 
of %s.",
+                            lastStableOffset, tp));
+            return count.get();
+        }
+    }
+
+    /**
+     * Waits until the last stable offset of {@code tp} reaches {@code 
expectedOffset}, and returns
+     * it. In most cases, this will return the expected value on the first 
check, but the
+     * transaction coordinator propagates it to partition leaders 
asynchronously, so LSO can briefly
+     * lag behind a committed transaction.
+     */
+    private static long awaitLastStableOffset(TopicPartition tp, long 
expectedOffset)
+            throws Exception {
+        try (KafkaConsumer<String, String> probe = createReadCommittedProbe()) 
{
+            final List<TopicPartition> partitions = 
Collections.singletonList(tp);
+            waitUtil(
+                    () -> probe.endOffsets(partitions).get(tp) == 
expectedOffset,
+                    Duration.ofSeconds(30),
+                    Duration.ofMillis(200),
+                    String.format(
+                            "The last stable offset of %s did not reach %d.", 
tp, expectedOffset));
+            return expectedOffset;
+        }
+    }
+
+    private static long getCommittedOffset(TopicPartition tp, String groupId) 
throws Exception {
+        final OffsetAndMetadata committed = findCommittedOffset(tp, groupId);
+        assertThat(committed).as("No offset was committed for %s", 
tp).isNotNull();
+        return committed.offset();
+    }
+
+    @Nullable
+    private static OffsetAndMetadata findCommittedOffset(TopicPartition tp, 
String groupId)
+            throws Exception {
+        try (AdminClient adminClient = KafkaSourceTestEnv.getAdminClient()) {
+            return adminClient
+                    .listConsumerGroupOffsets(groupId)
+                    .partitionsToOffsetAndMetadata()
+                    .get()
+                    .get(tp);
+        }
+    }
+
     private static List<ProducerRecord<String, Integer>> getRecords() {
         List<ProducerRecord<String, Integer>> records = new ArrayList<>();
         for (int part = 0; part < NUM_PARTITIONS; part++) {

Reply via email to