This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new dfa2c0ca4f [core] Preserve sequence-group deletes in projected reads
(#10135)
dfa2c0ca4f is described below
commit dfa2c0ca4f7609b18785f216b3aa6db6875ab9aa
Author: Ran Tao <[email protected]>
AuthorDate: Thu Sep 24 10:58:30 2026 +0800
[core] Preserve sequence-group deletes in projected reads (#10135)
---
.../compact/PartialUpdateMergeFunction.java | 9 +-
.../compact/PartialUpdateMergeFunctionTest.java | 145 +++++++++++++++++++++
.../paimon/spark/sql/DeleteFromTableTestBase.scala | 47 +++++++
3 files changed, 199 insertions(+), 2 deletions(-)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/PartialUpdateMergeFunction.java
b/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/PartialUpdateMergeFunction.java
index 00bc83b152..b97d7a29da 100644
---
a/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/PartialUpdateMergeFunction.java
+++
b/paimon-core/src/main/java/org/apache/paimon/mergetree/compact/PartialUpdateMergeFunction.java
@@ -621,8 +621,13 @@ public class PartialUpdateMergeFunction implements
MergeFunction<KeyValue> {
LinkedHashSet<DataField> extraFields = new LinkedHashSet<>();
List<String> readFieldNames = readType.getFieldNames();
- for (DataField readField : readType.getFields()) {
- int index = rowType.getFieldIndex(readField.name());
+ LinkedHashSet<Integer> requiredFields =
+ readFieldNames.stream()
+ .map(rowType::getFieldIndex)
+
.collect(Collectors.toCollection(LinkedHashSet::new));
+ // These groups determine whether the whole row exists, even for
an empty projection.
+
sequenceGroupPartialDelete.stream().sorted().forEach(requiredFields::add);
+ for (int index : requiredFields) {
Supplier<FieldsComparator> comparatorSupplier =
fieldSeqComparators.get(index);
if (comparatorSupplier == null) {
continue;
diff --git
a/paimon-core/src/test/java/org/apache/paimon/mergetree/compact/PartialUpdateMergeFunctionTest.java
b/paimon-core/src/test/java/org/apache/paimon/mergetree/compact/PartialUpdateMergeFunctionTest.java
index 9de9d22957..3c7eca4e72 100644
---
a/paimon-core/src/test/java/org/apache/paimon/mergetree/compact/PartialUpdateMergeFunctionTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/mergetree/compact/PartialUpdateMergeFunctionTest.java
@@ -26,10 +26,13 @@ import org.apache.paimon.types.DataType;
import org.apache.paimon.types.DataTypes;
import org.apache.paimon.types.RowKind;
import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.ProjectedRow;
import org.apache.paimon.shade.guava30.com.google.common.collect.ImmutableList;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import static org.apache.paimon.CoreOptions.FIELDS_DEFAULT_AGG_FUNC;
import static
org.apache.paimon.testutils.assertj.PaimonAssertions.anyCauseMatches;
@@ -424,6 +427,122 @@ public class PartialUpdateMergeFunctionTest {
validate(func, 1, 1, 1, 2, 2);
}
+ @ParameterizedTest
+ @ValueSource(strings = {"", "id", "v1", "seq1", "v2", "seq2", "extra",
"v2,id"})
+ public void testSequenceGroupDeleteWithProjection(String selectedFields) {
+ RowType rowType =
+ RowType.builder()
+ .field("id", DataTypes.INT())
+ .field("v1", DataTypes.INT())
+ .field("seq1", DataTypes.INT())
+ .field("v2", DataTypes.INT())
+ .field("seq2", DataTypes.INT())
+ .field("extra", DataTypes.INT())
+ .build();
+ Options options = new Options();
+ options.set("fields.seq1.sequence-group", "v1");
+ options.set("fields.seq2.sequence-group", "v2");
+ options.set("partial-update.remove-record-on-sequence-group", "seq2");
+ MergeFunctionFactory<KeyValue> factory =
+ PartialUpdateMergeFunction.factory(options, rowType,
ImmutableList.of("id"));
+ // An empty selection models COUNT(*); other selections cover each
field and reordering.
+ RowType readType =
+ factory.adjustReadType(
+ rowType.project(
+ selectedFields.isEmpty()
+ ? new String[0]
+ : selectedFields.split(",")));
+ MergeFunction<KeyValue> func = factory.create(readType);
+ ProjectedRow projection = ProjectedRow.from(readType, rowType);
+ GenericRow inserted = GenericRow.of(1, 10, 1, 20, 1, 100);
+
+ // A newer or equal seq2 deletes the whole row, even if seq2 was not
selected.
+ assertProjectedDelete(
+ func,
+ projection,
+ inserted,
+ GenericRow.of(1, null, null, null, 2, null),
+ RowKind.DELETE);
+ assertProjectedDelete(
+ func,
+ projection,
+ inserted,
+ GenericRow.of(1, null, null, null, 1, null),
+ RowKind.DELETE);
+ // An older seq2 must not delete the row.
+ assertProjectedDelete(
+ func,
+ projection,
+ inserted,
+ GenericRow.of(1, null, null, null, 0, null),
+ RowKind.INSERT);
+ // A null seq2 skips the group and must not delete the row.
+ assertProjectedDelete(
+ func,
+ projection,
+ inserted,
+ GenericRow.of(1, null, null, null, null, null),
+ RowKind.INSERT);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"", "id", "v1", "seq1", "v2", "seq2", "subSeq2",
"v2,id"})
+ public void testMultiSequenceFieldsDeleteWithProjection(String
selectedFields) {
+ RowType rowType =
+ RowType.builder()
+ .field("id", DataTypes.INT())
+ .field("v1", DataTypes.INT())
+ .field("seq1", DataTypes.INT())
+ .field("v2", DataTypes.INT())
+ .field("seq2", DataTypes.INT())
+ .field("subSeq2", DataTypes.INT())
+ .build();
+ Options options = new Options();
+ options.set("fields.seq1.sequence-group", "v1");
+ options.set("fields.seq2,subSeq2.sequence-group", "v2");
+ options.set("partial-update.remove-record-on-sequence-group", "seq2");
+ MergeFunctionFactory<KeyValue> factory =
+ PartialUpdateMergeFunction.factory(options, rowType,
ImmutableList.of("id"));
+ RowType readType =
+ factory.adjustReadType(
+ rowType.project(
+ selectedFields.isEmpty()
+ ? new String[0]
+ : selectedFields.split(",")));
+ MergeFunction<KeyValue> func = factory.create(readType);
+ ProjectedRow projection = ProjectedRow.from(readType, rowType);
+ GenericRow inserted = GenericRow.of(1, 10, 1, 20, 1, 1);
+
+ // seq2 stays equal: subSeq2 must be read to recognize the newer
delete.
+ assertProjectedDelete(
+ func,
+ projection,
+ inserted,
+ GenericRow.of(1, null, null, null, 1, 2),
+ RowKind.DELETE);
+ // Equal composite sequences also allow deletion.
+ assertProjectedDelete(
+ func,
+ projection,
+ inserted,
+ GenericRow.of(1, null, null, null, 1, 1),
+ RowKind.DELETE);
+ // The older subSeq2 must prevent deletion, despite the equal seq2.
+ assertProjectedDelete(
+ func,
+ projection,
+ inserted,
+ GenericRow.of(1, null, null, null, 1, 0),
+ RowKind.INSERT);
+ // A fully null sequence group must not delete the row.
+ assertProjectedDelete(
+ func,
+ projection,
+ inserted,
+ GenericRow.of(1, null, null, null, null, null),
+ RowKind.INSERT);
+ }
+
@Test
public void testMultiSequenceFieldsAdjustProjectionProject() {
Options options = new Options();
@@ -1035,6 +1154,32 @@ public class PartialUpdateMergeFunctionTest {
validate(func, 1, 2, 2, null);
}
+ private void assertProjectedDelete(
+ MergeFunction<KeyValue> function,
+ ProjectedRow projection,
+ GenericRow inserted,
+ GenericRow deleted,
+ RowKind expectedKind) {
+ function.reset();
+ function.add(
+ new KeyValue()
+ .replace(
+ GenericRow.of(1),
+ sequence++,
+ RowKind.INSERT,
+ projection.replaceRow(inserted)));
+ function.add(
+ new KeyValue()
+ .replace(
+ GenericRow.of(1),
+ sequence++,
+ RowKind.DELETE,
+ projection.replaceRow(deleted)));
+ assertThat(function.getResult().valueKind())
+ .as("delete record %s", deleted)
+ .isEqualTo(expectedKind);
+ }
+
private void add(MergeFunction<KeyValue> function, Integer... f) {
add(function, RowKind.INSERT, f);
}
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DeleteFromTableTestBase.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DeleteFromTableTestBase.scala
index 300c6b7b2d..fcbc0390c5 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DeleteFromTableTestBase.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/DeleteFromTableTestBase.scala
@@ -20,8 +20,10 @@ package org.apache.paimon.spark.sql
import org.apache.paimon.{CoreOptions, Snapshot}
import org.apache.paimon.CoreOptions.MergeEngine
+import org.apache.paimon.data.GenericRow
import org.apache.paimon.spark.PaimonSparkTestBase
import org.apache.paimon.spark.catalyst.analysis.Delete
+import org.apache.paimon.types.RowKind
import org.apache.spark.sql.Row
import org.assertj.core.api.Assertions.{assertThat, assertThatThrownBy}
@@ -614,4 +616,49 @@ abstract class DeleteFromTableTestBase extends
PaimonSparkTestBase {
}
}
}
+
+ test("Paimon Delete: sequence-group delete records with projected reads") {
+ spark.sql("""
+ |CREATE TABLE T (id INT, v1 INT, seq1 INT, v2 INT, seq2 INT)
+ |TBLPROPERTIES (
+ | 'primary-key' = 'id',
+ | 'bucket' = '1',
+ | 'merge-engine' = 'partial-update',
+ | 'fields.seq1.sequence-group' = 'v1',
+ | 'fields.seq2.sequence-group' = 'v2',
+ | 'partial-update.remove-record-on-sequence-group' = 'seq2',
+ | 'write-only' = 'true')
+ |""".stripMargin)
+
+ // mock two streams updating independent field groups for the same primary
keys.
+ spark.sql("INSERT INTO T VALUES (1, 10, 1, NULL, NULL), (2, 20, 1, NULL,
NULL)")
+ spark.sql("INSERT INTO T VALUES (1, NULL, NULL, 100, 1), (2, NULL, NULL,
200, 1)")
+ checkAnswer(spark.sql("SELECT * FROM T"), Seq(Row(1, 10, 1, 100, 1),
Row(2, 20, 1, 200, 1)))
+
+ // A newer seq2 triggers whole-row deletion; seq1=NULL skips the first
group.
+ // Use the Paimon write API because SQL DELETE rewrites files for this
configuration.
+ val builder = loadTable("T").newBatchWriteBuilder()
+ val write = builder.newWrite()
+ val commit = builder.newCommit()
+ try {
+ val delete = GenericRow.of(1, null, null, null, 2)
+ delete.setRowKind(RowKind.DELETE)
+ write.write(delete)
+ commit.commit(write.prepareCommit())
+ } finally {
+ write.close()
+ commit.close()
+ }
+
+ // Keep both streams' insert files and the delete file separate for
merging during reads.
+ checkAnswer(spark.sql("SELECT COUNT(*) FROM `T$files`"), Row(3L))
+
+ // Reading all columns retains seq2, so merging removes id=1 and combines
both groups for id=2.
+ checkAnswer(spark.sql("SELECT * FROM T"), Row(2, 20, 1, 200, 1))
+
+ // seq2 controls row existence even when only the key, another group, or
no columns are read.
+ checkAnswer(spark.sql("SELECT COUNT(*) FROM T"), Row(1L))
+ checkAnswer(spark.sql("SELECT id FROM T"), Row(2))
+ checkAnswer(spark.sql("SELECT v1 FROM T"), Row(20))
+ }
}