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 52c16789b [client] Fix data hole in primary key table batch read
(#3627)
52c16789b is described below
commit 52c16789b8b3676e4b467eebe8aee53ca2bd3094
Author: Junbo Wang <[email protected]>
AuthorDate: Sat Jul 11 10:50:17 2026 +0800
[client] Fix data hole in primary key table batch read (#3627)
---
.../client/table/scanner/SortMergeReader.java | 388 +++++++--------------
.../client/table/scanner/SortMergeReaderTest.java | 37 ++
.../fluss/spark/SparkPrimaryKeyTableReadTest.scala | 29 ++
3 files changed, 195 insertions(+), 259 deletions(-)
diff --git
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/SortMergeReader.java
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/SortMergeReader.java
index 088981090..f48a9c28d 100644
---
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/SortMergeReader.java
+++
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/SortMergeReader.java
@@ -26,31 +26,33 @@ import org.apache.fluss.utils.CloseableIterator;
import javax.annotation.Nullable;
-import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.PriorityQueue;
-import java.util.function.Function;
/**
- * A sort merge reader to merge snapshot record and change log. Both of them
should have the same
- * primary key encoding when comparing the keys.
+ * A sort merge reader that merges snapshot records and change log records
into a single stream
+ * sorted by primary key. Both inputs must share the same primary key encoding.
+ *
+ * <p>The merge is driven by a single {@link MergeIterator} (a two-way merge).
For the same key the
+ * change log wins over the snapshot: an update overrides the value, a delete
removes the row. Using
+ * one iterator keeps the result identical regardless of how many times {@link
#readBatch()} is
+ * called, so trailing change log records (keys greater than the max snapshot
key) are never
+ * dropped.
*/
public class SortMergeReader {
private final ProjectedRow snapshotProjectedPkRow;
private final CloseableIterator<LogRecord> snapshotRecordIterator;
private final Comparator<InternalRow> userKeyComparator;
- private CloseableIterator<KeyValueRow> changeLogIterator;
-
- private final SnapshotMergedRowIteratorWrapper
snapshotMergedRowIteratorWrapper;
-
- private final ChangeLogIteratorWrapper changeLogIteratorWrapper;
+ private final CloseableIterator<KeyValueRow> changeLogIterator;
private @Nullable final ProjectedRow projectedRow;
+ private @Nullable MergeIterator mergeIterator;
+
public SortMergeReader(
@Nullable int[] projectedFields,
int[] pkIndexes,
@@ -78,23 +80,129 @@ public class SortMergeReader {
this.snapshotRecordIterator =
ConcatRecordIterator.wrap(snapshotRecordIterators,
userKeyComparator, pkIndexes);
this.changeLogIterator = changeLogIterator;
- this.changeLogIteratorWrapper = new ChangeLogIteratorWrapper();
- this.snapshotMergedRowIteratorWrapper = new
SnapshotMergedRowIteratorWrapper();
- // to project to fields provided by user
this.projectedRow = projectedFields == null ? null :
ProjectedRow.from(projectedFields);
}
+ /**
+ * Returns the merged-row iterator, or {@code null} when nothing is left.
The same instance is
+ * returned on every call, so repeated invocations keep draining the
snapshot and change log.
+ */
@Nullable
public CloseableIterator<InternalRow> readBatch() {
- if (!snapshotRecordIterator.hasNext()) {
- return changeLogIterator.hasNext()
- ? changeLogIteratorWrapper.replace(changeLogIterator)
- : null;
- } else {
- CloseableIterator<SortMergeRows> mergedRecordIterator =
- transform(snapshotRecordIterator,
this::sortMergeWithChangeLog);
-
- return
snapshotMergedRowIteratorWrapper.replace(mergedRecordIterator);
+ if (mergeIterator == null) {
+ mergeIterator = new MergeIterator();
+ }
+ return mergeIterator.hasNext() ? mergeIterator : null;
+ }
+
+ /** Two-way merge iterator over the snapshot and change log streams. */
+ private class MergeIterator implements CloseableIterator<InternalRow> {
+
+ // peeked head of each stream; null means not peeked yet or drained
+ private @Nullable LogRecord pendingSnapshot;
+ private @Nullable KeyValueRow pendingLog;
+
+ // next row to return (before projection) and whether it has been
computed
+ private @Nullable InternalRow nextRow;
+ private boolean nextComputed;
+
+ private @Nullable LogRecord peekSnapshot() {
+ if (pendingSnapshot == null && snapshotRecordIterator.hasNext()) {
+ pendingSnapshot = snapshotRecordIterator.next();
+ }
+ return pendingSnapshot;
+ }
+
+ private @Nullable KeyValueRow peekLog() {
+ if (pendingLog == null && changeLogIterator.hasNext()) {
+ pendingLog = changeLogIterator.next();
+ }
+ return pendingLog;
+ }
+
+ @Override
+ public boolean hasNext() {
+ if (!nextComputed) {
+ nextRow = advance();
+ nextComputed = true;
+ }
+ return nextRow != null;
+ }
+
+ @Override
+ public InternalRow next() {
+ if (!hasNext()) {
+ throw new NoSuchElementException();
+ }
+ InternalRow row = nextRow;
+ nextRow = null;
+ nextComputed = false;
+ return projectedRow == null ? row : projectedRow.replaceRow(row);
+ }
+
+ /** Advances to the next merged row, or {@code null} when both streams
are drained. */
+ private @Nullable InternalRow advance() {
+ while (true) {
+ LogRecord snapshot = peekSnapshot();
+ KeyValueRow log = peekLog();
+
+ if (snapshot == null && log == null) {
+ return null;
+ }
+
+ // only the change log remains
+ if (snapshot == null) {
+ InternalRow row = takeLog(log);
+ if (row != null) {
+ return row;
+ }
+ continue;
+ }
+
+ InternalRow snapshotRow = snapshot.getRow();
+
+ // only the snapshot remains
+ if (log == null) {
+ pendingSnapshot = null;
+ return snapshotRow;
+ }
+
+ int compareResult =
+ userKeyComparator.compare(
+
snapshotProjectedPkRow.replaceRow(snapshotRow), log.keyRow());
+ if (compareResult < 0) {
+ pendingSnapshot = null;
+ return snapshotRow;
+ } else if (compareResult > 0) {
+ InternalRow row = takeLog(log);
+ if (row != null) {
+ return row;
+ }
+ continue;
+ } else {
+ // same key: change log overrides snapshot, delete drops
both
+ pendingSnapshot = null;
+ InternalRow row = takeLog(log);
+ if (row != null) {
+ return row;
+ }
+ continue;
+ }
+ }
+ }
+
+ /**
+ * Consumes the change log head; returns its value row, or {@code
null} if it is a delete.
+ */
+ private @Nullable InternalRow takeLog(KeyValueRow log) {
+ pendingLog = null;
+ return log.isDelete() ? null : log.valueRow();
+ }
+
+ @Override
+ public void close() {
+ snapshotRecordIterator.close();
+ changeLogIterator.close();
}
}
@@ -168,242 +276,4 @@ public class SortMergeReader {
return priorityQueue.peek().next();
}
}
-
- private SortMergeRows sortMergeWithChangeLog(InternalRow lakeSnapshotRow) {
- // no log record, we return the snapshot record
- if (!changeLogIterator.hasNext()) {
- return new SortMergeRows(lakeSnapshotRow);
- }
- KeyValueRow logKeyValueRow = changeLogIterator.next();
- // now, let's compare with the snapshot row with log row
- int compareResult =
- userKeyComparator.compare(
- snapshotProjectedPkRow.replaceRow(lakeSnapshotRow),
- logKeyValueRow.keyRow());
- if (compareResult == 0) {
- // record of snapshot is equal to log, but the log record is
delete,
- // we shouldn't emit record
- if (logKeyValueRow.isDelete()) {
- return SortMergeRows.EMPTY;
- } else {
- // return the log record
- return new SortMergeRows(logKeyValueRow.valueRow());
- }
- }
-
- // the snapshot record is less than the log record, emit the
- // snapshot record
- if (compareResult < 0) {
- // need to put back the log record to log iterator to make the log
record
- // can be advanced again
- changeLogIterator =
- SingleElementHeadIterator.addElementToHead(logKeyValueRow,
changeLogIterator);
- return new SortMergeRows(lakeSnapshotRow);
- } else {
- // snapshot record > log record
- // we should emit the log record firsts; and still need to
iterator changelog to find
- // the first change log greater than the snapshot record
- List<InternalRow> emitRows = new ArrayList<>();
- // only emit the log record if it's not a delete operation
- if (!logKeyValueRow.isDelete()) {
- emitRows.add(logKeyValueRow.valueRow());
- }
- boolean shouldEmitSnapshotRecord = true;
- while (changeLogIterator.hasNext()) {
- // get the next log record
- logKeyValueRow = changeLogIterator.next();
- // compare with the snapshot row,
- compareResult =
- userKeyComparator.compare(
-
snapshotProjectedPkRow.replaceRow(lakeSnapshotRow),
- logKeyValueRow.keyRow());
- // if snapshot record < the log record
- if (compareResult < 0) {
- // we can break the loop
- changeLogIterator =
- SingleElementHeadIterator.addElementToHead(
- logKeyValueRow, changeLogIterator);
- break;
- } else if (compareResult > 0) {
- // snapshot record > the log record
- // the log record should be emitted
- if (!logKeyValueRow.isDelete()) {
- emitRows.add(logKeyValueRow.valueRow());
- }
- } else {
- // log record == snapshot record
- // the log record should be emitted if is not delete, but
the snapshot record
- // shouldn't be emitted
- if (!logKeyValueRow.isDelete()) {
- emitRows.add(logKeyValueRow.valueRow());
- }
- shouldEmitSnapshotRecord = false;
- }
- }
-
- if (shouldEmitSnapshotRecord) {
- emitRows.add(lakeSnapshotRow);
- }
- return new SortMergeRows(emitRows);
- }
- }
-
- private static class ChangeLogIteratorWrapper implements
CloseableIterator<InternalRow> {
- private CloseableIterator<KeyValueRow> changeLogRecordIterator;
- private KeyValueRow nextReturnRow;
-
- public ChangeLogIteratorWrapper() {}
-
- public ChangeLogIteratorWrapper replace(
- CloseableIterator<KeyValueRow> changeLogRecordIterator) {
- this.changeLogRecordIterator = changeLogRecordIterator;
- this.nextReturnRow = null;
- return this;
- }
-
- @Override
- public void close() {
- if (changeLogRecordIterator != null) {
- changeLogRecordIterator.close();
- }
- }
-
- @Override
- public boolean hasNext() {
- if (nextReturnRow != null) {
- return true;
- }
- while (changeLogRecordIterator != null &&
changeLogRecordIterator.hasNext()) {
- KeyValueRow row = changeLogRecordIterator.next();
- if (!row.isDelete()) {
- nextReturnRow = row;
- return true;
- }
- }
- return false;
- }
-
- @Override
- public InternalRow next() {
- if (nextReturnRow == null) {
- throw new NoSuchElementException();
- }
- KeyValueRow row = nextReturnRow;
- nextReturnRow = null; // Clear cache after consuming
- return row.valueRow();
- }
- }
-
- private class SnapshotMergedRowIteratorWrapper implements
CloseableIterator<InternalRow> {
- private CloseableIterator<SortMergeRows> currentLakeSnapshotRecords;
-
- private @Nullable Iterator<InternalRow> currentMergedRows;
-
- // the row to be returned
- private @Nullable InternalRow returnedRow;
-
- public SnapshotMergedRowIteratorWrapper replace(
- CloseableIterator<SortMergeRows> currentLakeSnapshotRecords) {
- this.currentLakeSnapshotRecords = currentLakeSnapshotRecords;
- this.returnedRow = null;
- this.currentMergedRows = null;
- return this;
- }
-
- @Override
- public void close() {
- currentLakeSnapshotRecords.close();
- }
-
- @Override
- public boolean hasNext() {
- if (returnedRow != null) {
- return true;
- }
- try {
- // Loop to skip empty merge results (e.g., when a snapshot
record is
- // deleted by changelog) and advance to the next non-empty
result.
- while (returnedRow == null) {
- if (currentMergedRows == null) {
- if (!currentLakeSnapshotRecords.hasNext()) {
- return false;
- }
-
- SortMergeRows sortMergeRows =
currentLakeSnapshotRecords.next();
- if (!sortMergeRows.mergedRows.isEmpty()) {
- currentMergedRows =
sortMergeRows.mergedRows.iterator();
- } else {
- // If mergedRows is empty (e.g., record was
deleted), continue
- // the loop to try the next snapshot record.
- continue;
- }
- }
-
- if (currentMergedRows.hasNext()) {
- returnedRow = currentMergedRows.next();
- } else {
- currentMergedRows = null;
- }
- }
- return true;
- } catch (Exception e) {
- throw new RuntimeException(e);
- }
- }
-
- @Override
- public InternalRow next() {
- InternalRow returnedRow =
- projectedRow == null
- ? this.returnedRow
- : projectedRow.replaceRow(this.returnedRow);
- // now, we can set the internalRow to null,
- // if no any row remain in current merged row, set the
currentMergedRows to null
- // to enable fetch next merged rows
- this.returnedRow = null;
- if (currentMergedRows != null && !currentMergedRows.hasNext()) {
- currentMergedRows = null;
- }
- return returnedRow;
- }
- }
-
- private static class SortMergeRows {
- private static final SortMergeRows EMPTY = new
SortMergeRows(Collections.emptyList());
-
- // the rows merge with change log, one snapshot row may advance
multiple change log
- private final List<InternalRow> mergedRows;
-
- public SortMergeRows(List<InternalRow> mergedRows) {
- this.mergedRows = mergedRows;
- }
-
- public SortMergeRows(InternalRow internalRow) {
- this.mergedRows = Collections.singletonList(internalRow);
- }
- }
-
- private <R> CloseableIterator<R> transform(
- CloseableIterator<LogRecord> originElementIterator,
- final Function<InternalRow, R> function) {
- return new CloseableIterator<R>() {
- private final CloseableIterator<LogRecord> inner =
originElementIterator;
-
- @Override
- public void close() {
- inner.close();
- }
-
- @Override
- public boolean hasNext() {
- return inner.hasNext();
- }
-
- @Override
- public R next() {
- LogRecord element = inner.next();
- return function.apply(element.getRow());
- }
- };
- }
}
diff --git
a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/SortMergeReaderTest.java
b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/SortMergeReaderTest.java
index 5ce97d5f6..4bffb250b 100644
---
a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/SortMergeReaderTest.java
+++
b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/SortMergeReaderTest.java
@@ -137,6 +137,43 @@ class SortMergeReaderTest {
.containsExactly(0, 1, 3, 4, 6, 7, 9);
}
+ @Test
+ void testReadBatchEmitsChangeLogBeyondSnapshotMaxKey() {
+ int keyIndex = 0;
+ int[] pkIndexes = new int[] {keyIndex};
+
+ // snapshot keys: 0, 1, 2
+ List<LogRecord> snapshotRecords = createRecords(0, 3, false);
+ // change log keys: 3, 4, 5, all inserts whose keys are greater than
the max
+ // snapshot key (2). This simulates a pk table with datalake tiering
where the
+ // newly-written rows (larger seq ids) are still in the Fluss log tail
while the
+ // older rows have been tiered into the lake snapshot.
+ List<KeyValueRow> changeLogRecords =
+ createRecords(3, 3, true).stream()
+ .map(logRecord -> new KeyValueRow(pkIndexes,
logRecord.getRow(), false))
+ .collect(Collectors.toList());
+
+ SortMergeReader sortMergeReader =
+ new SortMergeReader(
+ null,
+ pkIndexes,
+ CloseableIterator.wrap(snapshotRecords.iterator()),
+ new FlussRowComparator(keyIndex),
+ CloseableIterator.wrap(changeLogRecords.iterator()));
+
+ InternalRow.FieldGetter[] fieldGetters =
+ InternalRow.createFieldGetters(
+ RowType.of(new IntType(), new StringType(), new
StringType()));
+ List<InternalRow> actualRows;
+ try (CloseableIterator<InternalRow> iterator =
sortMergeReader.readBatch()) {
+ actualRows = materializeRows(iterator, fieldGetters);
+ }
+
+ // The change-log rows beyond the max snapshot key must not be dropped.
+ assertThat(actualRows.stream().map(row ->
row.getInt(0)).collect(Collectors.toList()))
+ .containsExactly(0, 1, 2, 3, 4, 5);
+ }
+
private CloseableIterator<InternalRow> projected(
CloseableIterator<LogRecord> originElementIterator, final
ProjectedRow projectedRow) {
return new CloseableIterator<InternalRow>() {
diff --git
a/fluss-spark/fluss-spark-ut/src/test/scala/org/apache/fluss/spark/SparkPrimaryKeyTableReadTest.scala
b/fluss-spark/fluss-spark-ut/src/test/scala/org/apache/fluss/spark/SparkPrimaryKeyTableReadTest.scala
index 4e30d552d..823521a18 100644
---
a/fluss-spark/fluss-spark-ut/src/test/scala/org/apache/fluss/spark/SparkPrimaryKeyTableReadTest.scala
+++
b/fluss-spark/fluss-spark-ut/src/test/scala/org/apache/fluss/spark/SparkPrimaryKeyTableReadTest.scala
@@ -519,6 +519,35 @@ class SparkPrimaryKeyTableReadTest extends
FlussSparkTestBase {
}
}
+ test("Spark Read: primary key table batch read has no data hole with
monotonic keys") {
+ withTable("fluss_fault_test_pk") {
+ val tablePath = createTablePath("fluss_fault_test_pk")
+ sql(s"""
+ |CREATE TABLE $DEFAULT_DATABASE.fluss_fault_test_pk (seq_id
BIGINT, payload STRING)
+ |TBLPROPERTIES("primary.key" = "seq_id", "bucket.num" = 1)
+ |""".stripMargin)
+
+ // First batch: seq_id 1..100, materialized into a kv snapshot.
+ val firstBatch = (1 to 100).map(i => s"($i, 'v$i')").mkString(", ")
+ sql(s"INSERT INTO $DEFAULT_DATABASE.fluss_fault_test_pk VALUES
$firstBatch")
+ flussServer.triggerAndWaitSnapshot(tablePath)
+
+ // Second batch: seq_id 101..200, only present in the log tail. All keys
are
+ // strictly greater than the max key in the snapshot, mimicking a
datagen job
+ // that keeps appending monotonically increasing primary keys.
+ val secondBatch = (101 to 200).map(i => s"($i, 'v$i')").mkString(", ")
+ sql(s"INSERT INTO $DEFAULT_DATABASE.fluss_fault_test_pk VALUES
$secondBatch")
+
+ // total_count must equal max_seq - min_seq + 1, i.e. no data hole.
+ checkAnswer(
+ sql(s"""
+ |SELECT COUNT(*) AS total_count, MAX(seq_id) AS max_seq,
MIN(seq_id) AS min_seq
+ |FROM $DEFAULT_DATABASE.fluss_fault_test_pk""".stripMargin),
+ Row(200L, 200L, 1L) :: Nil
+ )
+ }
+ }
+
private def partitionPredicate(df: DataFrame):
Option[org.apache.fluss.predicate.Predicate] = {
flussUpsertScan(df).flatMap(_.partitionPredicate)
}