diff --git a/docs/docs/concepts/spec/datafile.md b/docs/docs/concepts/spec/datafile.md index 25408c53d1b1..0afd8f3b93af 100644 --- a/docs/docs/concepts/spec/datafile.md +++ b/docs/docs/concepts/spec/datafile.md @@ -137,6 +137,20 @@ contains five columns: _SEQUENCE_NUMBER | _VALUE_KIND | a | b | c ``` +With `delete-row.thin-mode = true`, for a primary-key merge engine where `DELETE` removes the +whole record, Paimon keeps the primary-key, sequence, record-level time, clustering, and non-null +fields in data-file DELETE rows, and writes null for other nullable value fields. Enabling this option +requires a primary-key table using deduplicate, partial-update with +`partial-update.remove-record-on-delete = true`, or aggregation with +`aggregation.remove-record-on-delete = true`. Postpone bucket tables (`bucket = -2`) are not supported. +Unsupported configurations are rejected during schema validation. If all fields must be retained, +the configuration is valid but no fields are projected. The physical schema is unchanged. +Changelog files are not projected by this optimization. + +This option defaults to `false` and is independent of `data-file.thin-mode`. When enabled, readers +that expose DELETE rows from data files, such as audit-log and delta readers, may observe nulls in +these fields. Disabling the option does not restore values already written as null. + ### Table w/o Primary key Data File An ordinary append table stores its declared value columns without the primary-key merge fields: diff --git a/docs/generated/core_configuration.html b/docs/generated/core_configuration.html index 0fc8c42fd261..eda7d42dba64 100644 --- a/docs/generated/core_configuration.html +++ b/docs/generated/core_configuration.html @@ -650,6 +650,12 @@ Boolean Enable data file thin mode to avoid duplicate columns storage. + +
delete-row.thin-mode
+ false + Boolean + Enable thin mode for whole-record DELETE rows in primary-key data files. Non-essential nullable value fields are stored as null. Requires a primary-key table using deduplicate, or partial-update and aggregation with remove-record-on-delete enabled. Postpone bucket tables (bucket = -2) are not supported. Unsupported configurations are rejected. Changelog files are unaffected. +
delete.force-produce-changelog
false diff --git a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java index c3202188dc09..7349439fe012 100644 --- a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java +++ b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java @@ -2427,6 +2427,18 @@ public String toString() { .withDescription( "Enable data file thin mode to avoid duplicate columns storage."); + public static final ConfigOption DELETE_ROW_THIN_MODE = + key("delete-row.thin-mode") + .booleanType() + .defaultValue(false) + .withDescription( + "Enable thin mode for whole-record DELETE rows in primary-key data files. " + + "Non-essential nullable value fields are stored as null. " + + "Requires a primary-key table using deduplicate, or partial-update " + + "and aggregation with remove-record-on-delete enabled. " + + "Postpone bucket tables (bucket = -2) are not supported. " + + "Unsupported configurations are rejected. Changelog files are unaffected."); + public static final ConfigOption PARTITION_IDLE_TIME_TO_REPORT_STATISTIC = key("partition.idle-time-to-report-statistic") .durationType() @@ -4781,10 +4793,18 @@ public boolean dataFileThinMode() { return options.get(DATA_FILE_THIN_MODE); } + public boolean deleteRowThinMode() { + return options.get(DELETE_ROW_THIN_MODE); + } + public boolean aggregationRemoveRecordOnDelete() { return options.get(AGGREGATION_REMOVE_RECORD_ON_DELETE); } + public boolean partialUpdateRemoveRecordOnDelete() { + return options.get(PARTIAL_UPDATE_REMOVE_RECORD_ON_DELETE); + } + public Optional commitLastSafeSnapshot() { return options.getOptional(COMMIT_LAST_SAFE_SNAPSHOT); } diff --git a/paimon-core/src/main/java/org/apache/paimon/io/DeleteRowFieldsProjector.java b/paimon-core/src/main/java/org/apache/paimon/io/DeleteRowFieldsProjector.java new file mode 100644 index 000000000000..a331f2435e37 --- /dev/null +++ b/paimon-core/src/main/java/org/apache/paimon/io/DeleteRowFieldsProjector.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.paimon.io; + +import org.apache.paimon.CoreOptions; +import org.apache.paimon.KeyValue; +import org.apache.paimon.schema.TableSchema; +import org.apache.paimon.types.DataField; +import org.apache.paimon.utils.ProjectedRow; + +import javax.annotation.Nullable; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** Projects whole-record DELETE rows to fields required by data files. */ +final class DeleteRowFieldsProjector { + + private final ProjectedRow projectedValue; + private final KeyValue reused = new KeyValue(); + + static @Nullable int[] createProjection(TableSchema schema, CoreOptions options) { + if (!options.deleteRowThinMode()) { + return null; + } + + Set requiredFields = new HashSet<>(schema.primaryKeys()); + requiredFields.addAll(options.sequenceField()); + if (options.recordLevelTimeField() != null) { + requiredFields.add(options.recordLevelTimeField()); + } + requiredFields.addAll(options.clusteringColumns()); + for (DataField field : schema.fields()) { + if (!field.type().isNullable()) { + requiredFields.add(field.name()); + } + } + + int[] projection = new int[schema.fields().size()]; + Arrays.fill(projection, -1); + for (int i = 0; i < schema.fields().size(); i++) { + if (requiredFields.contains(schema.fields().get(i).name())) { + projection[i] = i; + } + } + return Arrays.stream(projection).anyMatch(field -> field < 0) ? projection : null; + } + + DeleteRowFieldsProjector(int[] projection) { + this.projectedValue = ProjectedRow.from(projection); + } + + KeyValue project(KeyValue kv) { + return reused.replace( + kv.key(), + kv.sequenceNumber(), + kv.valueKind(), + projectedValue.replaceRow(kv.value())) + .setLevel(kv.level()); + } +} diff --git a/paimon-core/src/main/java/org/apache/paimon/io/KeyValueClusteringFileWriter.java b/paimon-core/src/main/java/org/apache/paimon/io/KeyValueClusteringFileWriter.java index 57548e831153..876c35a04dbb 100644 --- a/paimon-core/src/main/java/org/apache/paimon/io/KeyValueClusteringFileWriter.java +++ b/paimon-core/src/main/java/org/apache/paimon/io/KeyValueClusteringFileWriter.java @@ -34,6 +34,7 @@ import org.apache.paimon.stats.SimpleStats; import org.apache.paimon.stats.SimpleStatsConverter; import org.apache.paimon.table.SpecialFields; +import org.apache.paimon.types.RowKind; import org.apache.paimon.types.RowType; import org.apache.paimon.utils.Pair; @@ -72,6 +73,7 @@ public class KeyValueClusteringFileWriter private final Projection clusteringProjection; @Nullable private final DataFileIndexWriter dataFileIndexWriter; private final int[] keyStatMapping; + @Nullable private final DeleteRowFieldsProjector deleteRowFieldsProjector; private BinaryRow minClusteringFields = null; private BinaryRow currentClusteringFields = null; @@ -90,7 +92,8 @@ public KeyValueClusteringFileWriter( boolean thinMode, CoreOptions options, FileIndexOptions fileIndexOptions, - boolean isExternalPath) { + boolean isExternalPath, + @Nullable DeleteRowFieldsProjector deleteRowFieldsProjector) { super( fileIO, context, @@ -116,6 +119,7 @@ public KeyValueClusteringFileWriter( this.dataFileIndexWriter = DataFileIndexWriter.create( fileIO, dataFileToFileIndexPath(path), valueType, fileIndexOptions); + this.deleteRowFieldsProjector = deleteRowFieldsProjector; Map idToIndex = new HashMap<>(valueType.getFieldCount()); for (int i = 0; i < valueType.getFieldCount(); i++) { @@ -131,6 +135,9 @@ public KeyValueClusteringFileWriter( @Override public void write(KeyValue kv) throws IOException { + if (deleteRowFieldsProjector != null && kv.valueKind() == RowKind.DELETE) { + kv = deleteRowFieldsProjector.project(kv); + } super.write(kv); if (dataFileIndexWriter != null) { diff --git a/paimon-core/src/main/java/org/apache/paimon/io/KeyValueDataFileWriter.java b/paimon-core/src/main/java/org/apache/paimon/io/KeyValueDataFileWriter.java index 133a7af27225..ca62041e4972 100644 --- a/paimon-core/src/main/java/org/apache/paimon/io/KeyValueDataFileWriter.java +++ b/paimon-core/src/main/java/org/apache/paimon/io/KeyValueDataFileWriter.java @@ -32,6 +32,7 @@ import org.apache.paimon.manifest.FileSource; import org.apache.paimon.stats.SimpleStats; import org.apache.paimon.stats.SimpleStatsConverter; +import org.apache.paimon.types.RowKind; import org.apache.paimon.types.RowType; import org.apache.paimon.utils.Pair; @@ -74,6 +75,7 @@ public abstract class KeyValueDataFileWriter private final FileSource fileSource; @Nullable private final DataFileIndexWriter dataFileIndexWriter; @Nullable private final ManagedBlobReferenceCollector blobReferenceCollector; + @Nullable private final DeleteRowFieldsProjector deleteRowFieldsProjector; private BinaryRow minKey = null; private long minSeqNumber = Long.MAX_VALUE; @@ -94,7 +96,8 @@ public KeyValueDataFileWriter( FileSource fileSource, FileIndexOptions fileIndexOptions, boolean isExternalPath, - Set managedBlobFields) { + Set managedBlobFields, + @Nullable DeleteRowFieldsProjector deleteRowFieldsProjector) { super(fileIO, context, path, converter, writeRowType, options.asyncFileWrite()); this.keyType = keyType; @@ -115,10 +118,14 @@ public KeyValueDataFileWriter( ? null : new ManagedBlobReferenceCollector( fileIO, path, valueType, managedBlobFields); + this.deleteRowFieldsProjector = deleteRowFieldsProjector; } @Override public void write(KeyValue kv) throws IOException { + if (deleteRowFieldsProjector != null && kv.valueKind() == RowKind.DELETE) { + kv = deleteRowFieldsProjector.project(kv); + } super.write(kv); if (dataFileIndexWriter != null) { diff --git a/paimon-core/src/main/java/org/apache/paimon/io/KeyValueDataFileWriterImpl.java b/paimon-core/src/main/java/org/apache/paimon/io/KeyValueDataFileWriterImpl.java index 628fdf5cfed1..fe3a8a4219a7 100644 --- a/paimon-core/src/main/java/org/apache/paimon/io/KeyValueDataFileWriterImpl.java +++ b/paimon-core/src/main/java/org/apache/paimon/io/KeyValueDataFileWriterImpl.java @@ -29,6 +29,8 @@ import org.apache.paimon.types.RowType; import org.apache.paimon.utils.Pair; +import javax.annotation.Nullable; + import java.util.Arrays; import java.util.Set; import java.util.function.Function; @@ -49,7 +51,8 @@ public KeyValueDataFileWriterImpl( FileSource fileSource, FileIndexOptions fileIndexOptions, boolean isExternalPath, - Set managedBlobFields) { + Set managedBlobFields, + @Nullable DeleteRowFieldsProjector deleteRowFieldsProjector) { super( fileIO, context, @@ -64,7 +67,8 @@ public KeyValueDataFileWriterImpl( fileSource, fileIndexOptions, isExternalPath, - managedBlobFields); + managedBlobFields, + deleteRowFieldsProjector); } @Override diff --git a/paimon-core/src/main/java/org/apache/paimon/io/KeyValueFileWriterFactory.java b/paimon-core/src/main/java/org/apache/paimon/io/KeyValueFileWriterFactory.java index 7c55360fef4c..4c7d39556a4d 100644 --- a/paimon-core/src/main/java/org/apache/paimon/io/KeyValueFileWriterFactory.java +++ b/paimon-core/src/main/java/org/apache/paimon/io/KeyValueFileWriterFactory.java @@ -34,6 +34,7 @@ import org.apache.paimon.fs.FileIO; import org.apache.paimon.fs.Path; import org.apache.paimon.manifest.FileSource; +import org.apache.paimon.schema.TableSchema; import org.apache.paimon.statistics.NoneSimpleColStatsCollector; import org.apache.paimon.statistics.SimpleColStatsCollector; import org.apache.paimon.table.SpecialFields; @@ -72,13 +73,15 @@ public class KeyValueFileWriterFactory { private final FileIndexOptions fileIndexOptions; private final Set managedBlobFields; @Nullable private final PrimaryKeyBlobExternalizer blobExternalizer; + @Nullable private final int[] deleteRowFieldsProjection; private KeyValueFileWriterFactory( FileIO fileIO, long schemaId, FileWriterContextFactory formatContext, long suggestedFileSize, - CoreOptions options) { + CoreOptions options, + @Nullable TableSchema tableSchema) { this.fileIO = fileIO; this.schemaId = schemaId; this.keyType = formatContext.keyType; @@ -87,6 +90,10 @@ private KeyValueFileWriterFactory( this.suggestedFileSize = suggestedFileSize; this.options = options; this.fileIndexOptions = options.indexColumnsOptions(); + this.deleteRowFieldsProjection = + tableSchema == null + ? null + : DeleteRowFieldsProjector.createProjection(tableSchema, options); this.managedBlobFields = managedBlobFields(); this.blobExternalizer = managedBlobFields.isEmpty() @@ -203,7 +210,10 @@ private KeyValueClusteringFileWriter createKvSeparatedFileWriter( formatContext.thinModeEnabled, options, fileIndexOptions, - isExternalPath); + isExternalPath, + deleteRowFieldsProjection == null + ? null + : new DeleteRowFieldsProjector(deleteRowFieldsProjection)); } private KeyValueDataFileWriter createDataFileWriter( @@ -216,6 +226,10 @@ private KeyValueDataFileWriter createDataFileWriter( FileIndexOptions indexOptions = key.isChangelog ? new FileIndexOptions() : fileIndexOptions; Set dataFileManagedBlobFields = key.isChangelog ? Collections.emptySet() : managedBlobFields; + DeleteRowFieldsProjector deleteRowFieldsProjector = + key.isChangelog || deleteRowFieldsProjection == null + ? null + : new DeleteRowFieldsProjector(deleteRowFieldsProjection); return formatContext.thinModeEnabled ? new KeyValueThinDataFileWriterImpl( fileIO, @@ -230,7 +244,8 @@ private KeyValueDataFileWriter createDataFileWriter( fileSource, indexOptions, isExternalPath, - dataFileManagedBlobFields) + dataFileManagedBlobFields, + deleteRowFieldsProjector) : new KeyValueDataFileWriterImpl( fileIO, formatContext.fileWriterContext(key, writerFactory), @@ -244,7 +259,8 @@ private KeyValueDataFileWriter createDataFileWriter( fileSource, indexOptions, isExternalPath, - dataFileManagedBlobFields); + dataFileManagedBlobFields, + deleteRowFieldsProjector); } private Set managedBlobFields() { @@ -267,6 +283,25 @@ public String newChangelogFileName(int level) { return formatContext.pathFactory(new WriteFormatKey(level, true)).newChangelogFileName(); } + public static Builder builder( + FileIO fileIO, + TableSchema schema, + RowType keyType, + RowType valueType, + FileFormat fileFormat, + Function format2PathFactory, + long suggestedFileSize) { + return new Builder( + fileIO, + schema.id(), + keyType, + valueType, + fileFormat, + format2PathFactory, + suggestedFileSize, + schema); + } + public static Builder builder( FileIO fileIO, long schemaId, @@ -282,7 +317,8 @@ public static Builder builder( valueType, fileFormat, format2PathFactory, - suggestedFileSize); + suggestedFileSize, + null); } /** Builder of {@link KeyValueFileWriterFactory}. */ @@ -295,6 +331,7 @@ public static class Builder { private final FileFormat fileFormat; private final Function format2PathFactory; private final long suggestedFileSize; + @Nullable private final TableSchema tableSchema; private Builder( FileIO fileIO, @@ -303,7 +340,8 @@ private Builder( RowType valueType, FileFormat fileFormat, Function format2PathFactory, - long suggestedFileSize) { + long suggestedFileSize, + @Nullable TableSchema tableSchema) { this.fileIO = fileIO; this.schemaId = schemaId; this.keyType = keyType; @@ -311,6 +349,7 @@ private Builder( this.fileFormat = fileFormat; this.format2PathFactory = format2PathFactory; this.suggestedFileSize = suggestedFileSize; + this.tableSchema = tableSchema; } public KeyValueFileWriterFactory build( @@ -325,7 +364,7 @@ public KeyValueFileWriterFactory build( format2PathFactory, options); return new KeyValueFileWriterFactory( - fileIO, schemaId, context, suggestedFileSize, options); + fileIO, schemaId, context, suggestedFileSize, options, tableSchema); } } diff --git a/paimon-core/src/main/java/org/apache/paimon/io/KeyValueThinDataFileWriterImpl.java b/paimon-core/src/main/java/org/apache/paimon/io/KeyValueThinDataFileWriterImpl.java index fd05478533e1..3888426180ef 100644 --- a/paimon-core/src/main/java/org/apache/paimon/io/KeyValueThinDataFileWriterImpl.java +++ b/paimon-core/src/main/java/org/apache/paimon/io/KeyValueThinDataFileWriterImpl.java @@ -30,6 +30,8 @@ import org.apache.paimon.types.RowType; import org.apache.paimon.utils.Pair; +import javax.annotation.Nullable; + import java.util.Arrays; import java.util.HashMap; import java.util.Map; @@ -57,7 +59,8 @@ public KeyValueThinDataFileWriterImpl( FileSource fileSource, FileIndexOptions fileIndexOptions, boolean isExternalPath, - Set managedBlobFields) { + Set managedBlobFields, + @Nullable DeleteRowFieldsProjector deleteRowFieldsProjector) { super( fileIO, context, @@ -72,7 +75,8 @@ public KeyValueThinDataFileWriterImpl( fileSource, fileIndexOptions, isExternalPath, - managedBlobFields); + managedBlobFields, + deleteRowFieldsProjector); Map idToIndex = new HashMap<>(valueType.getFieldCount()); for (int i = 0; i < valueType.getFieldCount(); i++) { idToIndex.put(valueType.getFields().get(i).id(), i); diff --git a/paimon-core/src/main/java/org/apache/paimon/operation/KeyValueFileStoreWrite.java b/paimon-core/src/main/java/org/apache/paimon/operation/KeyValueFileStoreWrite.java index b685a152c7c1..2c01875896fb 100644 --- a/paimon-core/src/main/java/org/apache/paimon/operation/KeyValueFileStoreWrite.java +++ b/paimon-core/src/main/java/org/apache/paimon/operation/KeyValueFileStoreWrite.java @@ -131,7 +131,7 @@ public KeyValueFileStoreWrite( this.writerFactoryBuilder = KeyValueFileWriterFactory.builder( fileIO, - schema.id(), + schema, keyType, valueType, fileFormat(options), diff --git a/paimon-core/src/main/java/org/apache/paimon/postpone/PostponeBucketFileStoreWrite.java b/paimon-core/src/main/java/org/apache/paimon/postpone/PostponeBucketFileStoreWrite.java index f75be9a2b5a6..9e95451cea16 100644 --- a/paimon-core/src/main/java/org/apache/paimon/postpone/PostponeBucketFileStoreWrite.java +++ b/paimon-core/src/main/java/org/apache/paimon/postpone/PostponeBucketFileStoreWrite.java @@ -130,7 +130,7 @@ public PostponeBucketFileStoreWrite( this.writerFactoryBuilder = KeyValueFileWriterFactory.builder( fileIO, - schema.id(), + schema, keyType, valueType, fileFormat, diff --git a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java index e279d6479587..c5522fa4863c 100644 --- a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java +++ b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java @@ -187,6 +187,8 @@ public static void validateTableSchema(TableSchema schema, Set dynamicOp validateBucket(schema, options); + validateDeleteRowThinMode(schema, options); + validateStartupMode(options); validateFieldsPrefix(schema, options); @@ -879,6 +881,50 @@ private static void validateSequenceGroupSortOrder(TableSchema schema, CoreOptio } } + private static void validateDeleteRowThinMode(TableSchema schema, CoreOptions options) { + if (!options.deleteRowThinMode()) { + return; + } + + String option = CoreOptions.DELETE_ROW_THIN_MODE.key(); + checkArgument( + !schema.primaryKeys().isEmpty(), + "'%s' can only be enabled for a table with primary keys.", + option); + checkArgument( + options.bucket() != BucketMode.POSTPONE_BUCKET, + "'%s' does not support postpone bucket tables (bucket = -2).", + option); + + switch (options.mergeEngine()) { + case DEDUPLICATE: + return; + case PARTIAL_UPDATE: + checkArgument( + options.partialUpdateRemoveRecordOnDelete(), + "'%s' requires '%s' = true when '%s' = '%s'.", + option, + CoreOptions.PARTIAL_UPDATE_REMOVE_RECORD_ON_DELETE.key(), + CoreOptions.MERGE_ENGINE.key(), + options.mergeEngine()); + return; + case AGGREGATE: + checkArgument( + options.aggregationRemoveRecordOnDelete(), + "'%s' requires '%s' = true when '%s' = '%s'.", + option, + CoreOptions.AGGREGATION_REMOVE_RECORD_ON_DELETE.key(), + CoreOptions.MERGE_ENGINE.key(), + options.mergeEngine()); + return; + default: + throw new IllegalArgumentException( + String.format( + "'%s' does not support merge engine '%s'.", + option, options.mergeEngine())); + } + } + private static void validateMergeFunction(TableSchema schema) { if (schema.primaryKeys().isEmpty()) { return; diff --git a/paimon-core/src/test/java/org/apache/paimon/io/DeleteRowFieldsProjectorTest.java b/paimon-core/src/test/java/org/apache/paimon/io/DeleteRowFieldsProjectorTest.java new file mode 100644 index 000000000000..29cf951eb6bb --- /dev/null +++ b/paimon-core/src/test/java/org/apache/paimon/io/DeleteRowFieldsProjectorTest.java @@ -0,0 +1,146 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.paimon.io; + +import org.apache.paimon.CoreOptions; +import org.apache.paimon.KeyValue; +import org.apache.paimon.data.GenericRow; +import org.apache.paimon.options.Options; +import org.apache.paimon.schema.TableSchema; +import org.apache.paimon.types.DataField; +import org.apache.paimon.types.DataTypes; +import org.apache.paimon.types.RowKind; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Tests for {@link DeleteRowFieldsProjector}. */ +class DeleteRowFieldsProjectorTest { + + @Test + void testDisabledByDefault() { + Options options = new Options(); + assertThat(createProjection(options)).isNull(); + options.set(CoreOptions.DELETE_ROW_THIN_MODE, true); + assertThat(createProjection(options)).isNotNull(); + options.set(CoreOptions.DELETE_ROW_THIN_MODE, false); + assertThat(createProjection(options)).isNull(); + } + + @Test + void testRequiredFieldProjection() { + Options options = new Options(); + options.set(CoreOptions.DELETE_ROW_THIN_MODE, true); + options.set(CoreOptions.SEQUENCE_FIELD, "seq"); + options.set(CoreOptions.RECORD_LEVEL_TIME_FIELD, "expire"); + options.set(CoreOptions.CLUSTERING_COLUMNS, "cluster"); + options.set(CoreOptions.PK_VECTOR_INDEX_COLUMNS, "vector_index"); + options.set( + CoreOptions.PK_BTREE_INDEX_COLUMNS, "btree_index,key,not_null,seq,expire,cluster"); + options.set(CoreOptions.PK_BITMAP_INDEX_COLUMNS, "bitmap_index"); + options.set(CoreOptions.PK_MULTIVALUE_INDEX_COLUMNS, "multivalue_index"); + options.set(CoreOptions.PK_FULL_TEXT_INDEX_COLUMNS, "fulltext_index"); + options.set(CoreOptions.PK_FM_INDEX_COLUMNS, "fm_index"); + + assertThat( + DeleteRowFieldsProjector.createProjection( + tableSchema(), new CoreOptions(options))) + .containsExactly(0, 1, 2, 3, 4, -1, -1, -1, -1, -1, -1, -1); + } + + @Test + void testNoFieldsToProject() { + Options options = new Options(); + options.set(CoreOptions.DELETE_ROW_THIN_MODE, true); + TableSchema schema = + new TableSchema( + 0, + Arrays.asList( + new DataField(0, "key", DataTypes.INT().notNull()), + new DataField(1, "value", DataTypes.STRING().notNull())), + 1, + Collections.emptyList(), + Collections.singletonList("key"), + options.toMap(), + ""); + assertThat(DeleteRowFieldsProjector.createProjection(schema, new CoreOptions(options))) + .isNull(); + } + + @Test + void testProjectionDoesNotChangeInput() { + GenericRow value = + GenericRow.of( + 1, + "required", + 2L, + null, + null, + null, + null, + null, + null, + null, + null, + "payloads"); + KeyValue input = new KeyValue().replace(GenericRow.of(1), 2L, RowKind.DELETE, value); + + Options options = new Options(); + options.set(CoreOptions.DELETE_ROW_THIN_MODE, true); + KeyValue projected = new DeleteRowFieldsProjector(createProjection(options)).project(input); + + assertThat(projected.key()).isSameAs(input.key()); + assertThat(projected.sequenceNumber()).isEqualTo(2L); + assertThat(projected.valueKind()).isEqualTo(RowKind.DELETE); + assertThat(projected.value().isNullAt(1)).isFalse(); + assertThat(projected.value().isNullAt(11)).isTrue(); + assertThat(value.isNullAt(11)).isFalse(); + } + + private int[] createProjection(Options options) { + return DeleteRowFieldsProjector.createProjection(tableSchema(), new CoreOptions(options)); + } + + private TableSchema tableSchema() { + return new TableSchema( + 0, + Arrays.asList( + new DataField(0, "key", DataTypes.INT()), + new DataField(1, "not_null", DataTypes.STRING().notNull()), + new DataField(2, "seq", DataTypes.BIGINT()), + new DataField(3, "expire", DataTypes.BIGINT()), + new DataField(4, "cluster", DataTypes.STRING()), + new DataField(5, "vector_index", DataTypes.STRING()), + new DataField(6, "btree_index", DataTypes.STRING()), + new DataField(7, "bitmap_index", DataTypes.STRING()), + new DataField(8, "multivalue_index", DataTypes.STRING()), + new DataField(9, "fulltext_index", DataTypes.STRING()), + new DataField(10, "fm_index", DataTypes.STRING()), + new DataField(11, "payload", DataTypes.STRING())), + 11, + Collections.emptyList(), + Collections.singletonList("key"), + Collections.emptyMap(), + ""); + } +} diff --git a/paimon-core/src/test/java/org/apache/paimon/io/KeyValueFileReadWriteTest.java b/paimon-core/src/test/java/org/apache/paimon/io/KeyValueFileReadWriteTest.java index 333d61302b48..2934354314fd 100644 --- a/paimon-core/src/test/java/org/apache/paimon/io/KeyValueFileReadWriteTest.java +++ b/paimon-core/src/test/java/org/apache/paimon/io/KeyValueFileReadWriteTest.java @@ -54,6 +54,7 @@ import org.apache.paimon.table.SpecialFields; import org.apache.paimon.types.DataType; import org.apache.paimon.types.IntType; +import org.apache.paimon.types.RowKind; import org.apache.paimon.types.RowType; import org.apache.paimon.types.VarCharType; import org.apache.paimon.utils.CloseableIterator; @@ -66,6 +67,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; import java.io.IOException; @@ -407,6 +409,11 @@ protected KeyValueFileWriterFactory createWriterFactory(String pathStr, String f protected KeyValueFileWriterFactory createWriterFactory( String pathStr, String format, Options options) { + return createWriterFactory(pathStr, format, options, false); + } + + private KeyValueFileWriterFactory createWriterFactory( + String pathStr, String format, Options options, boolean useTableSchema) { Path path = new Path(pathStr); int suggestedFileSize = ThreadLocalRandom.current().nextInt(8192) + 1024; FileIO fileIO = FileIOFinder.find(path); @@ -430,18 +437,26 @@ protected KeyValueFileWriterFactory createWriterFactory( false, null); - return KeyValueFileWriterFactory.builder( - fileIO, - 0, - KEY_TYPE, - DEFAULT_ROW_TYPE, - // normal format will buffer changes in memory and we can't determine - // if the written file size is really larger than suggested, so we use a - // special format which flushes for every added element - new FlushingFileFormat(format), - pathFactoryMap, - suggestedFileSize) - .build(BinaryRow.EMPTY_ROW, 0, new CoreOptions(options)); + FileFormat fileFormat = new FlushingFileFormat(format); + KeyValueFileWriterFactory.Builder builder = + useTableSchema + ? KeyValueFileWriterFactory.builder( + fileIO, + createTestSchemaManager(path).schema(0), + KEY_TYPE, + DEFAULT_ROW_TYPE, + fileFormat, + pathFactoryMap, + suggestedFileSize) + : KeyValueFileWriterFactory.builder( + fileIO, + 0, + KEY_TYPE, + DEFAULT_ROW_TYPE, + fileFormat, + pathFactoryMap, + suggestedFileSize); + return builder.build(BinaryRow.EMPTY_ROW, 0, new CoreOptions(options)); } private KeyValueFileReaderFactory createReaderFactory( @@ -604,6 +619,110 @@ void testChangelogFile() throws Exception { } } + @ParameterizedTest(name = "format={0}, thinMode={1}") + @CsvSource({ + "avro, false", + "avro, true", + "orc, false", + "orc, true", + "parquet, false", + "parquet, true" + }) + void testDeleteRowFieldsProjection(String format, boolean thinMode) throws Exception { + Options options = new Options(); + options.set(CoreOptions.DELETE_ROW_THIN_MODE, true); + options.set(CoreOptions.METADATA_STATS_MODE, "FULL"); + options.set(CoreOptions.METADATA_STATS_DENSE_STORE, false); + options.set(CoreOptions.DATA_FILE_THIN_MODE, thinMode); + options.set(CoreOptions.SEQUENCE_FIELD, "itemId"); + + KeyValueFileWriterFactory writerFactory = + createWriterFactory(tempDir.toString(), format, options, true); + TestKeyValueGenerator generator = new TestKeyValueGenerator(); + KeyValue insert = + generator.nextInsert("20201110", 9, 42L, new int[] {10, 2}, "delete-before-image"); + KeyValue delete = + new KeyValue() + .replace( + insert.key(), + insert.sequenceNumber(), + RowKind.DELETE, + insert.value()); + + RollingFileWriter dataWriter = + writerFactory.createRollingMergeTreeFileWriter(0, FileSource.APPEND); + dataWriter.write(delete); + dataWriter.close(); + DataFileMeta dataFile = dataWriter.result().get(0); + + KeyValueFileReaderFactory readerFactory = + createReaderFactory(tempDir.toString(), format, null, null); + KeyValue dataDelete = readSingle(readerFactory, dataFile); + assertThat(dataDelete.valueKind()).isEqualTo(RowKind.DELETE); + assertThat(dataDelete.value().getLong(4)).isEqualTo(42L); + assertThat(dataDelete.value().isNullAt(5)).isTrue(); + assertThat(dataDelete.value().isNullAt(6)).isTrue(); + assertThat(dataFile.valueStats().nullCounts().getLong(6)).isEqualTo(1L); + + RollingFileWriter changelogWriter = + writerFactory.createRollingChangelogFileWriter(0); + changelogWriter.write(delete); + changelogWriter.close(); + + KeyValue changelogDelete = readSingle(readerFactory, changelogWriter.result().get(0)); + assertThat(changelogDelete.valueKind()).isEqualTo(RowKind.DELETE); + assertThat(changelogDelete.value().isNullAt(5)).isFalse(); + assertThat(changelogDelete.value().getString(6).toString()) + .isEqualTo("delete-before-image"); + } + + @Test + void testDeleteRowFieldsProjectionInClusteringFile() throws Exception { + Options options = new Options(); + options.set(CoreOptions.DELETE_ROW_THIN_MODE, true); + options.set(CoreOptions.CLUSTERING_COLUMNS, "comment"); + options.set(CoreOptions.SEQUENCE_FIELD, "itemId"); + + KeyValueFileWriterFactory writerFactory = + createWriterFactory(tempDir.toString(), "avro", options, true); + KeyValue insert = + new TestKeyValueGenerator() + .nextInsert("20201110", 9, 42L, new int[] {10, 2}, "clustering-field"); + KeyValue delete = + new KeyValue() + .replace( + insert.key(), + insert.sequenceNumber(), + RowKind.DELETE, + insert.value()); + + RollingFileWriter writer = + writerFactory.createRollingClusteringFileWriter(); + writer.write(delete); + writer.close(); + + KeyValueFileReaderFactory readerFactory = + createReaderFactory(tempDir.toString(), "avro", null, null); + KeyValue actual = readSingle(readerFactory, writer.result().get(0)); + assertThat(actual.value().isNullAt(5)).isTrue(); + assertThat(actual.value().getString(6).toString()).isEqualTo("clustering-field"); + } + + private KeyValue readSingle(KeyValueFileReaderFactory readerFactory, DataFileMeta file) + throws Exception { + try (RecordReaderIterator iterator = + new RecordReaderIterator<>(readerFactory.createRecordReader(file))) { + assertThat(iterator.hasNext()).isTrue(); + KeyValue result = + iterator.next() + .copy( + TestKeyValueGenerator.KEY_SERIALIZER, + TestKeyValueGenerator.DEFAULT_ROW_SERIALIZER); + assertThat(iterator.hasNext()).isFalse(); + return result; + } + } + @ParameterizedTest @ValueSource(strings = {"parquet", "orc", "avro"}) public void testReaderUseFileSizeFromMetadata(String format) throws Exception { diff --git a/paimon-core/src/test/java/org/apache/paimon/io/PrimaryKeyBlobFileWriterTest.java b/paimon-core/src/test/java/org/apache/paimon/io/PrimaryKeyBlobFileWriterTest.java index d7f4d2ee0e54..f706139c81b8 100644 --- a/paimon-core/src/test/java/org/apache/paimon/io/PrimaryKeyBlobFileWriterTest.java +++ b/paimon-core/src/test/java/org/apache/paimon/io/PrimaryKeyBlobFileWriterTest.java @@ -33,6 +33,7 @@ import org.apache.paimon.fs.local.LocalFileIO; import org.apache.paimon.manifest.FileSource; import org.apache.paimon.options.Options; +import org.apache.paimon.schema.TableSchema; import org.apache.paimon.table.SpecialFields; import org.apache.paimon.types.DataField; import org.apache.paimon.types.DataTypes; @@ -91,10 +92,19 @@ void testAttachReferenceSidecarToDataFile() throws Exception { Options options = new Options(); options.set(CoreOptions.BLOB_FIELD, "payload"); options.set(CoreOptions.BLOB_DESCRIPTOR_FIELD, "descriptor"); + TableSchema schema = + new TableSchema( + 0, + valueType.getFields(), + 2, + Collections.emptyList(), + Collections.singletonList("id"), + options.toMap(), + ""); KeyValueFileWriterFactory factory = KeyValueFileWriterFactory.builder( fileIO, - 0, + schema, keyType, valueType, new FlushingFileFormat("avro"), @@ -117,10 +127,12 @@ void testAttachReferenceSidecarToDataFile() throws Exception { factory.createRollingMergeTreeFileWriter(0, FileSource.APPEND); writer.write(new KeyValue().replace(GenericRow.of(1), 0, RowKind.INSERT, externalized)); + writer.write(new KeyValue().replace(GenericRow.of(1), 1, RowKind.DELETE, externalized)); writer.close(); factory.prepareCommit(); DataFileMeta meta = writer.result().get(0); + assertThat(meta.deleteRowCount()).hasValue(1L); assertThat(meta.extraFiles()).singleElement().asString().endsWith(".blobref"); Path sidecar = pathFactory.toAlignedPath(meta.extraFiles().get(0), meta); assertThat(ManagedBlobReferenceFile.read(fileIO, sidecar)) diff --git a/paimon-core/src/test/java/org/apache/paimon/schema/SchemaValidationTest.java b/paimon-core/src/test/java/org/apache/paimon/schema/SchemaValidationTest.java index 54a7088950bc..63df74f1e31a 100644 --- a/paimon-core/src/test/java/org/apache/paimon/schema/SchemaValidationTest.java +++ b/paimon-core/src/test/java/org/apache/paimon/schema/SchemaValidationTest.java @@ -28,7 +28,9 @@ import org.assertj.core.api.ThrowableAssert; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; import java.time.LocalDateTime; @@ -37,6 +39,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Stream; import static java.util.Collections.emptyList; import static java.util.Collections.singletonList; @@ -55,6 +58,105 @@ class SchemaValidationTest { + @ParameterizedTest + @CsvSource({ + "deduplicate, 1", + "deduplicate, -1", + "partial-update, 1", + "partial-update, -1", + "aggregation, 1", + "aggregation, -1" + }) + void testDeleteRowThinModeSupportedMergeEngines(String mergeEngine, int bucket) { + Map options = new HashMap<>(); + options.put(CoreOptions.DELETE_ROW_THIN_MODE.key(), "true"); + options.put(CoreOptions.MERGE_ENGINE.key(), mergeEngine); + options.put(BUCKET.key(), String.valueOf(bucket)); + if (mergeEngine.equals("partial-update")) { + options.put(CoreOptions.PARTIAL_UPDATE_REMOVE_RECORD_ON_DELETE.key(), "true"); + } else if (mergeEngine.equals("aggregation")) { + options.put(CoreOptions.AGGREGATION_REMOVE_RECORD_ON_DELETE.key(), "true"); + } + + // All fields are required, so enabling the option is valid even without fields to project. + assertThatCode(() -> validateTableSchema(deleteRowThinModeSchema(options, true))) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @MethodSource("unsupportedDeleteRowThinModeMergeEngines") + void testDeleteRowThinModeRequiresWholeRecordDeletes(String mergeEngine, String message) { + Map options = new HashMap<>(); + options.put(BUCKET.key(), "1"); + options.put(CoreOptions.MERGE_ENGINE.key(), mergeEngine); + assertThatCode(() -> validateTableSchema(deleteRowThinModeSchema(options, true))) + .doesNotThrowAnyException(); + options.put(CoreOptions.DELETE_ROW_THIN_MODE.key(), "false"); + assertThatCode(() -> validateTableSchema(deleteRowThinModeSchema(options, true))) + .doesNotThrowAnyException(); + + options.put(CoreOptions.DELETE_ROW_THIN_MODE.key(), "true"); + assertThatThrownBy(() -> validateTableSchema(deleteRowThinModeSchema(options, true))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(message); + } + + private static Stream unsupportedDeleteRowThinModeMergeEngines() { + return Stream.of( + Arguments.of( + "partial-update", + "'delete-row.thin-mode' requires 'partial-update.remove-record-on-delete' = true when 'merge-engine' = 'partial-update'."), + Arguments.of( + "aggregation", + "'delete-row.thin-mode' requires 'aggregation.remove-record-on-delete' = true when 'merge-engine' = 'aggregation'."), + Arguments.of( + "first-row", + "'delete-row.thin-mode' does not support merge engine 'first-row'.")); + } + + @ParameterizedTest + @ValueSource(strings = {"none", "input"}) + void testDeleteRowThinModeRejectsPostponeBucket(String changelogProducer) { + Map options = new HashMap<>(); + options.put(BUCKET.key(), "-2"); + options.put(CoreOptions.CHANGELOG_PRODUCER.key(), changelogProducer); + assertThatCode(() -> validateTableSchema(deleteRowThinModeSchema(options, true))) + .doesNotThrowAnyException(); + + options.put(CoreOptions.DELETE_ROW_THIN_MODE.key(), "true"); + assertThatThrownBy(() -> validateTableSchema(deleteRowThinModeSchema(options, true))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "'delete-row.thin-mode' does not support postpone bucket tables (bucket = -2)."); + } + + @Test + void testDeleteRowThinModeRequiresPrimaryKey() { + Map options = new HashMap<>(); + options.put(BUCKET.key(), "-1"); + assertThatCode(() -> validateTableSchema(deleteRowThinModeSchema(options, false))) + .doesNotThrowAnyException(); + + options.put(CoreOptions.DELETE_ROW_THIN_MODE.key(), "true"); + assertThatThrownBy(() -> validateTableSchema(deleteRowThinModeSchema(options, false))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "'delete-row.thin-mode' can only be enabled for a table with primary keys."); + } + + private TableSchema deleteRowThinModeSchema(Map options, boolean primaryKey) { + return new TableSchema( + 0, + Arrays.asList( + new DataField(0, "id", DataTypes.INT().notNull()), + new DataField(1, "value", DataTypes.INT().notNull())), + 1, + emptyList(), + primaryKey ? singletonList("id") : emptyList(), + options, + ""); + } + @Test void testNullablePrimaryKeyRequiresPrimaryKeyTable() { Map options = new HashMap<>(); diff --git a/paimon-core/src/test/java/org/apache/paimon/table/DeleteRowFieldsTest.java b/paimon-core/src/test/java/org/apache/paimon/table/DeleteRowFieldsTest.java new file mode 100644 index 000000000000..9ec3aa0259c0 --- /dev/null +++ b/paimon-core/src/test/java/org/apache/paimon/table/DeleteRowFieldsTest.java @@ -0,0 +1,210 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.paimon.table; + +import org.apache.paimon.CoreOptions; +import org.apache.paimon.catalog.Catalog; +import org.apache.paimon.data.BinaryRow; +import org.apache.paimon.data.BinaryString; +import org.apache.paimon.data.GenericRow; +import org.apache.paimon.data.InternalRow; +import org.apache.paimon.data.serializer.InternalRowSerializer; +import org.apache.paimon.predicate.Predicate; +import org.apache.paimon.predicate.PredicateBuilder; +import org.apache.paimon.reader.RecordReader; +import org.apache.paimon.schema.Schema; +import org.apache.paimon.schema.SchemaChange; +import org.apache.paimon.table.source.InnerTableRead; +import org.apache.paimon.types.DataTypes; +import org.apache.paimon.types.RowKind; +import org.apache.paimon.types.RowType; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** Tests for storing only required fields in primary-key DELETE rows. */ +public class DeleteRowFieldsTest extends TableTestBase { + + @Test + public void testPostponeBucketRejectsDeleteRowThinMode() throws Exception { + Schema.Builder builder = + Schema.newBuilder() + .column("id", DataTypes.INT()) + .column("value", DataTypes.INT()) + .primaryKey("id") + .option(CoreOptions.BUCKET.key(), "-2") + .option(CoreOptions.CHANGELOG_PRODUCER.key(), "input") + .option(CoreOptions.DELETE_ROW_THIN_MODE.key(), "true"); + String message = + "'delete-row.thin-mode' does not support postpone bucket tables (bucket = -2)."; + assertThatThrownBy(() -> catalog.createTable(identifier(), builder.build(), false)) + .hasRootCauseInstanceOf(IllegalArgumentException.class) + .hasRootCauseMessage(message); + assertThatThrownBy(() -> catalog.getTable(identifier())) + .isInstanceOf(Catalog.TableNotExistException.class); + + builder.option(CoreOptions.DELETE_ROW_THIN_MODE.key(), "false"); + catalog.createTable(identifier(), builder.build(), false); + FileStoreTable table = (FileStoreTable) catalog.getTable(identifier()); + assertThatThrownBy( + () -> + catalog.alterTable( + identifier(), + SchemaChange.setOption( + CoreOptions.DELETE_ROW_THIN_MODE.key(), "true"), + false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(message); + assertThat(((FileStoreTable) catalog.getTable(identifier())).schema()) + .isEqualTo(table.schema()); + + assertThatThrownBy( + () -> + table.copy( + Collections.singletonMap( + CoreOptions.DELETE_ROW_THIN_MODE.key(), "true"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(message); + } + + @ParameterizedTest + @CsvSource({ + "deduplicate, false", + "deduplicate, true", + "partial-update, false", + "partial-update, true", + "aggregation, false", + "aggregation, true" + }) + public void testDeleteReadFilterCompactionAndReinsert(String mergeEngine, boolean thinMode) + throws Exception { + FileStoreTable table = createTable(mergeEngine, thinMode); + write(table, row(RowKind.INSERT, 1L, "old-payload")); + write(table, row(RowKind.DELETE, 2L, "old-payload")); + + assertThat(read(table)).isEmpty(); + + List deletes = readKeepingDeletes(table); + assertThat(deletes).hasSize(1); + InternalRow delete = deletes.get(0); + assertThat(delete.getRowKind()).isEqualTo(RowKind.DELETE); + assertThat(delete.getInt(0)).isEqualTo(1); + assertThat(delete.getInt(1)).isEqualTo(1); + assertThat(delete.getLong(2)).isEqualTo(2L); + assertThat(delete.getString(3).toString()).isEqualTo("required-value"); + if (thinMode) { + assertThat(delete.isNullAt(4)).isTrue(); + } else { + assertThat(delete.getString(4).toString()).isEqualTo("old-payload"); + } + + Predicate payloadFilter = + new PredicateBuilder(table.rowType()) + .equal(4, BinaryString.fromString("old-payload")); + assertThat(readWithFilter(table, payloadFilter)).isEmpty(); + + FileStoreTable compactionTable = + (FileStoreTable) + table.copy(Collections.singletonMap(CoreOptions.WRITE_ONLY.key(), "false")); + compact(compactionTable, partition(1), 0); + assertThat(read(compactionTable)).isEmpty(); + + write(compactionTable, row(RowKind.INSERT, 3L, "new-payload")); + List rows = read(compactionTable); + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getLong(2)).isEqualTo(3L); + assertThat(rows.get(0).getString(4).toString()).isEqualTo("new-payload"); + } + + private FileStoreTable createTable(String mergeEngine, boolean thinMode) throws Exception { + Schema.Builder builder = + Schema.newBuilder() + .column("pt", DataTypes.INT()) + .column("k", DataTypes.INT()) + .column("seq", DataTypes.BIGINT()) + .column("required_value", DataTypes.STRING().notNull()) + .column("payload", DataTypes.STRING()) + .partitionKeys("pt") + .primaryKey("pt", "k") + .option(CoreOptions.BUCKET.key(), "1") + .option(CoreOptions.WRITE_ONLY.key(), "true") + .option(CoreOptions.SEQUENCE_FIELD.key(), "seq") + .option(CoreOptions.DATA_FILE_THIN_MODE.key(), "true") + .option(CoreOptions.MERGE_ENGINE.key(), mergeEngine); + if (thinMode) { + builder.option(CoreOptions.DELETE_ROW_THIN_MODE.key(), "true"); + } + if (mergeEngine.equals("partial-update")) { + builder.option(CoreOptions.PARTIAL_UPDATE_REMOVE_RECORD_ON_DELETE.key(), "true"); + } else if (mergeEngine.equals("aggregation")) { + builder.option(CoreOptions.AGGREGATION_REMOVE_RECORD_ON_DELETE.key(), "true"); + } + catalog.createTable(identifier(), builder.build(), false); + return (FileStoreTable) catalog.getTable(identifier()); + } + + private GenericRow row(RowKind kind, long sequence, String payload) { + return GenericRow.ofKind( + kind, + 1, + 1, + sequence, + BinaryString.fromString("required-value"), + BinaryString.fromString(payload)); + } + + private BinaryRow partition(int value) { + InternalRowSerializer serializer = + new InternalRowSerializer(RowType.of(DataTypes.INT().notNull())); + return serializer.toBinaryRow(GenericRow.of(value)).copy(); + } + + private List readKeepingDeletes(FileStoreTable table) throws Exception { + InnerTableRead read = table.newRead().forceKeepDelete(); + return read(table, read, null); + } + + private List readWithFilter(FileStoreTable table, Predicate predicate) + throws Exception { + InnerTableRead read = table.newRead().withFilter(predicate); + return read(table, read, predicate); + } + + private List read(FileStoreTable table, InnerTableRead read, Predicate predicate) + throws Exception { + List rows = new ArrayList<>(); + InternalRowSerializer serializer = new InternalRowSerializer(table.rowType()); + try (RecordReader reader = + read.createReader( + predicate == null + ? table.newScan().plan() + : table.newScan().withFilter(predicate).plan())) { + reader.forEachRemaining(row -> rows.add(serializer.copy(row))); + } + return rows; + } +} diff --git a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeySortedIndexReadTest.java b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeySortedIndexReadTest.java index c76ba9ddb7b6..bde6879c054c 100644 --- a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeySortedIndexReadTest.java +++ b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeySortedIndexReadTest.java @@ -42,6 +42,7 @@ import org.apache.paimon.table.sink.TableCommitImpl; import org.apache.paimon.table.sink.TableWriteImpl; import org.apache.paimon.types.DataTypes; +import org.apache.paimon.types.RowKind; import org.apache.paimon.shade.guava30.com.google.common.util.concurrent.MoreExecutors; @@ -76,7 +77,11 @@ protected Schema schemaDefault() { @Test void testDeletionVectorAndResidualPredicateRemainActive() throws Exception { createTableDefault(); - FileStoreTable table = getTableDefault(); + FileStoreTable table = + getTableDefault() + .copy( + Collections.singletonMap( + CoreOptions.DELETE_ROW_THIN_MODE.key(), "true")); BinaryString keep = BinaryString.fromString("keep"); BinaryString drop = BinaryString.fromString("drop"); write(table, ioManager, GenericRow.of(1, 10, keep), GenericRow.of(2, 10, drop)); @@ -89,10 +94,30 @@ void testDeletionVectorAndResidualPredicateRemainActive() throws Exception { .newIndexFileHandler() .scanSourceIndexes(compactedSnapshot, BinaryRow.EMPTY_ROW, 0)) .isNotEmpty(); - write( - table, - ioManager, - GenericRow.ofKind(org.apache.paimon.types.RowKind.DELETE, 3, 10, keep)); + FileStoreTable writeOnlyTable = + table.copy(Collections.singletonMap(CoreOptions.WRITE_ONLY.key(), "true")); + write(writeOnlyTable, ioManager, GenericRow.ofKind(RowKind.DELETE, 3, 10, keep)); + + // Verify that the indexed value is cleared in the persisted DELETE before compaction. + List deletedIds = new ArrayList<>(); + try (RecordReader reader = + writeOnlyTable + .newRead() + .forceKeepDelete() + .createReader( + writeOnlyTable + .newScan() + .withLevelFilter(level -> level == 0) + .plan())) { + reader.forEachRemaining( + row -> { + assertThat(row.getRowKind()).isEqualTo(RowKind.DELETE); + assertThat(row.isNullAt(1)).isTrue(); + deletedIds.add(row.getInt(0)); + }); + } + assertThat(deletedIds).containsExactly(3); + compact(table, BinaryRow.EMPTY_ROW, 0, ioManager, false); Snapshot snapshot = table.store().snapshotManager().latestSnapshot(); List payloads =