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

luoyuxia pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fluss.git


The following commit(s) were added to refs/heads/main by this push:
     new 0afc21a4b [client] Fix tiering hang on first_row merge engine empty 
batches (#3242)
0afc21a4b is described below

commit 0afc21a4bd094229c5943e3bb05a0db3286cc6d2
Author: Kaixuan Duan <[email protected]>
AuthorDate: Fri Jun 5 10:16:16 2026 +0800

    [client] Fix tiering hang on first_row merge engine empty batches (#3242)
---
 .../scanner/log/AbstractLogFetchCollector.java     |  15 ++-
 .../table/scanner/log/ArrowLogFetchCollector.java  |   6 +-
 .../table/scanner/log/LogFetchCollector.java       |   6 +-
 .../client/table/scanner/log/LogScannerImpl.java   |   9 +-
 .../client/table/scanner/log/ScanRecords.java      |  33 +++++-
 .../table/scanner/log/LogFetchCollectorTest.java   |   3 +
 .../client/table/scanner/log/LogFetcherITCase.java |   8 +-
 .../client/table/scanner/log/ScanRecordsTest.java  |  44 ++++++++
 .../flink/tiering/source/TieringSplitReader.java   | 120 +++++++++++++--------
 .../tiering/source/TieringSplitReaderTest.java     |  90 ++++++++++++++++
 10 files changed, 271 insertions(+), 63 deletions(-)

diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/AbstractLogFetchCollector.java
 
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/AbstractLogFetchCollector.java
index f4daf890e..e058e0e2d 100644
--- 
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/AbstractLogFetchCollector.java
+++ 
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/AbstractLogFetchCollector.java
@@ -65,7 +65,7 @@ abstract class AbstractLogFetchCollector<T, R> {
     /**
      * Return the fetched log records, empty the record buffer and update the 
consumed position.
      *
-     * <p>NOTE: returning empty records guarantees the consumed position are 
NOT updated.
+     * <p>NOTE: empty record lists may still advance the consumed position.
      *
      * @return The fetched records per partition
      * @throws FetchException If there is OffsetOutOfRange error in 
fetchResponse and the
@@ -73,6 +73,7 @@ abstract class AbstractLogFetchCollector<T, R> {
      */
     public R collectFetch(final LogFetchBuffer logFetchBuffer) {
         Map<TableBucket, List<T>> fetched = new HashMap<>();
+        Map<TableBucket, Long> consumedUpToOffsets = new HashMap<>();
         int recordsRemaining = maxPollRecords;
 
         try {
@@ -108,8 +109,11 @@ abstract class AbstractLogFetchCollector<T, R> {
                     logFetchBuffer.poll();
                 } else {
                     List<T> records = fetchRecords(nextInLineFetch, 
recordsRemaining);
+                    TableBucket tableBucket = nextInLineFetch.tableBucket;
+                    // Always record the advanced next fetch offset for this 
bucket, even when
+                    // the materialized record list is empty.
+                    consumedUpToOffsets.put(tableBucket, 
nextInLineFetch.nextFetchOffset());
                     if (!records.isEmpty()) {
-                        TableBucket tableBucket = nextInLineFetch.tableBucket;
                         List<T> currentRecords = fetched.get(tableBucket);
                         if (currentRecords == null) {
                             fetched.put(tableBucket, records);
@@ -126,6 +130,8 @@ abstract class AbstractLogFetchCollector<T, R> {
                         }
 
                         recordsRemaining -= recordCount(records);
+                    } else {
+                        fetched.putIfAbsent(tableBucket, 
Collections.emptyList());
                     }
                 }
             }
@@ -140,7 +146,7 @@ abstract class AbstractLogFetchCollector<T, R> {
             throw e;
         }
 
-        return toResult(fetched);
+        return toResult(fetched, consumedUpToOffsets);
     }
 
     /** Initialize a {@link CompletedFetch} object. */
@@ -293,7 +299,8 @@ abstract class AbstractLogFetchCollector<T, R> {
 
     protected abstract int recordCount(List<T> fetchedRecords);
 
-    protected abstract R toResult(Map<TableBucket, List<T>> fetchedRecords);
+    protected abstract R toResult(
+            Map<TableBucket, List<T>> fetchedRecords, Map<TableBucket, Long> 
consumedUpToOffsets);
 
     /**
      * Release resources held by fetched records on failure. The default 
implementation is a no-op,
diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/ArrowLogFetchCollector.java
 
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/ArrowLogFetchCollector.java
index 39b6aac02..4c6f7eab9 100644
--- 
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/ArrowLogFetchCollector.java
+++ 
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/ArrowLogFetchCollector.java
@@ -62,7 +62,11 @@ public class ArrowLogFetchCollector
     }
 
     @Override
-    protected ArrowScanRecords toResult(Map<TableBucket, List<ArrowBatchData>> 
fetchedRecords) {
+    protected ArrowScanRecords toResult(
+            Map<TableBucket, List<ArrowBatchData>> fetchedRecords,
+            Map<TableBucket, Long> consumedUpToOffsets) {
+        // Arrow scan paths don't need consumedUpToOffsets (issue #2371 is 
specific to
+        // row-based tiering), so it's discarded here rather than carried in 
ArrowScanRecords.
         return new ArrowScanRecords(fetchedRecords);
     }
 
diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetchCollector.java
 
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetchCollector.java
index bcf47c07f..799e74d2e 100644
--- 
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetchCollector.java
+++ 
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetchCollector.java
@@ -67,7 +67,9 @@ public class LogFetchCollector extends 
AbstractLogFetchCollector<ScanRecord, Sca
     }
 
     @Override
-    protected ScanRecords toResult(Map<TableBucket, List<ScanRecord>> 
fetchedRecords) {
-        return new ScanRecords(fetchedRecords);
+    protected ScanRecords toResult(
+            Map<TableBucket, List<ScanRecord>> fetchedRecords,
+            Map<TableBucket, Long> consumedUpToOffsets) {
+        return new ScanRecords(fetchedRecords, consumedUpToOffsets);
     }
 }
diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogScannerImpl.java
 
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogScannerImpl.java
index e7178f066..9e9316069 100644
--- 
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogScannerImpl.java
+++ 
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogScannerImpl.java
@@ -141,7 +141,11 @@ public class LogScannerImpl implements LogScanner {
 
     @Override
     public ScanRecords poll(Duration timeout) {
-        return doPoll(timeout, this::pollForFetches, ScanRecords::isEmpty, () 
-> ScanRecords.EMPTY);
+        return doPoll(
+                timeout,
+                this::pollForFetches,
+                scanRecords -> scanRecords.buckets().isEmpty(),
+                () -> ScanRecords.EMPTY);
     }
 
     /**
@@ -250,7 +254,8 @@ public class LogScannerImpl implements LogScanner {
 
     private ScanRecords pollForFetches() {
         ScanRecords scanRecords = logFetcher.collectFetch();
-        if (!scanRecords.isEmpty()) {
+        // Check buckets() (includes progress-only buckets).
+        if (!scanRecords.buckets().isEmpty()) {
             return scanRecords;
         }
 
diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/ScanRecords.java
 
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/ScanRecords.java
index 9d58c22b4..eb3e157d1 100644
--- 
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/ScanRecords.java
+++ 
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/ScanRecords.java
@@ -22,6 +22,8 @@ import org.apache.fluss.client.table.scanner.ScanRecord;
 import org.apache.fluss.metadata.TableBucket;
 import org.apache.fluss.utils.AbstractIterator;
 
+import javax.annotation.Nullable;
+
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
@@ -41,8 +43,18 @@ public class ScanRecords implements Iterable<ScanRecord> {
 
     private final Map<TableBucket, List<ScanRecord>> records;
 
+    /** The exclusive upper bound of consumed offsets per polled bucket in 
this round. */
+    private final Map<TableBucket, Long> consumedUpToOffsets;
+
     public ScanRecords(Map<TableBucket, List<ScanRecord>> records) {
+        this(records, Collections.emptyMap());
+    }
+
+    public ScanRecords(
+            Map<TableBucket, List<ScanRecord>> records,
+            Map<TableBucket, Long> consumedUpToOffsets) {
         this.records = records;
+        this.consumedUpToOffsets = consumedUpToOffsets;
     }
 
     /**
@@ -59,15 +71,25 @@ public class ScanRecords implements Iterable<ScanRecord> {
     }
 
     /**
-     * Get the bucket ids which have records contained in this record set.
-     *
-     * @return the set of partitions with data in this record set (maybe empty 
if no data was
-     *     returned)
+     * Get the bucket ids that were polled in this round, including buckets 
whose record list is
+     * empty but whose log offset still advanced.
      */
     public Set<TableBucket> buckets() {
         return Collections.unmodifiableSet(records.keySet());
     }
 
+    /**
+     * Get the exclusive upper bound of offsets consumed for the given bucket 
in this poll round.
+     *
+     * @param bucket the bucket to query
+     * @return the exclusive upper bound offset, or {@code null} if the bucket 
was not polled in
+     *     this round
+     */
+    @Nullable
+    public Long consumedUpToOffset(TableBucket bucket) {
+        return consumedUpToOffsets.get(bucket);
+    }
+
     /** The number of records for all buckets. */
     public int count() {
         int count = 0;
@@ -77,8 +99,9 @@ public class ScanRecords implements Iterable<ScanRecord> {
         return count;
     }
 
+    /** Returns {@code true} if this {@code ScanRecords} contains no 
materialized records. */
     public boolean isEmpty() {
-        return records.isEmpty();
+        return count() == 0;
     }
 
     @Override
diff --git 
a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetchCollectorTest.java
 
b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetchCollectorTest.java
index e769ebf4e..3b4d47fc1 100644
--- 
a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetchCollectorTest.java
+++ 
b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetchCollectorTest.java
@@ -258,6 +258,9 @@ public class LogFetchCollectorTest {
         assertThat(scanRecords.records(tb)).isEmpty();
         assertThat(logScannerStatus.getBucketOffset(tb)).isEqualTo(20L);
         assertThat(completedFetch.isConsumed()).isTrue();
+        // Empty record list, but bucket exposed via buckets() with an 
advanced consumedUpToOffset.
+        assertThat(scanRecords.buckets()).contains(tb);
+        assertThat(scanRecords.consumedUpToOffset(tb)).isEqualTo(20L);
     }
 
     private DefaultCompletedFetch makeCompletedFetch(
diff --git 
a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetcherITCase.java
 
b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetcherITCase.java
index 50addfcfe..147be7922 100644
--- 
a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetcherITCase.java
+++ 
b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogFetcherITCase.java
@@ -149,7 +149,10 @@ public class LogFetcherITCase extends 
ClientToServerITCaseBase {
                     
assertThat(logFetcher.getCompletedFetchesSize()).isEqualTo(2);
                 });
         ScanRecords records = logFetcher.collectFetch();
-        assertThat(records.buckets().size()).isEqualTo(1);
+        // Both polled buckets are exposed; tb1 was polled but produced no 
records.
+        TableBucket tb1 = new TableBucket(tableId, bucketId1);
+        assertThat(records.buckets()).containsExactlyInAnyOrder(tb0, tb1);
+        assertThat(records.records(tb1)).isEmpty();
         List<ScanRecord> scanRecords = records.records(tb0);
         
assertThat(scanRecords.stream().map(ScanRecord::getRow).collect(Collectors.toList()))
                 .isEqualTo(expectedRows);
@@ -195,7 +198,8 @@ public class LogFetcherITCase extends 
ClientToServerITCaseBase {
                     
assertThat(newSchemaLogFetcher.getCompletedFetchesSize()).isEqualTo(2);
                 });
         records = newSchemaLogFetcher.collectFetch();
-        assertThat(records.buckets().size()).isEqualTo(1);
+        assertThat(records.buckets()).containsExactlyInAnyOrder(tb0, tb1);
+        assertThat(records.records(tb1)).isEmpty();
         assertThat(records.records(tb0)).hasSize(20);
         scanRecords = records.records(tb0);
         
assertThat(scanRecords.stream().map(ScanRecord::getRow).collect(Collectors.toList()))
diff --git 
a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/ScanRecordsTest.java
 
b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/ScanRecordsTest.java
index db4a32667..49c6e9c42 100644
--- 
a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/ScanRecordsTest.java
+++ 
b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/ScanRecordsTest.java
@@ -25,6 +25,8 @@ import org.junit.jupiter.api.Test;
 
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -57,4 +59,46 @@ public class ScanRecordsTest {
         }
         assertThat(c).isEqualTo(4);
     }
+
+    /**
+     * Verifies buckets(), isEmpty(), and consumedUpToOffset() semantics for 
progress-only polls.
+     */
+    @Test
+    void bucketsAndIsEmptySemantics() {
+        TableBucket tb = new TableBucket(0L, 0);
+
+        // No records and no progress: both isEmpty() and buckets() must be 
empty.
+        ScanRecords trulyEmpty = ScanRecords.EMPTY;
+        assertThat(trulyEmpty.isEmpty()).isTrue();
+        assertThat(trulyEmpty.buckets()).isEmpty();
+
+        // Progress-only round: isEmpty() stays true (no materialized records),
+        // but buckets() exposes the advanced buckets and consumedUpToOffset 
carries the offset.
+        TableBucket emptyBucket = new TableBucket(0L, 1);
+        Map<TableBucket, List<ScanRecord>> progressRecords = new HashMap<>();
+        progressRecords.put(tb, Collections.emptyList());
+        progressRecords.put(emptyBucket, Collections.emptyList());
+        Map<TableBucket, Long> progressOffsets = new HashMap<>();
+        progressOffsets.put(tb, 42L);
+        progressOffsets.put(emptyBucket, 10L);
+        ScanRecords progressOnly = new ScanRecords(progressRecords, 
progressOffsets);
+        assertThat(progressOnly.isEmpty()).isTrue();
+        assertThat(progressOnly.buckets()).containsExactlyInAnyOrder(tb, 
emptyBucket);
+        assertThat(progressOnly.records(emptyBucket)).isEmpty();
+        assertThat(progressOnly.consumedUpToOffset(tb)).isEqualTo(42L);
+        
assertThat(progressOnly.consumedUpToOffset(emptyBucket)).isEqualTo(10L);
+        assertThat(progressOnly.consumedUpToOffset(new TableBucket(0L, 
99))).isNull();
+
+        // Materialized records present: isEmpty() flips to false;
+        // the legacy single-arg constructor has no consumedUpToOffset.
+        Map<TableBucket, List<ScanRecord>> matRecords = new HashMap<>();
+        matRecords.put(
+                tb,
+                Collections.singletonList(
+                        new ScanRecord(0L, 1000L, ChangeType.INSERT, row(1, 
"a"))));
+        ScanRecords withRecords = new ScanRecords(matRecords);
+        assertThat(withRecords.isEmpty()).isFalse();
+        assertThat(withRecords.buckets()).containsExactly(tb);
+        assertThat(withRecords.consumedUpToOffset(tb)).isNull();
+    }
 }
diff --git 
a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/TieringSplitReader.java
 
b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/TieringSplitReader.java
index d59787e15..335f5114c 100644
--- 
a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/TieringSplitReader.java
+++ 
b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/TieringSplitReader.java
@@ -355,63 +355,89 @@ public class TieringSplitReader<WriteResult>
         Map<TableBucket, TableBucketWriteResult<WriteResult>> writeResults = 
new HashMap<>();
         Map<TableBucket, String> finishedSplitIds = new HashMap<>();
 
+        // Iterate every polled bucket, including those that only advanced 
their offset.
         for (TableBucket bucket : scanRecords.buckets()) {
-            List<ScanRecord> bucketScanRecords = scanRecords.records(bucket);
-            if (bucketScanRecords.isEmpty()) {
-                continue;
-            }
-            // no any stopping offset, just skip handle the records for the 
bucket
             Long stoppingOffset = currentTableStoppingOffsets.get(bucket);
             if (stoppingOffset == null) {
                 continue;
             }
+
+            List<ScanRecord> records = scanRecords.records(bucket);
             LakeWriter<WriteResult> lakeWriter = null;
-            for (ScanRecord record : bucketScanRecords) {
-                // if record is less than stopping offset
-                if (record.logOffset() < stoppingOffset) {
-                    if (lakeWriter == null) {
-                        lakeWriter =
-                                getOrCreateLakeWriter(
-                                        bucket,
-                                        
currentTableSplitsByBucket.get(bucket).getPartitionName());
-                    }
-                    lakeWriter.write(record);
-                    if (record.getSizeInBytes() > 0) {
-                        
tieringMetrics.recordBytesRead(record.getSizeInBytes());
-                    }
+            ScanRecord lastRecord = null;
+
+            for (ScanRecord record : records) {
+                lastRecord = record;
+
+                // The scanner may return records beyond this split's 
exclusive stopping offset.
+                // Those records belong to the next split and must not be 
tiered here.
+                if (record.logOffset() >= stoppingOffset) {
+                    continue;
                 }
+
+                if (lakeWriter == null) {
+                    lakeWriter =
+                            getOrCreateLakeWriter(
+                                    bucket,
+                                    
currentTableSplitsByBucket.get(bucket).getPartitionName());
+                }
+                lakeWriter.write(record);
+                if (record.getSizeInBytes() > 0) {
+                    tieringMetrics.recordBytesRead(record.getSizeInBytes());
+                }
+            }
+
+            // consumedUpToOffset is an exclusive upper bound: all offsets 
before it have been
+            // consumed by the scanner in this poll round. It may advance even 
when records is
+            // empty, for example when FIRST_ROW filters duplicate upserts 
into empty WAL batches.
+            Long consumedUpToOffset = scanRecords.consumedUpToOffset(bucket);
+            checkState(
+                    consumedUpToOffset != null,
+                    "Missing consumed-up-to offset for polled bucket %s.",
+                    bucket);
+
+            // The split owns offsets before stoppingOffset only. If the 
scanner consumed past
+            // the split boundary, cap the tiered progress at stoppingOffset 
so the next split
+            // still owns later data.
+            long tieredLogEndOffset = Math.min(consumedUpToOffset, 
stoppingOffset);
+            long tieredTimestamp;
+            if (lastRecord != null) {
+                tieredTimestamp = lastRecord.timestamp();
+            } else {
+                LogOffsetAndTimestamp latest = 
currentTableTieredOffsetAndTimestamp.get(bucket);
+                tieredTimestamp = latest != null ? latest.timestamp : 
UNKNOWN_BUCKET_TIMESTAMP;
             }
-            ScanRecord lastRecord = 
bucketScanRecords.get(bucketScanRecords.size() - 1);
             currentTableTieredOffsetAndTimestamp.put(
-                    bucket,
-                    new LogOffsetAndTimestamp(lastRecord.logOffset(), 
lastRecord.timestamp()));
-            // has arrived into the end of the split,
-            if (lastRecord.logOffset() >= stoppingOffset - 1) {
-                currentTableStoppingOffsets.remove(bucket);
-                if (bucket.getPartitionId() != null) {
-                    currentLogScanner.unsubscribe(bucket.getPartitionId(), 
bucket.getBucket());
-                } else {
-                    // todo: should unsubscribe the log split if unsubscribe 
bucket for
-                    // un-partitioned table is supported
-                }
-                TieringSplit currentTieringSplit = 
currentTableSplitsByBucket.remove(bucket);
-                String currentSplitId = currentTieringSplit.splitId();
-                // put write result of the bucket
-                writeResults.put(
-                        bucket,
-                        completeLakeWriter(
-                                bucket,
-                                currentTieringSplit.getPartitionName(),
-                                stoppingOffset,
-                                lastRecord.timestamp()));
-                // put split of the bucket
-                finishedSplitIds.put(bucket, currentSplitId);
-                LOG.info(
-                        "Finish tier bucket {} for table {}, split: {}.",
-                        bucket,
-                        currentTablePath,
-                        currentSplitId);
+                    bucket, new LogOffsetAndTimestamp(tieredLogEndOffset - 1, 
tieredTimestamp));
+
+            // The split owns offsets below stoppingOffset. If the scanner has 
not consumed up to
+            // that exclusive bound yet, keep the split active.
+            if (consumedUpToOffset < stoppingOffset) {
+                continue;
             }
+
+            currentTableStoppingOffsets.remove(bucket);
+            if (bucket.getPartitionId() != null) {
+                currentLogScanner.unsubscribe(bucket.getPartitionId(), 
bucket.getBucket());
+            } else {
+                // todo: should unsubscribe the log split if unsubscribe 
bucket for
+                // un-partitioned table is supported
+            }
+            TieringSplit currentTieringSplit = 
currentTableSplitsByBucket.remove(bucket);
+            String currentSplitId = currentTieringSplit.splitId();
+            writeResults.put(
+                    bucket,
+                    completeLakeWriter(
+                            bucket,
+                            currentTieringSplit.getPartitionName(),
+                            stoppingOffset,
+                            tieredTimestamp));
+            finishedSplitIds.put(bucket, currentSplitId);
+            LOG.info(
+                    "Finish tier bucket {} for table {}, split: {}.",
+                    bucket,
+                    currentTablePath,
+                    currentSplitId);
         }
 
         if (!finishedSplitIds.isEmpty()) {
diff --git 
a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/tiering/source/TieringSplitReaderTest.java
 
b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/tiering/source/TieringSplitReaderTest.java
index 171f521e0..9aad5d1c3 100644
--- 
a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/tiering/source/TieringSplitReaderTest.java
+++ 
b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/tiering/source/TieringSplitReaderTest.java
@@ -24,6 +24,7 @@ import org.apache.fluss.client.table.writer.AppendWriter;
 import org.apache.fluss.client.table.writer.TableWriter;
 import org.apache.fluss.client.table.writer.UpsertWriter;
 import org.apache.fluss.client.write.HashBucketAssigner;
+import org.apache.fluss.config.ConfigOptions;
 import org.apache.fluss.flink.tiering.TestingLakeTieringFactory;
 import org.apache.fluss.flink.tiering.TestingWriteResult;
 import org.apache.fluss.flink.tiering.source.metrics.TieringMetrics;
@@ -33,12 +34,14 @@ import 
org.apache.fluss.flink.tiering.source.split.TieringSplit;
 import org.apache.fluss.flink.utils.FlinkTestBase;
 import org.apache.fluss.lake.writer.LakeWriter;
 import org.apache.fluss.lake.writer.WriterInitContext;
+import org.apache.fluss.metadata.MergeEngineType;
 import org.apache.fluss.metadata.TableBucket;
 import org.apache.fluss.metadata.TableDescriptor;
 import org.apache.fluss.metadata.TablePath;
 import org.apache.fluss.record.LogRecord;
 import org.apache.fluss.row.InternalRow;
 import org.apache.fluss.row.encode.CompactedKeyEncoder;
+import org.apache.fluss.server.replica.Replica;
 
 import org.apache.flink.api.connector.source.SourceSplit;
 import org.apache.flink.connector.base.source.reader.RecordsWithSplitIds;
@@ -335,6 +338,93 @@ class TieringSplitReaderTest extends FlinkTestBase {
         }
     }
 
+    /**
+     * Verifies that the tiering service finishes under {@code first_row} 
merge engine even when
+     * duplicate upserts produce empty WAL batches.
+     */
+    @Test
+    void testTieringFirstRowMergeEngineFinishes() throws Exception {
+        TablePath tablePath = TablePath.of("fluss", 
"tiering_first_row_finish");
+        TableDescriptor descriptor =
+                TableDescriptor.builder()
+                        .schema(DEFAULT_PK_TABLE_SCHEMA)
+                        .distributedBy(DEFAULT_BUCKET_NUM, "id")
+                        .property(ConfigOptions.TABLE_MERGE_ENGINE, 
MergeEngineType.FIRST_ROW)
+                        .build();
+        long tableId = createTable(tablePath, descriptor);
+
+        // Duplicate upserts under FIRST_ROW: only the first per id yields a 
CDC
+        // record, the rest become empty WAL batches that still advance the 
offset.
+        int distinctKeys = 5;
+        int duplicatesPerKey = 10;
+        try (Table table = conn.getTable(tablePath)) {
+            for (int round = 0; round < duplicatesPerKey; round++) {
+                UpsertWriter writer = table.newUpsert().createWriter();
+                for (int id = 0; id < distinctKeys; id++) {
+                    writer.upsert(row(id, "v" + round));
+                }
+                writer.flush();
+            }
+        }
+
+        // Build log splits whose stoppingOffset equals the leader's current 
logEndOffset.
+        List<TieringSplit> logSplits = new ArrayList<>();
+        Set<String> splitIds = new HashSet<>();
+        long totalLogEndOffset = 0L;
+        for (int bucket = 0; bucket < DEFAULT_BUCKET_NUM; bucket++) {
+            TableBucket tb = new TableBucket(tableId, bucket);
+            Replica leader = 
FLUSS_CLUSTER_EXTENSION.waitAndGetLeaderReplica(tb);
+            long stoppingOffset = leader.getLogTablet().localLogEndOffset();
+            totalLogEndOffset += stoppingOffset;
+            if (stoppingOffset <= 0) {
+                continue;
+            }
+            TieringLogSplit split =
+                    createLogSplit(tablePath, tableId, bucket, 
EARLIEST_OFFSET, stoppingOffset);
+            logSplits.add(split);
+            splitIds.add(split.splitId());
+        }
+        assertThat(logSplits).isNotEmpty();
+        // Pre-condition: total log offsets must exceed distinct-key count, 
otherwise
+        // no empty batch was produced.
+        assertThat(totalLogEndOffset)
+                .as(
+                        "Expected logEndOffset (%d) to exceed distinctKeys 
(%d) so that "
+                                + "empty batches are produced under FIRST_ROW",
+                        totalLogEndOffset, distinctKeys)
+                .isGreaterThan(distinctKeys);
+
+        try (Connection connection =
+                        ConnectionFactory.createConnection(
+                                FLUSS_CLUSTER_EXTENSION.getClientConfig());
+                TieringSplitReader<TestingWriteResult> tieringSplitReader =
+                        createTieringReader(connection)) {
+            tieringSplitReader.handleSplitsChanges(new 
SplitsAddition<>(logSplits));
+
+            // With the fix every split must finish within a few fetch rounds.
+            Set<String> finished = new HashSet<>();
+            int maxRounds = 10;
+            for (int i = 0; i < maxRounds && !finished.containsAll(splitIds); 
i++) {
+                
RecordsWithSplitIds<TableBucketWriteResult<TestingWriteResult>> fetchResult =
+                        tieringSplitReader.fetch();
+                finished.addAll(fetchResult.finishedSplits());
+                // drain the iterator so that the reader advances internal 
state
+                while (fetchResult.nextSplit() != null) {
+                    while (fetchResult.nextRecordFromSplit() != null) {
+                        // consume
+                    }
+                }
+            }
+
+            assertThat(finished)
+                    .as(
+                            "All tiering splits must finish under FIRST_ROW 
merge engine "
+                                    + "with duplicate keys. Finished: %s, 
expected: %s",
+                            finished, splitIds)
+                    .containsAll(splitIds);
+        }
+    }
+
     private TieringSplitReader<TestingWriteResult> 
createTieringReader(Connection connection) {
         final TieringMetrics tieringMetrics =
                 new TieringMetrics(

Reply via email to