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 a6e6647c5b [core] Support incremental clustering for Data Evolution
tables (#10680)
a6e6647c5b is described below
commit a6e6647c5b8417130ea11bc139c498394522986c
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Oct 20 12:44:42 2026 +0800
[core] Support incremental clustering for Data Evolution tables (#10680)
---
docs/docs/append-table/incremental-clustering.mdx | 9 +-
.../data-evolution-maintenance.mdx | 42 +
docs/docs/multimodal-table/data-evolution.mdx | 4 +-
.../DataEvolutionIncrementalClusterRewriter.java | 155 ++++
.../append/cluster/IncrementalClusterManager.java | 6 +
.../append/cluster/IncrementalClusterRewriter.java | 4 +-
.../paimon/operation/FileStoreCommitImpl.java | 2 +-
.../org/apache/paimon/schema/SchemaValidation.java | 13 +-
.../cluster/IncrementalClusterManagerTest.java | 27 +
.../dataevolution/DataEvolutionClusterTest.java | 882 +++++++++++++++++++++
.../apache/paimon/schema/SchemaValidationTest.java | 87 ++
.../apache/paimon/flink/action/CompactAction.java | 9 +-
.../flink/compact/IncrementalClusterCompact.java | 31 +-
.../DataEvolutionCommitPreparationOperator.java | 31 +-
.../paimon/flink/DataEvolutionDeleteSqlITCase.java | 337 +++++++-
.../action/IncrementalClusterActionITCase.java | 13 +
.../paimon/spark/procedure/CompactProcedure.java | 190 +++--
.../spark/procedure/CompactProcedureTestBase.scala | 334 +++++++-
18 files changed, 2089 insertions(+), 87 deletions(-)
diff --git a/docs/docs/append-table/incremental-clustering.mdx
b/docs/docs/append-table/incremental-clustering.mdx
index 25249a8f16..49f1763712 100644
--- a/docs/docs/append-table/incremental-clustering.mdx
+++ b/docs/docs/append-table/incremental-clustering.mdx
@@ -48,8 +48,10 @@ by a query, and does not replace SQL `ORDER BY`.
| Global/local sort mode | Configurable for batch clustering jobs. |
Clustering is performed within each partition and bucket; the global/local
option does not select this path. |
| Historical-partition auto-clustering | Supported. |
`clustering.history-partition.*` does not apply. |
-Data Evolution tables cannot enable incremental clustering. If streaming
consumers require ordered append reads from a
-bucketed table, keep that ordering and do not enable clustering.
+Data Evolution tables support incremental clustering without dedicated BLOB or
VECTOR files; see
+[Data Evolution
clustering](../multimodal-table/data-evolution-maintenance#incremental-clustering)
for its execution
+and row-ID behavior. If streaming consumers require ordered append reads from
a bucketed table, keep that ordering
+and do not enable clustering.
## Enable Incremental Clustering
@@ -134,6 +136,9 @@ For unaware-bucket tables, the mode controls how the
**selected files in each pa
Here, “global” refers to the selected clustering work within a partition. It
does not imply that all existing files or
all table partitions become globally ordered after an incremental run.
+Data Evolution uses the same sort modes. Its readers merge the latest column
values and apply deletion vectors
+for complete selected file groups before sorting. The rewritten rows receive
new row IDs at commit time.
+
## Run Incremental Clustering
Run explicit compact jobs in batch mode. Table options supply the clustering
columns and strategy. The examples below
diff --git a/docs/docs/multimodal-table/data-evolution-maintenance.mdx
b/docs/docs/multimodal-table/data-evolution-maintenance.mdx
index 7666899d42..682334e99b 100644
--- a/docs/docs/multimodal-table/data-evolution-maintenance.mdx
+++ b/docs/docs/multimodal-table/data-evolution-maintenance.mdx
@@ -33,6 +33,7 @@ have different effects on row IDs and dependent indexes.
| Reduce column-file fragmentation | [Ordinary
compaction](#ordinary-compaction) | Preserved |
| Resize large normal files | [Split during
compaction](#splitting-large-files-during-compaction) | Preserved |
| Remove logically deleted positions | [Materialize deletion
vectors](#materialize-deletion-vectors) | Reassigned in rewritten ranges |
+| Sort ordinary columns for pruning | [Incremental
clustering](#incremental-clustering) | Reassigned in rewritten groups |
For updates and logical deletes, see [Data Evolution](./data-evolution).
For the normal/dedicated range contract, see [File Layout and
Reads](./data-evolution-file-layout).
@@ -169,6 +170,46 @@ expiration to remove the files. The legacy
`data-evolution.compaction.rewrite-row-ids = true` setting is rejected; use the
materialization procedure instead.
+### Incremental Clustering
+
+Data Evolution tables can enable `clustering.incremental = true` with nonempty
+`clustering.columns` when they have no dedicated BLOB or VECTOR files. BLOB
+descriptor and view fields stored inline, and VECTOR columns stored in normal
+files, do not require dedicated files and remain supported.
+
+The existing [clustering strategies and file
selection](../append-table/incremental-clustering)
+choose the work. Selection includes every column version in each selected
+row-ID group. Readers merge the latest column values and apply logical deletes
+before the existing Spark or Flink clustering pipeline sorts the surviving rows
+and writes new normal files with new row IDs. Unselected groups keep their row
IDs.
+The output records the clustering columns and level for subsequent incremental
+selection. `clustering.incremental.mode` selects the same distributed global
sort
+or task-local sort used by ordinary append-table clustering.
+
+For example, enable clustering with Spark SQL and run an explicit batch
compact:
+
+```sql
+ALTER TABLE default.target_table SET TBLPROPERTIES (
+ 'clustering.incremental' = 'true',
+ 'clustering.columns' = 'id'
+);
+CALL sys.compact(table => 'default.target_table', compact_strategy => 'minor');
+```
+
+Use the corresponding Flink `ALTER TABLE ... SET` syntax and run the compact
job
+in batch mode. Explicit compact jobs use clustering while this option is
enabled.
+To consolidate column versions while preserving row IDs, disable clustering for
+the compact invocation with `options => 'clustering.incremental=false'`.
+
+Clustering removes the applied deletion vectors and drops global indexes in
+affected partitions because their row IDs no longer identify the same rows.
+Rebuild those indexes after clustering, or use `scalar-index.search-mode =
full`
+for complete scalar query results while only part of the table remains indexed.
+Concurrent updates or deletes that overlap the selected row-ID groups cause
+commit to fail, preventing stale rewrites from replacing newer values.
+Historical snapshots and tags retain
+the previous files until expiration.
+
### Row-ID Lifetime
| Operation | Effect on row IDs |
@@ -178,6 +219,7 @@ materialization procedure instead.
| Logical delete | Hides IDs without shifting the remaining rows. |
| Ordinary Data Evolution compaction | Preserves IDs, but may change
file-group boundaries. |
| Deletion-vector materialization | Assigns new IDs to surviving rows in the
rewritten ranges. |
+| Data Evolution incremental clustering | Assigns new IDs to surviving rows in
the selected groups. |
| Explicit `reassign_row_id` maintenance | Changes IDs by remapping file
metadata. |
| Overwrite or partition removal | Removes the replaced rows from the current
table state; do not assume their IDs remain usable. |
diff --git a/docs/docs/multimodal-table/data-evolution.mdx
b/docs/docs/multimodal-table/data-evolution.mdx
index c57b95a065..fbe2d4ac88 100644
--- a/docs/docs/multimodal-table/data-evolution.mdx
+++ b/docs/docs/multimodal-table/data-evolution.mdx
@@ -50,7 +50,9 @@ read work until compaction consolidates them.
Set both `row-tracking.enabled` and `data-evolution.enabled` when creating the
table. These options are immutable. Data Evolution requires an append table
with no primary key and `bucket = -1` (the default for an append table).
-`clustering.incremental` cannot be enabled with Data Evolution.
+[Incremental clustering](./data-evolution-maintenance#incremental-clustering)
is
+supported for tables without dedicated BLOB or VECTOR files. Clustering
rewrites
+the selected groups and assigns new row IDs.
The following examples assume a configured Paimon catalog and the `default`
database. Deletion vectors are enabled so the same table can also run the
diff --git
a/paimon-core/src/main/java/org/apache/paimon/append/cluster/DataEvolutionIncrementalClusterRewriter.java
b/paimon-core/src/main/java/org/apache/paimon/append/cluster/DataEvolutionIncrementalClusterRewriter.java
new file mode 100644
index 0000000000..238c4057ea
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/append/cluster/DataEvolutionIncrementalClusterRewriter.java
@@ -0,0 +1,155 @@
+/*
+ * 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.append.cluster;
+
+import org.apache.paimon.Snapshot;
+import
org.apache.paimon.append.dataevolution.DataEvolutionCompactDeletionVectorRewriter;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.deletionvectors.append.AppendDeleteFileMaintainer;
+import org.apache.paimon.io.CompactIncrement;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataIncrement;
+import org.apache.paimon.mergetree.LevelSortedRun;
+import org.apache.paimon.mergetree.SortedRun;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.table.sink.CommitMessageImpl;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.SplitGenerator;
+import org.apache.paimon.utils.RangeHelper;
+
+import javax.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.apache.paimon.format.blob.BlobFileFormat.isBlobFile;
+import static org.apache.paimon.types.VectorType.isVectorStoreFile;
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Preserves complete row-id groups and materializes deletion vectors for
clustering. */
+class DataEvolutionIncrementalClusterRewriter extends
IncrementalClusterRewriter {
+
+ private final FileStoreTable table;
+ private final Snapshot snapshot;
+
+ DataEvolutionIncrementalClusterRewriter(FileStoreTable table, Snapshot
snapshot) {
+ this.table = table;
+ this.snapshot = snapshot;
+ }
+
+ @Override
+ List<LevelSortedRun> constructPartitionLevels(List<DataFileMeta> files) {
+ checkArgument(
+ files.stream()
+ .noneMatch(
+ file ->
+ isBlobFile(file.fileName())
+ ||
isVectorStoreFile(file.fileName())),
+ "Data evolution incremental clustering does not support
dedicated BLOB/VECTOR files.");
+ RangeHelper<DataFileMeta> helper = new
RangeHelper<>(DataFileMeta::nonNullRowIdRange);
+ List<LevelSortedRun> runs = new ArrayList<>();
+ Map<Integer, List<DataFileMeta>> higherLevels = new HashMap<>();
+ for (List<DataFileMeta> group : helper.mergeOverlappingRanges(files)) {
+ checkArgument(
+ helper.areAllRangesSame(group), "Normal files must have
equal row-id ranges.");
+ // A partial update makes the complete group eligible again,
including its base.
+ int level =
group.stream().mapToInt(DataFileMeta::level).min().getAsInt();
+ if (level == 0) {
+ runs.add(new LevelSortedRun(0, SortedRun.fromSorted(group)));
+ } else {
+ higherLevels.computeIfAbsent(level, ignored -> new
ArrayList<>()).addAll(group);
+ }
+ }
+ higherLevels.forEach(
+ (level, group) -> runs.add(new LevelSortedRun(level,
SortedRun.fromSorted(group))));
+ runs.sort(Comparator.comparingInt(LevelSortedRun::level));
+ return runs;
+ }
+
+ @Override
+ void configureSplit(
+ DataSplit.Builder builder,
+ SplitGenerator.SplitGroup splitGroup,
+ @Nullable AppendDeleteFileMaintainer dvMaintainer) {
+ builder.rawConvertible(false);
+ if (dvMaintainer != null) {
+ builder.withDataDeletionFiles(
+ splitGroup.files.stream()
+ .map(file ->
dvMaintainer.getDeletionFile(file.fileName()))
+ .collect(Collectors.toList()));
+ }
+ }
+
+ @Override
+ @Nullable
+ CommitMessage finishPartition(
+ @Nullable AppendDeleteFileMaintainer dvMaintainer, int
totalBuckets) {
+ // Keep source deletion vectors intact until the rewrite commits.
+ return null;
+ }
+
+ @Override
+ public List<CommitMessage> prepareCommitMessages(List<CommitMessage>
messages) {
+ Map<BinaryRow, CompactIncrement> increments = new HashMap<>();
+ for (CommitMessage message : messages) {
+ CommitMessageImpl impl = (CommitMessageImpl) message;
+ checkArgument(
+ impl.bucket() == 0 && impl.newFilesIncrement().isEmpty(),
+ "Data evolution clustering requires compact messages for
unaware bucket 0.");
+ CompactIncrement merged =
+ increments.computeIfAbsent(
+ message.partition(),
+ ignored ->
+ new CompactIncrement(
+ new ArrayList<>(),
+ new ArrayList<>(),
+ new ArrayList<>(),
+ new ArrayList<>(),
+ new ArrayList<>()));
+ CompactIncrement increment = impl.compactIncrement();
+ merged.compactBefore().addAll(increment.compactBefore());
+ merged.compactAfter().addAll(increment.compactAfter());
+ merged.changelogFiles().addAll(increment.changelogFiles());
+ merged.newIndexFiles().addAll(increment.newIndexFiles());
+ merged.deletedIndexFiles().addAll(increment.deletedIndexFiles());
+ }
+ List<CommitMessage> prepared = new ArrayList<>();
+ increments.forEach(
+ (partition, increment) -> {
+ checkArgument(
+ !increment.compactBefore().isEmpty(),
+ "Data evolution clustering output must include its
source files.");
+ prepared.add(
+ new CommitMessageImpl(
+ partition, 0, null,
DataIncrement.emptyIncrement(), increment));
+ });
+ // The committer invalidates global indexes against the latest
snapshot on every attempt.
+ prepared.addAll(
+ new DataEvolutionCompactDeletionVectorRewriter(table, snapshot)
+ .rewriteDeletionVectors(prepared));
+ return prepared.stream()
+ .map(message -> ((CommitMessageImpl)
message).withCheckFromSnapshot(snapshot.id()))
+ .collect(Collectors.toList());
+ }
+}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/append/cluster/IncrementalClusterManager.java
b/paimon-core/src/main/java/org/apache/paimon/append/cluster/IncrementalClusterManager.java
index 2fa240e028..6c196c3b04 100644
---
a/paimon-core/src/main/java/org/apache/paimon/append/cluster/IncrementalClusterManager.java
+++
b/paimon-core/src/main/java/org/apache/paimon/append/cluster/IncrementalClusterManager.java
@@ -43,6 +43,7 @@ import org.slf4j.LoggerFactory;
import javax.annotation.Nullable;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -115,6 +116,10 @@ public class IncrementalClusterManager {
}
public Map<BinaryRow, CompactUnit> createCompactUnits(boolean
fullCompaction) {
+ if (snapshot == null) {
+ return Collections.emptyMap();
+ }
+
// 1. construct LSM structure for each partition
Map<BinaryRow, List<LevelSortedRun>> partitionLevels =
constructLevels();
logForPartitionLevel(partitionLevels, partitionComputer);
@@ -186,6 +191,7 @@ public class IncrementalClusterManager {
DataSplit.Builder builder =
DataSplit.builder()
+ .withSnapshot(snapshot.id())
.withPartition(partition)
.withBucket(0)
.withTotalBuckets(1)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/append/cluster/IncrementalClusterRewriter.java
b/paimon-core/src/main/java/org/apache/paimon/append/cluster/IncrementalClusterRewriter.java
index 91fd178567..d021e2c815 100644
---
a/paimon-core/src/main/java/org/apache/paimon/append/cluster/IncrementalClusterRewriter.java
+++
b/paimon-core/src/main/java/org/apache/paimon/append/cluster/IncrementalClusterRewriter.java
@@ -47,7 +47,9 @@ import java.util.stream.Collectors;
public class IncrementalClusterRewriter {
public static IncrementalClusterRewriter create(FileStoreTable table,
Snapshot snapshot) {
- return new IncrementalClusterRewriter();
+ return table.coreOptions().dataEvolutionEnabled()
+ ? new DataEvolutionIncrementalClusterRewriter(table, snapshot)
+ : new IncrementalClusterRewriter();
}
List<LevelSortedRun> constructPartitionLevels(List<DataFileMeta>
partitionFiles) {
diff --git
a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
index ffe79cf4bb..d1573b9412 100644
---
a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
+++
b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
@@ -836,7 +836,7 @@ public class FileStoreCommitImpl implements FileStoreCommit
{
}
private Set<Pair<BinaryRow, Integer>>
materializedBuckets(List<CommitMessage> commitMessages) {
- if (!options.dataEvolutionEnabled() ||
!options.deletionVectorsEnabled()) {
+ if (!options.dataEvolutionEnabled()) {
return Collections.emptySet();
}
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 c98600f7ee..9aaca5f6d9 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
@@ -1628,9 +1628,6 @@ public class SchemaValidation {
checkArgument(
rowTrackingEnabled,
"Data evolution config must enabled with
row-tracking.enabled");
- checkArgument(
- !options.clusteringIncrementalEnabled(),
- "Data evolution config must disabled with
clustering.incremental");
}
if (options.dataEvolutionNestedFieldEnabled()) {
@@ -1925,6 +1922,16 @@ public class SchemaValidation {
schema.primaryKeys().isEmpty(),
"Cannot define %s for incremental clustering table.",
PRIMARY_KEY.key());
+ if (options.dataEvolutionEnabled()) {
+ checkArgument(
+ fieldNamesInBlobFile(schema.logicalRowType(),
options.blobInlineField())
+ .isEmpty(),
+ "Data evolution incremental clustering does not
support dedicated BLOB files.");
+ checkArgument(
+ vectorFileFormat(options) == null
+ || fieldsInVectorFile(schema.logicalRowType(),
true).isEmpty(),
+ "Data evolution incremental clustering does not
support dedicated VECTOR files.");
+ }
if (options.bucket() != -1) {
checkArgument(
!options.bucketAppendOrdered(),
diff --git
a/paimon-core/src/test/java/org/apache/paimon/append/cluster/IncrementalClusterManagerTest.java
b/paimon-core/src/test/java/org/apache/paimon/append/cluster/IncrementalClusterManagerTest.java
index 2997ec5a9e..484a206afd 100644
---
a/paimon-core/src/test/java/org/apache/paimon/append/cluster/IncrementalClusterManagerTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/append/cluster/IncrementalClusterManagerTest.java
@@ -43,6 +43,8 @@ import org.apache.paimon.types.RowType;
import org.assertj.core.util.Lists;
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.ValueSource;
import java.util.ArrayList;
import java.util.Collections;
@@ -73,6 +75,31 @@ public class IncrementalClusterManagerTest {
"Only support incremental clustering when
'clustering.incremental' is true.");
}
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ public void
testEmptyPlanningSnapshotDoesNotReadConcurrentFirstAppend(boolean dataEvolution)
+ throws Exception {
+ Map<String, String> options = new HashMap<>();
+ options.put(CoreOptions.WRITE_ONLY.key(), "true");
+ options.put(CoreOptions.DATA_EVOLUTION_ENABLED.key(),
String.valueOf(dataEvolution));
+ options.put(CoreOptions.ROW_TRACKING_ENABLED.key(),
String.valueOf(dataEvolution));
+ FileStoreTable table = createTable(options, Collections.emptyList());
+ assertThat(table.snapshotManager().latestSnapshot()).isNull();
+ IncrementalClusterManager manager = new
IncrementalClusterManager(table, null, null);
+
+ writeOnce(
+ table,
+ GenericRow.of(1, 2, BinaryString.fromString("a"),
BinaryString.fromString("b")));
+
+ assertThat(manager.createCompactUnits(true)).isEmpty();
+ assertThat(manager.createCompactUnits(false)).isEmpty();
+ assertThat(
+ new IncrementalClusterManager(
+ table, null,
table.snapshotManager().latestSnapshot())
+ .createCompactUnits(true))
+ .isNotEmpty();
+ }
+
@Test
public void testConstructPartitionLevels() {
// Create test files with different levels
diff --git
a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionClusterTest.java
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionClusterTest.java
new file mode 100644
index 0000000000..51e79a1c31
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionClusterTest.java
@@ -0,0 +1,882 @@
+/*
+ * 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.append.dataevolution;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.append.cluster.IncrementalClusterManager;
+import org.apache.paimon.append.cluster.IncrementalClusterRewriter;
+import org.apache.paimon.append.cluster.Sorter;
+import org.apache.paimon.catalog.RenamingSnapshotCommit;
+import org.apache.paimon.catalog.SnapshotCommit;
+import org.apache.paimon.compact.CompactUnit;
+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.deletionvectors.BitmapDeletionVector;
+import org.apache.paimon.deletionvectors.DeletionVector;
+import org.apache.paimon.deletionvectors.append.BaseAppendDeleteFileMaintainer;
+import org.apache.paimon.disk.IOManager;
+import org.apache.paimon.globalindex.GlobalIndexBuilderUtils;
+import org.apache.paimon.globalindex.sorted.SortedGlobalIndexScanner;
+import org.apache.paimon.globalindex.sorted.SortedGlobalIndexTestUtils;
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.io.CompactIncrement;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataIncrement;
+import org.apache.paimon.manifest.FileKind;
+import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.operation.Lock;
+import org.apache.paimon.operation.commit.RowIdExistenceConflictException;
+import org.apache.paimon.partition.PartitionPredicate;
+import org.apache.paimon.partition.PartitionStatistics;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.reader.RecordReaderIterator;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.table.CatalogEnvironment;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.FileStoreTableFactory;
+import org.apache.paimon.table.SpecialFields;
+import org.apache.paimon.table.TableTestBase;
+import org.apache.paimon.table.sink.BatchTableCommit;
+import org.apache.paimon.table.sink.BatchTableWrite;
+import org.apache.paimon.table.sink.BatchWriteBuilder;
+import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.table.sink.CommitMessageImpl;
+import org.apache.paimon.table.sink.TableCommitImpl;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.ReadBuilder;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.MutableObjectIterator;
+import org.apache.paimon.utils.Pair;
+import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.SnapshotManager;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+
+import static
org.apache.paimon.errors.ErrorMessages.DATA_EVOLUTION_ROW_ID_CONFLICT_MESSAGE;
+import static org.apache.paimon.table.BucketMode.UNAWARE_BUCKET;
+import static org.apache.paimon.utils.DataEvolutionUtils.retrieveAnchorFile;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Behavioral tests for sorting data-evolution rows into fresh row-ID ranges.
*/
+class DataEvolutionClusterTest extends TableTestBase {
+
+ @Test
+ void testClusterSortsLatestValuesAndAppliesDeletionVectors() throws
Exception {
+ FileStoreTable table = createTable(true);
+ append(table, "a", 30, 10, 20);
+ append(table, "a", 60, 40, 50);
+ updatePayload(table, "a", 0L, 30, 10, 20);
+ delete(table, "a", new Range(0, 2), 2L);
+
+ Snapshot snapshot = table.latestSnapshot().get();
+ List<CommitMessage> messages = prepareCluster(table, snapshot, null,
true);
+ assertThat(messages).isNotEmpty();
+ commitCluster(table, snapshot, messages);
+
+ assertThat(readRows(table))
+ .containsExactly(
+ "a|10|updated-10|6",
+ "a|30|updated-30|7",
+ "a|40|base-40|8",
+ "a|50|base-50|9",
+ "a|60|base-60|10");
+ assertThat(table.latestSnapshot().get().nextRowId()).isEqualTo(11L);
+ assertThat(
+
table.store().newIndexFileHandler().scanEntries().stream()
+ .filter(entry ->
entry.indexFile().globalIndexMeta() == null))
+ .isEmpty();
+ }
+
+ @Test
+ void testFullClusterSkipsAlreadyClusteredPartition() throws Exception {
+ FileStoreTable table = createTable(false);
+ append(table, "a", 30, 10, 20);
+ Snapshot snapshot = table.latestSnapshot().get();
+ commitCluster(table, snapshot, prepareCluster(table, snapshot, null,
true));
+ List<String> clusteredRows = readRows(table);
+ List<String> clusteredFiles = fileNames(table, "a");
+
+ Snapshot clusteredSnapshot = table.latestSnapshot().get();
+ assertThat(prepareCluster(table, clusteredSnapshot, null,
true)).isEmpty();
+ assertThat(readRows(table)).isEqualTo(clusteredRows);
+ assertThat(fileNames(table, "a")).isEqualTo(clusteredFiles);
+ }
+
+ @Test
+ void testClusterRemovesFullyDeletedGroup() throws Exception {
+ FileStoreTable table = createTable(true);
+ append(table, "a", 30, 10, 20);
+ delete(table, "a", new Range(0, 2), 0L, 1L, 2L);
+
+ Snapshot snapshot = table.latestSnapshot().get();
+ List<CommitMessage> messages = prepareCluster(table, snapshot, null,
true);
+ assertThat(messages).isNotEmpty();
+ commitCluster(table, snapshot, messages);
+
+ assertThat(readRows(table)).isEmpty();
+ assertThat(fileNames(table, "a")).isEmpty();
+
assertThat(table.store().newIndexFileHandler().scanEntries()).isEmpty();
+ assertThat(table.latestSnapshot().get().nextRowId()).isEqualTo(3L);
+ }
+
+ @Test
+ void testClusterHonorsOutputFileRowLimit() throws Exception {
+ FileStoreTable table = createTable(false);
+ append(table, "a", 70, 10, 50, 30, 60, 20, 40);
+ table =
table.copy(Collections.singletonMap(CoreOptions.TARGET_FILE_ROW_NUM.key(),
"2"));
+
+ Snapshot snapshot = table.latestSnapshot().get();
+ commitCluster(table, snapshot, prepareCluster(table, snapshot, null,
true));
+
+ List<DataFileMeta> files =
+
table.store().newScan().withSnapshot(table.latestSnapshot().get()).plan().files()
+ .stream()
+ .map(entry -> entry.file())
+ .collect(Collectors.toList());
+ assertThat(files).hasSize(4);
+ assertThat(files).allSatisfy(file ->
assertThat(file.rowCount()).isBetween(1L, 2L));
+ assertThat(
+ files.stream()
+ .map(DataFileMeta::nonNullRowIdRange)
+ .sorted(Comparator.comparingLong(range ->
range.from)))
+ .containsExactly(
+ new Range(7, 8), new Range(9, 10), new Range(11, 12),
new Range(13, 13));
+ assertThat(readRows(table))
+ .containsExactly(
+ "a|10|base-10|7",
+ "a|20|base-20|8",
+ "a|30|base-30|9",
+ "a|40|base-40|10",
+ "a|50|base-50|11",
+ "a|60|base-60|12",
+ "a|70|base-70|13");
+ }
+
+ @Test
+ void testPartialSortKeyUpdateDirtiesClusteredGroup() throws Exception {
+ FileStoreTable table = createTable(false);
+ append(table, "a", 30, 10, 20);
+ Snapshot snapshot = table.latestSnapshot().get();
+ commitCluster(table, snapshot, prepareCluster(table, snapshot, null,
true));
+
+ BatchWriteBuilder builder = table.newBatchWriteBuilder();
+ RowType writeType = table.rowType().project(Arrays.asList("pt", "id"));
+ try (BatchTableWrite write =
builder.newWrite().withWriteType(writeType);
+ BatchTableCommit commit = builder.newCommit()) {
+ for (int id : new int[] {50, 10, 30}) {
+ write.write(GenericRow.of(BinaryString.fromString("a"), id));
+ }
+ List<CommitMessage> updates = write.prepareCommit();
+ assignFirstRowId(updates, 3L);
+ commit.commit(updates);
+ }
+
+ Snapshot updated = table.latestSnapshot().get();
+ List<CommitMessage> messages = prepareCluster(table, updated, null,
true);
+ assertThat(messages).isNotEmpty();
+ commitCluster(table, updated, messages);
+
+ assertThat(readRows(table))
+ .containsExactly("a|10|base-20|6", "a|30|base-30|7",
"a|50|base-10|8");
+ }
+
+ @Test
+ void testIncrementalClusterWaitsForCompactionThreshold() throws Exception {
+ FileStoreTable table = createTable(false);
+ append(table, "a", 30, 10, 20);
+ assertThat(prepareCluster(table, table.latestSnapshot().get(), null,
false)).isEmpty();
+
+ append(table, "a", 60, 40, 50);
+ Snapshot snapshot = table.latestSnapshot().get();
+ List<CommitMessage> messages = prepareCluster(table, snapshot, null,
false);
+ assertThat(messages).isNotEmpty();
+ commitCluster(table, snapshot, messages);
+
+ assertThat(readRows(table))
+ .containsExactly(
+ "a|10|base-10|6",
+ "a|20|base-20|7",
+ "a|30|base-30|8",
+ "a|40|base-40|9",
+ "a|50|base-50|10",
+ "a|60|base-60|11");
+ }
+
+ @Test
+ void testMinorClusterSelectsCompleteDirtyGroupAndRetainsUntouchedGroup()
throws Exception {
+ Map<String, String> options = new HashMap<>();
+ options.put(CoreOptions.TARGET_FILE_ROW_NUM.key(), "3");
+ options.put(CoreOptions.FILE_COMPRESSION.key(), "none");
+
options.put(CoreOptions.COMPACTION_MAX_SIZE_AMPLIFICATION_PERCENT.key(),
"10000");
+ options.put(CoreOptions.COMPACTION_SIZE_RATIO.key(), "0");
+ FileStoreTable table = createTable(false).copy(options);
+ BatchWriteBuilder builder = table.newBatchWriteBuilder();
+ try (BatchTableWrite write = builder.newWrite();
+ BatchTableCommit commit = builder.newCommit()) {
+ for (int id : new int[] {60, 10, 50, 30, 20, 40}) {
+ Random random = new Random(id);
+ int length = id <= 30 ? 8192 : 131072;
+ StringBuilder payload = new StringBuilder(length);
+ for (int i = 0; i < length; i++) {
+ payload.append((char) ('a' + random.nextInt(26)));
+ }
+ write.write(
+ GenericRow.of(
+ BinaryString.fromString("a"),
+ id,
+ BinaryString.fromString(payload.toString())));
+ }
+ commit.commit(write.prepareCommit());
+ }
+ Snapshot initial = table.latestSnapshot().get();
+ commitCluster(table, initial, prepareCluster(table, initial, null,
true));
+
+ List<DataFileMeta> clusteredFiles =
+
table.store().newScan().withSnapshot(table.latestSnapshot().get()).plan().files()
+ .stream()
+ .map(entry -> entry.file())
+
.sorted(Comparator.comparingLong(DataFileMeta::nonNullFirstRowId))
+ .collect(Collectors.toList());
+ assertThat(clusteredFiles).hasSize(2);
+ DataFileMeta dirtyBase = clusteredFiles.get(0);
+ DataFileMeta untouchedBase = clusteredFiles.get(1);
+ assertThat(dirtyBase.nonNullRowIdRange()).isEqualTo(new Range(6, 8));
+ assertThat(untouchedBase.nonNullRowIdRange()).isEqualTo(new Range(9,
11));
+
assertThat(untouchedBase.fileSize()).isGreaterThan(dirtyBase.fileSize() * 8);
+ List<String> expected = new ArrayList<>(readRows(table).subList(3, 6));
+
+ updatePayload(table, "a", 6L, 10, 20, 30);
+ append(table, "a", 90, 70, 80);
+ Snapshot snapshot = table.latestSnapshot().get();
+ IncrementalClusterManager manager = new
IncrementalClusterManager(table, null, snapshot);
+ Map<BinaryRow, CompactUnit> units = manager.createCompactUnits(false);
+ assertThat(units).hasSize(1);
+ CompactUnit unit = units.get(partition(table, "a"));
+ assertThat(unit.files())
+ .extracting(DataFileMeta::fileName)
+ .contains(dirtyBase.fileName())
+ .doesNotContain(untouchedBase.fileName());
+ assertThat(unit.files())
+ .filteredOn(file -> file.nonNullRowIdRange().equals(new
Range(6, 8)))
+ .hasSize(2);
+ commitCluster(table, snapshot, rewriteClusterUnits(table, manager,
units));
+
+ expected.addAll(
+ Arrays.asList(
+ "a|10|updated-10|15",
+ "a|20|updated-20|16",
+ "a|30|updated-30|17",
+ "a|70|base-70|18",
+ "a|80|base-80|19",
+ "a|90|base-90|20"));
+ assertThat(readRows(table)).containsExactlyElementsOf(expected);
+ assertThat(fileNames(table, "a"))
+ .contains(untouchedBase.fileName())
+ .doesNotContain(dirtyBase.fileName());
+ }
+
+ @Test
+ void testConcurrentPartialUpdateRejectsStaleCluster() throws Exception {
+ FileStoreTable table = createTable(true);
+ append(table, "a", 30, 10, 20);
+ Snapshot snapshot = table.latestSnapshot().get();
+ List<CommitMessage> messages = prepareCluster(table, snapshot, null,
true);
+
+ updatePayload(table, "a", 0L, 30, 10, 20);
+ long updateSnapshot = table.latestSnapshot().get().id();
+ assertThatThrownBy(() -> commitCluster(table, snapshot, messages))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining(DATA_EVOLUTION_ROW_ID_CONFLICT_MESSAGE);
+
assertThat(table.latestSnapshot().get().id()).isEqualTo(updateSnapshot);
+ assertThat(readRows(table))
+ .containsExactly("a|30|updated-30|0", "a|10|updated-10|1",
"a|20|updated-20|2");
+ }
+
+ @Test
+ void testConcurrentDeleteRejectsStaleCluster() throws Exception {
+ FileStoreTable table = createTable(true);
+ append(table, "a", 30, 10, 20);
+ Snapshot snapshot = table.latestSnapshot().get();
+ List<CommitMessage> messages = prepareCluster(table, snapshot, null,
true);
+
+ delete(table, "a", new Range(0, 2), 1L);
+ long deleteSnapshot = table.latestSnapshot().get().id();
+ // The delete changes the anchor's DV metadata, so normal file
conflict detection rejects
+ // the stale removal before the additional row-ID conflict check is
reached.
+ assertThatThrownBy(() -> commitCluster(table, snapshot, messages))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("File deletion conflicts detected!");
+
assertThat(table.latestSnapshot().get().id()).isEqualTo(deleteSnapshot);
+ assertThat(readRows(table)).containsExactly("a|30|base-30|0",
"a|20|base-20|2");
+ }
+
+ @Test
+ void testConcurrentIndependentAppendIsPreserved() throws Exception {
+ FileStoreTable table = createTable(true);
+ append(table, "a", 30, 10, 20);
+ Snapshot snapshot = table.latestSnapshot().get();
+ List<CommitMessage> messages = prepareCluster(table, snapshot, null,
true);
+
+ append(table, "a", 60, 40, 50);
+ commitCluster(table, snapshot, messages);
+
+ assertThat(readRows(table))
+ .containsExactly(
+ "a|60|base-60|3",
+ "a|40|base-40|4",
+ "a|50|base-50|5",
+ "a|10|base-10|6",
+ "a|20|base-20|7",
+ "a|30|base-30|8");
+ }
+
+ @Test
+ void testPartitionClusterDropsOnlyAffectedGlobalIndexes() throws Exception
{
+ FileStoreTable table = createTable(true);
+ append(table, "a", 30, 10, 20);
+ append(table, "b", 60, 40, 50);
+ addGlobalIndex(table, "a", "index-a", new Range(0, 2), "id");
+ addGlobalIndex(table, "b", "index-b", new Range(3, 5), "id");
+ List<String> untouchedFiles = fileNames(table, "b");
+
+ Snapshot snapshot = table.latestSnapshot().get();
+ List<CommitMessage> messages =
+ prepareCluster(table, snapshot, partitionPredicate(table,
"a"), true);
+ addGlobalIndex(table, "a", "concurrent-index-a", new Range(0, 2),
"payload");
+ assertThat(globalIndexNames(table))
+ .containsExactly("concurrent-index-a", "index-a", "index-b");
+ commitCluster(table, snapshot, messages);
+
+ assertThat(globalIndexNames(table)).containsExactly("index-b");
+ assertThat(fileNames(table, "b")).isEqualTo(untouchedFiles);
+ assertThat(readRows(table))
+ .containsExactly(
+ "b|60|base-60|3",
+ "b|40|base-40|4",
+ "b|50|base-50|5",
+ "a|10|base-10|6",
+ "a|20|base-20|7",
+ "a|30|base-30|8");
+ }
+
+ @Test
+ void testWithoutDeletionVectorsDropsIndexCommittedAfterPreparation()
throws Exception {
+ FileStoreTable table = createTable(false);
+ append(table, "a", 30, 10, 20);
+ addGlobalIndex(table, "a", "index-before-plan", new Range(0, 2), "id");
+ Snapshot snapshot = table.latestSnapshot().get();
+ List<CommitMessage> messages = prepareCluster(table, snapshot, null,
true);
+ messages =
+ IncrementalClusterRewriter.create(table,
snapshot).prepareCommitMessages(messages);
+
+ addGlobalIndex(table, "a", "index-after-preparation", new Range(0, 2),
"payload");
+ assertThat(globalIndexNames(table))
+ .containsExactly("index-after-preparation",
"index-before-plan");
+ commitChecked(table, messages);
+
+ assertThat(globalIndexNames(table)).isEmpty();
+ assertThat(readRows(table))
+ .containsExactly("a|10|base-10|3", "a|20|base-20|4",
"a|30|base-30|5");
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testClusterRetryDropsIndexCommittedBeforeSnapshotPublication(boolean
deletionVectors)
+ throws Exception {
+ Map<String, String> retryOptions = new HashMap<>();
+ retryOptions.put(CoreOptions.COMMIT_MIN_RETRY_WAIT.key(), "0ms");
+ retryOptions.put(CoreOptions.COMMIT_MAX_RETRY_WAIT.key(), "0ms");
+ FileStoreTable original =
createTable(deletionVectors).copy(retryOptions);
+ append(original, "a", 30, 10, 20);
+ if (deletionVectors) {
+ delete(original, "a", new Range(0, 2), 1L);
+ }
+ commit(original, prepareGlobalIndex(original,
original.latestSnapshot().get(), "id"));
+ Snapshot scanSnapshot = original.latestSnapshot().get();
+ List<CommitMessage> messages = prepareCluster(original, scanSnapshot,
null, true);
+ AtomicInteger attempts = new AtomicInteger();
+ CatalogEnvironment environment =
+ new CatalogEnvironment(null, null, null, null, null, null,
false, false) {
+ @Override
+ public SnapshotCommit snapshotCommit(
+ SnapshotManager manager, CoreOptions options,
String commitUser) {
+ SnapshotCommit delegate = new
RenamingSnapshotCommit(manager, Lock.empty());
+ return new SnapshotCommit(Lock.empty()) {
+ @Override
+ protected boolean commitImpl(
+ String baseUuid,
+ Snapshot snapshot,
+ String branch,
+ List<PartitionStatistics> statistics)
+ throws Exception {
+ assertThat(snapshot.commitKind())
+
.isEqualTo(Snapshot.CommitKind.COMPACT);
+ int attempt = attempts.incrementAndGet();
+ // Index cleanup has already been prepared for
this candidate.
+ assertThat(
+ original.store()
+
.indexManifestFileFactory()
+ .create()
+
.read(snapshot.indexManifest()))
+ .isEmpty();
+ if (attempt == 1) {
+
assertThat(original.latestSnapshot().get().id())
+ .isEqualTo(scanSnapshot.id());
+ DataEvolutionClusterTest.this.commit(
+ original,
+ prepareGlobalIndex(original,
scanSnapshot, "payload"));
+
assertThat(globalIndexNames(original)).hasSize(2);
+ }
+ boolean committed =
+ delegate.commit(baseUuid, snapshot,
branch, statistics);
+ assertThat(committed).isEqualTo(attempt == 2);
+ return committed;
+ }
+
+ @Override
+ protected void closeImpl() throws Exception {
+ delegate.close();
+ }
+ };
+ }
+ };
+ FileStoreTable committingTable =
+ FileStoreTableFactory.create(
+ original.fileIO(), original.location(),
original.schema(), environment);
+ commitCluster(committingTable, scanSnapshot, messages);
+
+ assertThat(attempts).hasValue(2);
+
assertThat(original.latestSnapshot().get().id()).isEqualTo(scanSnapshot.id() +
2);
+ assertThat(original.latestSnapshot().get().nextRowId())
+ .isEqualTo(deletionVectors ? 5L : 6L);
+ assertThat(globalIndexNames(original)).isEmpty();
+ assertThat(readRows(original))
+ .containsExactlyElementsOf(
+ deletionVectors
+ ? Arrays.asList("a|20|base-20|3",
"a|30|base-30|4")
+ : Arrays.asList(
+ "a|10|base-10|3", "a|20|base-20|4",
"a|30|base-30|5"));
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testClusterRejectsPreviouslyPreparedPartialUpdate(boolean
deletionVectors)
+ throws Exception {
+ FileStoreTable table = createTable(deletionVectors);
+ append(table, "a", 30, 10, 20);
+ Snapshot scanSnapshot = table.latestSnapshot().get();
+ List<CommitMessage> updates =
+ preparePayloadUpdate(table, "a", 0L, 30, 10, 20).stream()
+ .map(
+ message ->
+ ((CommitMessageImpl) message)
+
.withCheckFromSnapshot(scanSnapshot.id()))
+ .collect(Collectors.toList());
+ commitCluster(table, scanSnapshot, prepareCluster(table, scanSnapshot,
null, true));
+ commit(table, prepareGlobalIndex(table, table.latestSnapshot().get(),
"id"));
+
+ assertStaleCommitPreservesCluster(
+ table, updates, RowIdExistenceConflictException.class, "Row ID
existence conflict");
+ }
+
+ @Test
+ void testClusterRejectsPreviouslyPreparedDelete() throws Exception {
+ FileStoreTable table = createTable(true);
+ append(table, "a", 30, 10, 20);
+ Snapshot scanSnapshot = table.latestSnapshot().get();
+ CommitMessage deletion =
+ ((CommitMessageImpl) prepareDelete(table, "a", new Range(0,
2), 1L))
+ .withCheckFromSnapshot(scanSnapshot.id());
+ commitCluster(table, scanSnapshot, prepareCluster(table, scanSnapshot,
null, true));
+ commit(table, prepareGlobalIndex(table, table.latestSnapshot().get(),
"id"));
+
+ assertStaleCommitPreservesCluster(
+ table,
+ Collections.singletonList(deletion),
+ RuntimeException.class,
+ "File deletion conflicts detected!");
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testClusterRejectsPreviouslyBuiltGlobalIndex(boolean deletionVectors)
throws Exception {
+ FileStoreTable table = createTable(deletionVectors);
+ append(table, "a", 30, 10, 20);
+ Snapshot scanSnapshot = table.latestSnapshot().get();
+ List<CommitMessage> indexes = prepareGlobalIndex(table, scanSnapshot,
"id");
+ commitCluster(table, scanSnapshot, prepareCluster(table, scanSnapshot,
null, true));
+ commit(table, prepareGlobalIndex(table, table.latestSnapshot().get(),
"id"));
+
+ assertStaleCommitPreservesCluster(
+ table, indexes, RuntimeException.class, "Global index row ID
existence conflict");
+ }
+
+ private void assertStaleCommitPreservesCluster(
+ FileStoreTable table,
+ List<CommitMessage> staleMessages,
+ Class<? extends RuntimeException> exceptionClass,
+ String conflictMessage)
+ throws Exception {
+ Snapshot clustered = table.latestSnapshot().get();
+ List<String> files = fileNames(table, "a");
+ List<String> indexes = globalIndexNames(table);
+ assertThat(indexes).isNotEmpty();
+ assertThatThrownBy(() -> commit(table, staleMessages))
+ .isInstanceOf(exceptionClass)
+ .hasMessageContaining(conflictMessage);
+
assertThat(table.latestSnapshot().get().id()).isEqualTo(clustered.id());
+ assertThat(table.latestSnapshot().get().nextRowId()).isEqualTo(6L);
+ assertThat(fileNames(table, "a")).isEqualTo(files);
+ assertThat(globalIndexNames(table)).isEqualTo(indexes);
+ assertThat(readRows(table))
+ .containsExactly("a|10|base-10|3", "a|20|base-20|4",
"a|30|base-30|5");
+ }
+
+ private FileStoreTable createTable(boolean deletionVectors) throws
Exception {
+ Schema schema =
+ Schema.newBuilder()
+ .column("pt", DataTypes.STRING())
+ .column("id", DataTypes.INT())
+ .column("payload", DataTypes.STRING())
+ .partitionKeys("pt")
+ .option(CoreOptions.ROW_TRACKING_ENABLED.key(), "true")
+ .option(CoreOptions.DATA_EVOLUTION_ENABLED.key(),
"true")
+ .option(
+ CoreOptions.DELETION_VECTORS_ENABLED.key(),
+ String.valueOf(deletionVectors))
+ .option(CoreOptions.CLUSTERING_COLUMNS.key(), "id")
+ .option(CoreOptions.CLUSTERING_STRATEGY.key(), "order")
+ .option(CoreOptions.CLUSTERING_INCREMENTAL.key(),
"true")
+
.option(CoreOptions.NUM_SORTED_RUNS_COMPACTION_TRIGGER.key(), "2")
+ .build();
+ catalog.createTable(identifier(), schema, false);
+ return getTableDefault();
+ }
+
+ private void append(FileStoreTable table, String partition, int... values)
throws Exception {
+ BatchWriteBuilder builder = table.newBatchWriteBuilder();
+ try (BatchTableWrite write = builder.newWrite();
+ BatchTableCommit commit = builder.newCommit()) {
+ for (int value : values) {
+ write.write(
+ GenericRow.of(
+ BinaryString.fromString(partition),
+ value,
+ BinaryString.fromString("base-" + value)));
+ }
+ commit.commit(write.prepareCommit());
+ }
+ }
+
+ private void updatePayload(
+ FileStoreTable table, String partition, long firstRowId, int...
values)
+ throws Exception {
+ commit(table, preparePayloadUpdate(table, partition, firstRowId,
values));
+ }
+
+ private List<CommitMessage> preparePayloadUpdate(
+ FileStoreTable table, String partition, long firstRowId, int...
values)
+ throws Exception {
+ RowType writeType = table.rowType().project(Arrays.asList("pt",
"payload"));
+ BatchWriteBuilder builder = table.newBatchWriteBuilder();
+ try (BatchTableWrite write =
builder.newWrite().withWriteType(writeType)) {
+ for (int value : values) {
+ write.write(
+ GenericRow.of(
+ BinaryString.fromString(partition),
+ BinaryString.fromString("updated-" + value)));
+ }
+ List<CommitMessage> messages = write.prepareCommit();
+ assignFirstRowId(messages, firstRowId);
+ return messages;
+ }
+ }
+
+ private void assignFirstRowId(List<CommitMessage> messages, long
firstRowId) {
+ for (CommitMessage message : messages) {
+ List<DataFileMeta> files = ((CommitMessageImpl)
message).newFilesIncrement().newFiles();
+ List<DataFileMeta> assigned =
+ files.stream()
+ .map(file -> file.assignFirstRowId(firstRowId))
+ .collect(Collectors.toList());
+ files.clear();
+ files.addAll(assigned);
+ }
+ }
+
+ private List<CommitMessage> prepareCluster(
+ FileStoreTable table,
+ Snapshot snapshot,
+ PartitionPredicate partitionPredicate,
+ boolean fullCompaction)
+ throws Exception {
+ IncrementalClusterManager manager =
+ new IncrementalClusterManager(table, partitionPredicate,
snapshot);
+ return rewriteClusterUnits(table, manager,
manager.createCompactUnits(fullCompaction));
+ }
+
+ // Run the engine-independent read/sort/write stages in-process. The
source selection and
+ // split planning are the same shared manager used by the distributed
engine implementations.
+ private List<CommitMessage> rewriteClusterUnits(
+ FileStoreTable table,
+ IncrementalClusterManager manager,
+ Map<BinaryRow, CompactUnit> units)
+ throws Exception {
+ Map<BinaryRow, Pair<List<DataSplit>, CommitMessage>> planned =
+ manager.toSplitsAndRewriteDvFiles(units);
+ List<CommitMessage> messages = new ArrayList<>();
+ FileStoreTable writeTable =
+
table.copy(Collections.singletonMap(CoreOptions.WRITE_ONLY.key(), "true"));
+ for (Map.Entry<BinaryRow, CompactUnit> entry : units.entrySet()) {
+ Pair<List<DataSplit>, CommitMessage> partitionPlan =
planned.get(entry.getKey());
+ assertThat(partitionPlan.getRight()).isNull();
+ List<DataFileMeta> after = new ArrayList<>();
+ try (IOManager ioManager = IOManager.create(tempPath.toString());
+ RecordReaderIterator<InternalRow> reader =
+ new RecordReaderIterator<>(
+ table.newReadBuilder()
+ .newRead()
+ .createReader(
+ new
ArrayList<>(partitionPlan.getLeft())));
+ BatchTableWrite write =
+ writeTable
+ .newBatchWriteBuilder()
+ .newWrite()
+ .withWriteType(table.rowType())) {
+ if (reader.hasNext()) {
+ Sorter sorter =
+ Sorter.getSorter(
+ reader, ioManager, table.rowType(),
table.coreOptions());
+ try {
+ MutableObjectIterator<BinaryRow> sorted =
sorter.sort();
+ BinaryRow row = new BinaryRow(sorter.arity());
+ while ((row = sorted.next(row)) != null) {
+ write.write(sorter.removeSortKey(row));
+ }
+ } finally {
+ sorter.close();
+ }
+ }
+ for (CommitMessage output : write.prepareCommit()) {
+ after.addAll(((CommitMessageImpl)
output).newFilesIncrement().newFiles());
+ }
+ }
+ messages.add(
+ new CommitMessageImpl(
+ entry.getKey(),
+ 0,
+ null,
+ DataIncrement.emptyIncrement(),
+ new CompactIncrement(
+ entry.getValue().files(),
+ IncrementalClusterManager.upgrade(
+ after,
entry.getValue().outputLevel()),
+ Collections.emptyList())));
+ }
+ return messages;
+ }
+
+ private void commitCluster(
+ FileStoreTable table, Snapshot snapshot, List<CommitMessage>
messages)
+ throws Exception {
+ commitChecked(
+ table,
+ IncrementalClusterRewriter.create(table,
snapshot).prepareCommitMessages(messages));
+ }
+
+ private void commitChecked(FileStoreTable table, List<CommitMessage>
messages)
+ throws Exception {
+ try (TableCommitImpl commit = table.newCommit("test-cluster")) {
+ commit.materializeDvRowIdCheck().commit(messages);
+ }
+ }
+
+ private void delete(FileStoreTable table, String value, Range range,
long... rowIds)
+ throws Exception {
+ commit(table, prepareDelete(table, value, range, rowIds));
+ }
+
+ private CommitMessage prepareDelete(
+ FileStoreTable table, String value, Range range, long... rowIds)
throws Exception {
+ BinaryRow partition = partition(table, value);
+ List<DataFileMeta> group =
+
table.store().newScan().withSnapshot(table.latestSnapshot().get()).plan().files()
+ .stream()
+ .filter(entry -> entry.partition().equals(partition))
+ .map(entry -> entry.file())
+ .filter(file -> file.nonNullRowIdRange().equals(range))
+ .collect(Collectors.toList());
+ DataFileMeta anchor = retrieveAnchorFile(group, file -> file);
+ BaseAppendDeleteFileMaintainer maintainer =
+ BaseAppendDeleteFileMaintainer.forUnawareAppend(
+ table.store().newIndexFileHandler(),
+ table.latestSnapshot().get(),
+ partition);
+ DeletionVector vector = new BitmapDeletionVector();
+ for (long rowId : rowIds) {
+ vector.delete(rowId - range.from);
+ }
+ maintainer.notifyNewDeletionVector(anchor.fileName(), vector);
+ List<IndexFileMeta> additions = new ArrayList<>();
+ List<IndexFileMeta> removals = new ArrayList<>();
+ for (IndexManifestEntry entry : maintainer.persist()) {
+ (entry.kind() == FileKind.ADD ? additions :
removals).add(entry.indexFile());
+ }
+ return new CommitMessageImpl(
+ partition,
+ UNAWARE_BUCKET,
+ null,
+ new DataIncrement(
+ Collections.emptyList(),
+ Collections.emptyList(),
+ Collections.emptyList(),
+ additions,
+ removals),
+ CompactIncrement.emptyIncrement());
+ }
+
+ private void addGlobalIndex(
+ FileStoreTable table, String value, String fileName, Range range,
String column)
+ throws Exception {
+ IndexFileMeta index =
+ new IndexFileMeta(
+ "test-global-index",
+ fileName,
+ 1,
+ range.count(),
+ new GlobalIndexMeta(
+ range.from,
+ range.to,
+ table.rowType().getField(column).id(),
+ null,
+ null),
+ null);
+ commit(
+ table,
+ new CommitMessageImpl(
+ partition(table, value),
+ UNAWARE_BUCKET,
+ null,
+
DataIncrement.indexIncrement(Collections.singletonList(index)),
+ CompactIncrement.emptyIncrement()));
+ }
+
+ private void commit(FileStoreTable table, CommitMessage message) throws
Exception {
+ commit(table, Collections.singletonList(message));
+ }
+
+ private void commit(FileStoreTable table, List<CommitMessage> messages)
throws Exception {
+ try (BatchTableCommit commit =
table.newBatchWriteBuilder().newCommit()) {
+ commit.commit(messages);
+ }
+ }
+
+ private List<CommitMessage> prepareGlobalIndex(
+ FileStoreTable table, Snapshot snapshot, String column) throws
Exception {
+ List<CommitMessage> messages = new ArrayList<>();
+ for (DataSplit split :
+ GlobalIndexBuilderUtils.splitByContiguousRowRange(
+ new SortedGlobalIndexScanner(table, "btree")
+
.withIndexFields(Collections.singletonList(column))
+ .withSnapshot(snapshot)
+ .scan()
+ .get()
+ .entries())) {
+ messages.addAll(
+ SortedGlobalIndexTestUtils.buildIndex(
+ table, "btree", column, split, snapshot.id()));
+ }
+ assertThat(messages).isNotEmpty();
+ return messages;
+ }
+
+ private List<String> readRows(FileStoreTable table) throws Exception {
+ ReadBuilder builder =
+ table.newReadBuilder()
+
.withReadType(SpecialFields.rowTypeWithRowId(table.rowType()));
+ List<String> rows = new ArrayList<>();
+ try (RecordReader<InternalRow> reader =
+ builder.newRead().createReader(builder.newScan().plan())) {
+ reader.forEachRemaining(
+ row ->
+ rows.add(
+ row.getString(0)
+ + "|"
+ + row.getInt(1)
+ + "|"
+ + row.getString(2)
+ + "|"
+ + row.getLong(3)));
+ }
+ rows.sort(
+ Comparator.comparingLong(
+ row ->
Long.parseLong(row.substring(row.lastIndexOf('|') + 1))));
+ return rows;
+ }
+
+ private List<String> fileNames(FileStoreTable table, String value) {
+ BinaryRow partition = partition(table, value);
+ return
table.store().newScan().withSnapshot(table.latestSnapshot().get()).plan().files()
+ .stream()
+ .filter(entry -> entry.partition().equals(partition))
+ .map(entry -> entry.file().fileName())
+ .sorted()
+ .collect(Collectors.toList());
+ }
+
+ private List<String> globalIndexNames(FileStoreTable table) {
+ return table.store().newIndexFileHandler().scanEntries().stream()
+ .filter(entry -> entry.indexFile().globalIndexMeta() != null)
+ .map(entry -> entry.indexFile().fileName())
+ .sorted()
+ .collect(Collectors.toList());
+ }
+
+ private BinaryRow partition(FileStoreTable table, String value) {
+ return new InternalRowSerializer(table.schema().logicalPartitionType())
+ .toBinaryRow(GenericRow.of(BinaryString.fromString(value)));
+ }
+
+ private PartitionPredicate partitionPredicate(FileStoreTable table, String
value) {
+ return PartitionPredicate.fromMaps(
+ table.schema().logicalPartitionType(),
+ Collections.singletonList(Collections.singletonMap("pt",
value)),
+ table.coreOptions().partitionDefaultName());
+ }
+}
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 54a7088950..bcd43a5565 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
@@ -1563,6 +1563,93 @@ class SchemaValidationTest {
assertThatCode(() ->
validateTableSchema(schema)).doesNotThrowAnyException();
}
+ @Test
+ void testDataEvolutionIncrementalClustering() {
+ List<DataField> fields =
+ Arrays.asList(
+ new DataField(0, "id", DataTypes.INT()),
+ new DataField(1, "value", DataTypes.STRING()));
+ TableSchema schema =
+ new TableSchema(
+ 1,
+ fields,
+ 1,
+ emptyList(),
+ emptyList(),
+ dataEvolutionClusteringOptions(),
+ "");
+
+ assertThatCode(() ->
validateTableSchema(schema)).doesNotThrowAnyException();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"blob-descriptor-field", "blob-view-field"})
+ void testDataEvolutionIncrementalClusteringAllowsInlineBlob(String
inlineOption) {
+ Map<String, String> options = dataEvolutionClusteringOptions();
+ options.put(inlineOption, "payload");
+ List<DataField> fields =
+ Arrays.asList(
+ new DataField(0, "id", DataTypes.INT()),
+ new DataField(1, "payload", DataTypes.BLOB()));
+ TableSchema schema = new TableSchema(1, fields, 1, emptyList(),
emptyList(), options, "");
+
+ assertThatCode(() ->
validateTableSchema(schema)).doesNotThrowAnyException();
+ }
+
+ @Test
+ void testDataEvolutionIncrementalClusteringRejectsDedicatedBlob() {
+ for (DataType blobType :
+ Arrays.asList(
+ DataTypes.BLOB(),
+ DataTypes.ARRAY(DataTypes.BLOB()),
+ DataTypes.MAP(DataTypes.STRING(), DataTypes.BLOB()))) {
+ List<DataField> fields =
+ Arrays.asList(
+ new DataField(0, "id", DataTypes.INT()),
+ new DataField(1, "payload", blobType));
+ TableSchema schema =
+ new TableSchema(
+ 1,
+ fields,
+ 1,
+ emptyList(),
+ emptyList(),
+ dataEvolutionClusteringOptions(),
+ "");
+
+ assertThatThrownBy(() -> validateTableSchema(schema))
+ .hasMessage(
+ "Data evolution incremental clustering does not
support dedicated BLOB files.");
+ }
+ }
+
+ @Test
+ void testDataEvolutionIncrementalClusteringVectorStorage() {
+ Map<String, String> options = dataEvolutionClusteringOptions();
+ List<DataField> fields =
+ Arrays.asList(
+ new DataField(0, "id", DataTypes.INT()),
+ new DataField(1, "embedding", DataTypes.VECTOR(3,
DataTypes.FLOAT())));
+ TableSchema schema = new TableSchema(1, fields, 1, emptyList(),
emptyList(), options, "");
+
+ assertThatCode(() ->
validateTableSchema(schema)).doesNotThrowAnyException();
+
+ options.put(VECTOR_FILE_FORMAT.key(), "json");
+ assertThatThrownBy(() -> validateTableSchema(schema))
+ .hasMessage(
+ "Data evolution incremental clustering does not
support dedicated VECTOR files.");
+ }
+
+ private Map<String, String> dataEvolutionClusteringOptions() {
+ Map<String, String> options = new HashMap<>();
+ options.put(BUCKET.key(), "-1");
+ options.put(CoreOptions.ROW_TRACKING_ENABLED.key(), "true");
+ options.put(DATA_EVOLUTION_ENABLED.key(), "true");
+ options.put(CoreOptions.CLUSTERING_INCREMENTAL.key(), "true");
+ options.put(CoreOptions.CLUSTERING_COLUMNS.key(), "id");
+ return options;
+ }
+
@Test
public void testFileIndexColumns() {
List<String> keys =
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/CompactAction.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/CompactAction.java
index 1d01ba1f2e..94480deb1b 100644
---
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/CompactAction.java
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/CompactAction.java
@@ -154,12 +154,15 @@ public class CompactAction extends TableActionBase {
if (fileStoreTable.coreOptions().bucket() ==
BucketMode.POSTPONE_BUCKET) {
buildForPostponeBucketCompaction(env, fileStoreTable, isStreaming);
} else if (fileStoreTable.bucketMode() == BucketMode.BUCKET_UNAWARE) {
- if (fileStoreTable.coreOptions().dataEvolutionEnabled()) {
- buildForDataEvolutionTableCompact(env, fileStoreTable,
isStreaming);
- } else if
(fileStoreTable.coreOptions().clusteringIncrementalEnabled()) {
+ if (fileStoreTable.coreOptions().clusteringIncrementalEnabled()) {
+ checkArgument(
+ !isStreaming ||
!fileStoreTable.coreOptions().dataEvolutionEnabled(),
+ "Data evolution incremental clustering only supports
batch mode yet.");
new IncrementalClusterCompact(
env, fileStoreTable, partitionPredicate,
fullCompaction)
.build();
+ } else if (fileStoreTable.coreOptions().dataEvolutionEnabled()) {
+ buildForDataEvolutionTableCompact(env, fileStoreTable,
isStreaming);
} else {
buildForAppendTableCompact(env, fileStoreTable, isStreaming);
}
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/compact/IncrementalClusterCompact.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/compact/IncrementalClusterCompact.java
index 0367adce8c..ef5a58632b 100644
---
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/compact/IncrementalClusterCompact.java
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/compact/IncrementalClusterCompact.java
@@ -19,6 +19,7 @@
package org.apache.paimon.flink.compact;
import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
import org.apache.paimon.append.cluster.IncrementalClusterManager;
import org.apache.paimon.codegen.CodeGenUtils;
import org.apache.paimon.compact.CompactUnit;
@@ -31,6 +32,8 @@ import
org.apache.paimon.flink.cluster.IncrementalClusterSplitSource;
import
org.apache.paimon.flink.cluster.RewriteIncrementalClusterCommittableOperator;
import org.apache.paimon.flink.sink.Committable;
import org.apache.paimon.flink.sink.CommittableTypeInfo;
+import org.apache.paimon.flink.sink.DataEvolutionCommitPreparationOperator;
+import org.apache.paimon.flink.sink.DataEvolutionDeletionVectorMaterializeSink;
import org.apache.paimon.flink.sink.FlinkSinkBuilder;
import org.apache.paimon.flink.sink.RowAppendTableSink;
import org.apache.paimon.flink.sorter.SortOperator;
@@ -59,12 +62,15 @@ import org.apache.flink.table.data.RowData;
import javax.annotation.Nullable;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
+import static org.apache.paimon.flink.FlinkConnectorOptions.PRECOMMIT_COMPACT;
import static org.apache.paimon.flink.FlinkConnectorOptions.SCAN_PARALLELISM;
+import static
org.apache.paimon.flink.FlinkConnectorOptions.SINK_COORDINATOR_COMMIT_ENABLED;
import static org.apache.paimon.table.PrimaryKeyTableUtils.addKeyNamePrefix;
/** Compact for incremental clustering. */
@@ -87,6 +93,15 @@ public class IncrementalClusterCompact {
PartitionPredicate partitionPredicate,
@Nullable Boolean fullCompaction) {
this.env = env;
+ if (table.coreOptions().dataEvolutionEnabled()) {
+ Map<String, String> rewriteOptions = new HashMap<>();
+ // Rewrite stored references, including views whose upstream value
is currently null.
+ rewriteOptions.put(CoreOptions.BLOB_VIEW_RESOLVE_ENABLED.key(),
"false");
+ // Sorted outputs must reach the clustering committer without
another rewrite or commit.
+ rewriteOptions.put(PRECOMMIT_COMPACT.key(), "false");
+ rewriteOptions.put(SINK_COORDINATOR_COMMIT_ENABLED.key(), "false");
+ table = table.copy(rewriteOptions);
+ }
this.table = table;
this.clusterManager =
new IncrementalClusterManager(
@@ -331,6 +346,20 @@ public class IncrementalClusterCompact {
for (int i = 1; i < dataStreams.size(); i++) {
dataStream = dataStream.union(dataStreams.get(i));
}
- sink.doCommit(dataStream, commitUser);
+ if (table.coreOptions().dataEvolutionEnabled()) {
+ Snapshot snapshot = clusterManager.snapshot();
+ dataStream =
+ dataStream
+ .transform(
+ "Data Evolution Cluster Commit Preparation
: " + table.name(),
+ new CommittableTypeInfo(),
+
DataEvolutionCommitPreparationOperator.Factory.forClustering(
+ table, snapshot))
+ .forceNonParallel();
+ new DataEvolutionDeletionVectorMaterializeSink(table, snapshot)
+ .doCommit(dataStream, commitUser);
+ } else {
+ sink.doCommit(dataStream, commitUser);
+ }
}
}
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/DataEvolutionCommitPreparationOperator.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/DataEvolutionCommitPreparationOperator.java
index f2c0aa7bff..40c262af08 100644
---
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/DataEvolutionCommitPreparationOperator.java
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/DataEvolutionCommitPreparationOperator.java
@@ -19,6 +19,7 @@
package org.apache.paimon.flink.sink;
import org.apache.paimon.Snapshot;
+import org.apache.paimon.append.cluster.IncrementalClusterRewriter;
import
org.apache.paimon.append.dataevolution.DataEvolutionCompactionCommitPreparation;
import org.apache.paimon.options.Options;
import org.apache.paimon.table.FileStoreTable;
@@ -41,17 +42,20 @@ public class DataEvolutionCommitPreparationOperator
private final FileStoreTable table;
private final Snapshot snapshot;
private final boolean materializeDvRowIdCheck;
+ private final boolean clustering;
private final List<Committable> committables;
private DataEvolutionCommitPreparationOperator(
StreamOperatorParameters<Committable> parameters,
FileStoreTable table,
Snapshot snapshot,
- boolean materializeDvRowIdCheck) {
+ boolean materializeDvRowIdCheck,
+ boolean clustering) {
super(parameters, Options.fromMap(table.options()));
this.table = table;
this.snapshot = snapshot;
this.materializeDvRowIdCheck = materializeDvRowIdCheck;
+ this.clustering = clustering;
this.committables = new ArrayList<>();
}
@@ -73,6 +77,15 @@ public class DataEvolutionCommitPreparationOperator
for (Committable committable : toCommit) {
messages.add(committable.commitMessage());
}
+ if (clustering) {
+ List<Committable> prepared = new ArrayList<>();
+ for (CommitMessage message :
+ IncrementalClusterRewriter.create(table, snapshot)
+ .prepareCommitMessages(messages)) {
+ prepared.add(new Committable(checkpointId, message));
+ }
+ return prepared;
+ }
for (CommitMessage message :
new DataEvolutionCompactionCommitPreparation(table,
snapshot).prepare(messages)) {
toCommit.add(new Committable(toCommit.get(0).checkpointId(),
message));
@@ -97,16 +110,30 @@ public class DataEvolutionCommitPreparationOperator
private final FileStoreTable table;
private final Snapshot snapshot;
private final boolean materializeDvRowIdCheck;
+ private final boolean clustering;
public Factory(FileStoreTable table, Snapshot snapshot) {
this(table, snapshot, false);
}
public Factory(FileStoreTable table, Snapshot snapshot, boolean
materializeDvRowIdCheck) {
+ this(table, snapshot, materializeDvRowIdCheck, false);
+ }
+
+ public static Factory forClustering(FileStoreTable table, Snapshot
snapshot) {
+ return new Factory(table, snapshot, true, true);
+ }
+
+ private Factory(
+ FileStoreTable table,
+ Snapshot snapshot,
+ boolean materializeDvRowIdCheck,
+ boolean clustering) {
super(Options.fromMap(table.options()));
this.table = table;
this.snapshot = snapshot;
this.materializeDvRowIdCheck = materializeDvRowIdCheck;
+ this.clustering = clustering;
}
@Override
@@ -115,7 +142,7 @@ public class DataEvolutionCommitPreparationOperator
StreamOperatorParameters<Committable> parameters) {
return (T)
new DataEvolutionCommitPreparationOperator(
- parameters, table, snapshot,
materializeDvRowIdCheck);
+ parameters, table, snapshot,
materializeDvRowIdCheck, clustering);
}
@Override
diff --git
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/DataEvolutionDeleteSqlITCase.java
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/DataEvolutionDeleteSqlITCase.java
index 2115105394..e0504d20bd 100644
---
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/DataEvolutionDeleteSqlITCase.java
+++
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/DataEvolutionDeleteSqlITCase.java
@@ -19,14 +19,36 @@
package org.apache.paimon.flink;
import org.apache.paimon.Snapshot;
+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.index.DeletionVectorMeta;
+import org.apache.paimon.io.DataFileMeta;
import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.operation.FileStoreWrite;
+import org.apache.paimon.reader.RecordReader;
import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.sink.BatchTableCommit;
+import org.apache.paimon.table.sink.BatchTableWrite;
+import org.apache.paimon.table.sink.BatchWriteBuilder;
+import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.table.sink.CommitMessageImpl;
+import org.apache.paimon.table.sink.TableWriteImpl;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.Split;
import org.apache.flink.table.api.config.TableConfigOptions;
import org.apache.flink.types.Row;
import org.junit.jupiter.api.Test;
-
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
@@ -87,6 +109,237 @@ public class DataEvolutionDeleteSqlITCase extends
CatalogITCaseBase {
Row.of(6, "six", "B"));
}
+ @ParameterizedTest
+ @CsvSource({
+ "global-sort,",
+ "local-sort,",
+ "global-sort,precommit-compact=true",
+ "local-sort,sink.coordinator-commit.enabled=true"
+ })
+ public void
testIncrementalClusteringProcedurePreservesUnselectedPartitionIndexes(
+ String mode, String ingestionOption) throws Exception {
+ tEnv.getConfig().set(TableConfigOptions.TABLE_DML_SYNC, true);
+ createTable();
+ sql(
+ "ALTER TABLE T SET ("
+ + "'clustering.incremental' = 'true', "
+ + "'clustering.strategy' = 'order', "
+ + "'clustering.columns' = 'id', "
+ + "'clustering.incremental.mode' = '"
+ + mode
+ + "', "
+ + "'scan.parallelism' = '2', "
+ + "'source.split.target-size' = '1 b', "
+ + "'sink.parallelism' = '1', "
+ + "'num-sorted-run.compaction-trigger' = '10')");
+ // Keep more distinct rows than shuffle ranges so duplicate maximum
sample boundaries do
+ // not randomly send every row to one writer. Both readers still see
overlapping ranges.
+ List<String> oddValues = new ArrayList<>();
+ List<String> evenValues = new ArrayList<>();
+ for (int id = 39; id >= 1; id -= 2) {
+ oddValues.add("(" + id + ", 'value-" + id + "', 'A')");
+ evenValues.add("(" + (id + 1) + ", 'value-" + (id + 1) + "',
'A')");
+ }
+ sql("INSERT INTO T VALUES " + String.join(", ", oddValues));
+ updateFirstGroupNames(paimonTable("T"));
+ sql("INSERT INTO T VALUES " + String.join(", ", evenValues));
+ sql("INSERT INTO T VALUES (41, 'value-41', 'B')");
+ sql("DELETE FROM T WHERE id = 2");
+ createGlobalIndex();
+
+ FileStoreTable table = paimonTable("T");
+ long nextRowId = table.latestSnapshot().get().nextRowId();
+ long beforeSnapshot = table.latestSnapshot().get().id();
+ assertThat(globalIndexPartitions(table)).containsExactly("A", "B");
+
+ sql("CALL sys.compact(`table` => 'default.T', partitions => 'dt=A')");
+
assertThat(table.latestSnapshot().get().id()).isEqualTo(beforeSnapshot);
+ sql(
+ "CALL sys.compact(`table` => 'default.T', partitions =>
'dt=A', "
+ + "compact_strategy => 'full'"
+ + (ingestionOption == null ? "" : ", options => '" +
ingestionOption + "'")
+ + ")");
+
+ List<Row> expectedRows = new ArrayList<>();
+ List<Row> expectedRowIds = new ArrayList<>();
+ for (int id = 1; id <= 40; id++) {
+ if (id != 2) {
+ expectedRows.add(Row.of(id, (id % 2 == 1 ? "updated-" :
"value-") + id, "A"));
+ expectedRowIds.add(Row.of(nextRowId + expectedRowIds.size()));
+ }
+ }
+ expectedRows.add(Row.of(41, "value-41", "B"));
+ assertThat(sql("SELECT id, name, dt FROM T ORDER BY id"))
+ .containsExactlyElementsOf(expectedRows);
+ assertThat(sql("SELECT _ROW_ID FROM T$row_tracking " + "WHERE dt = 'A'
ORDER BY _ROW_ID"))
+ .containsExactlyElementsOf(expectedRowIds);
+ assertClusteredFileOrder(table, mode);
+ assertThat(deletionVectorCardinality(table)).isZero();
+ assertThat(globalIndexPartitions(table)).containsExactly("B");
+ sql("ALTER TABLE T SET ('scalar-index.search-mode' = 'full')");
+ assertThat(sql("SELECT id FROM T WHERE name =
'updated-3'")).containsExactly(Row.of(3));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"global-sort", "local-sort"})
+ public void testIncrementalClusteringPreservesInlineBlobViews(String mode)
throws Exception {
+ tEnv.getConfig().set(TableConfigOptions.TABLE_DML_SYNC, true);
+ sql(
+ "CREATE TABLE upstream_blob (id INT, picture BYTES) WITH ("
+ + "'row-tracking.enabled' = 'true', "
+ + "'data-evolution.enabled' = 'true', "
+ + "'blob-field' = 'picture', 'sink.parallelism' =
'1')");
+ sql("INSERT INTO upstream_blob VALUES (3, X'03'), (1, X'01'), (5,
X'05')");
+ sql("INSERT INTO upstream_blob VALUES (6, X'06'), (4, X'04'), (2,
CAST(NULL AS BYTES))");
+ sql(
+ "CREATE TABLE blob_views (id INT, image_ref BYTES) WITH ("
+ + "'row-tracking.enabled' = 'true', "
+ + "'data-evolution.enabled' = 'true', "
+ + "'blob-view-field' = 'image_ref', "
+ + "'clustering.incremental' = 'true', "
+ + "'clustering.strategy' = 'order',
'clustering.columns' = 'id', "
+ + "'clustering.incremental.mode' = '"
+ + mode
+ + "', 'scan.parallelism' = '2',
'source.split.target-size' = '1 b', "
+ + "'sink.parallelism' = '1')");
+ for (String ids : new String[] {"1, 3, 5", "2, 4, 6"}) {
+ sql(
+ "INSERT INTO blob_views SELECT id, "
+ + "sys.blob_view('default.upstream_blob',
'picture', _ROW_ID) "
+ + "FROM `upstream_blob$row_tracking` WHERE id IN ("
+ + ids
+ + ")");
+ }
+ String rawReferences =
+ "SELECT id, image_ref FROM blob_views "
+ + "/*+ OPTIONS('blob-view.resolve.enabled'='false') */
ORDER BY id";
+ List<Row> references = sql(rawReferences);
+ assertThat(references).hasSize(6);
+ assertThat(references).allSatisfy(row ->
assertThat(row.getField(1)).isNotNull());
+ FileStoreTable table = paimonTable("blob_views");
+ long nextRowId = table.latestSnapshot().get().nextRowId();
+
+ sql("CALL sys.compact(`table` => 'default.blob_views',
compact_strategy => 'full')");
+
+ // A currently null upstream value must not erase the non-null stored
reference.
+ assertThat(sql(rawReferences)).containsExactlyElementsOf(references);
+ assertThat(sql("SELECT id, image_ref FROM blob_views ORDER BY id"))
+ .containsExactly(
+ Row.of(1, new byte[] {1}),
+ Row.of(2, null),
+ Row.of(3, new byte[] {3}),
+ Row.of(4, new byte[] {4}),
+ Row.of(5, new byte[] {5}),
+ Row.of(6, new byte[] {6}));
+ assertThat(sql("SELECT _ROW_ID FROM blob_views$row_tracking ORDER BY
_ROW_ID"))
+ .containsExactly(
+ Row.of(nextRowId),
+ Row.of(nextRowId + 1),
+ Row.of(nextRowId + 2),
+ Row.of(nextRowId + 3),
+ Row.of(nextRowId + 4),
+ Row.of(nextRowId + 5));
+ }
+
+ @ParameterizedTest
+ @CsvSource({"global-sort,parquet", "local-sort,parquet",
"global-sort,row", "local-sort,row"})
+ public void testIncrementalClusteringPreservesInlineVectors(String mode,
String format)
+ throws Exception {
+ tEnv.getConfig().set(TableConfigOptions.TABLE_DML_SYNC, true);
+ sql(
+ "CREATE TABLE vectors (id INT, embedding ARRAY<FLOAT>) WITH ("
+ + "'row-tracking.enabled' = 'true', "
+ + "'data-evolution.enabled' = 'true', "
+ + "'file.format' = '"
+ + format
+ + "', "
+ + "'vector-field' = 'embedding',
'field.embedding.vector-dim' = '3', "
+ + "'clustering.incremental' = 'true', "
+ + "'clustering.strategy' = 'order',
'clustering.columns' = 'id', "
+ + "'clustering.incremental.mode' = '"
+ + mode
+ + "', 'scan.parallelism' = '2',
'source.split.target-size' = '1 b', "
+ + "'sink.parallelism' = '1')");
+ sql(
+ "INSERT INTO vectors VALUES "
+ + "(3, CAST(ARRAY[0, 3, 4] AS ARRAY<FLOAT>)), "
+ + "(1, CAST(ARRAY[0, 1, 2] AS ARRAY<FLOAT>)), "
+ + "(5, CAST(ARRAY[0, 5, 6] AS ARRAY<FLOAT>))");
+ sql(
+ "INSERT INTO vectors VALUES "
+ + "(6, CAST(ARRAY[0, 6, 7] AS ARRAY<FLOAT>)), "
+ + "(4, CAST(ARRAY[0, 4, 5] AS ARRAY<FLOAT>)), "
+ + "(2, CAST(NULL AS ARRAY<FLOAT>))");
+ FileStoreTable table = paimonTable("vectors");
+ long nextRowId = table.latestSnapshot().get().nextRowId();
+
+ sql("CALL sys.compact(`table` => 'default.vectors', compact_strategy
=> 'full')");
+
+ List<String> rows = new ArrayList<>();
+ try (RecordReader<InternalRow> reader =
+ table.newRead().createReader(table.newScan().plan())) {
+ reader.forEachRemaining(
+ row ->
+ rows.add(
+ row.getInt(0)
+ + "|"
+ + (row.isNullAt(1)
+ ? "null"
+ : Arrays.toString(
+
row.getVector(1).toFloatArray()))));
+ }
+ Collections.sort(rows);
+ assertThat(rows)
+ .containsExactly(
+ "1|[0.0, 1.0, 2.0]",
+ "2|null",
+ "3|[0.0, 3.0, 4.0]",
+ "4|[0.0, 4.0, 5.0]",
+ "5|[0.0, 5.0, 6.0]",
+ "6|[0.0, 6.0, 7.0]");
+ assertThat(sql("SELECT _ROW_ID FROM vectors$row_tracking ORDER BY
_ROW_ID"))
+ .containsExactly(
+ Row.of(nextRowId),
+ Row.of(nextRowId + 1),
+ Row.of(nextRowId + 2),
+ Row.of(nextRowId + 3),
+ Row.of(nextRowId + 4),
+ Row.of(nextRowId + 5));
+ }
+
+ @Test
+ public void testIncrementalClusteringRemovesFullyDeletedPartition() throws
Exception {
+ tEnv.getConfig().set(TableConfigOptions.TABLE_DML_SYNC, true);
+ createTable();
+ sql(
+ "ALTER TABLE T SET ("
+ + "'clustering.incremental' = 'true', "
+ + "'clustering.strategy' = 'order', "
+ + "'clustering.columns' = 'id', "
+ + "'scan.parallelism' = '2')");
+ sql("INSERT INTO T VALUES (3, 'three', 'A'), (1, 'one', 'A'), (2,
'two', 'A')");
+ sql("INSERT INTO T VALUES (4, 'four', 'B')");
+ createGlobalIndex();
+ sql("DELETE FROM T WHERE id IN (1, 2, 3)");
+ FileStoreTable table = paimonTable("T");
+ assertThat(deletionVectorCardinality(table)).isEqualTo(3L);
+ long nextRowId = table.latestSnapshot().get().nextRowId();
+
+ sql(
+ "CALL sys.compact(`table` => 'default.T', partitions =>
'dt=A', "
+ + "compact_strategy => 'full')");
+
+ assertThat(sql("SELECT id, name, dt FROM
T")).containsExactly(Row.of(4, "four", "B"));
+ assertThat(table.newScan().plan().splits())
+ .allSatisfy(
+ split ->
+ assertThat(((DataSplit)
split).partition().getString(0).toString())
+ .isEqualTo("B"));
+ assertThat(deletionVectorCardinality(table)).isZero();
+ assertThat(globalIndexPartitions(table)).containsExactly("B");
+
assertThat(table.latestSnapshot().get().nextRowId()).isEqualTo(nextRowId);
+ }
+
@Test
public void testMaterializeDeletionVectorsForEmptyTable() throws Exception
{
tEnv.getConfig().set(TableConfigOptions.TABLE_DML_SYNC, true);
@@ -238,6 +491,88 @@ public class DataEvolutionDeleteSqlITCase extends
CatalogITCaseBase {
assertThat(sql("SELECT id, name, dt FROM
T")).containsExactly(Row.of(1, "old", "A"));
}
+ @SuppressWarnings("unchecked")
+ private static void updateFirstGroupNames(FileStoreTable table) throws
Exception {
+ BatchWriteBuilder builder = table.newBatchWriteBuilder();
+ BinaryRow partition = ((DataSplit)
table.newScan().plan().splits().get(0)).partition();
+ try (BatchTableWrite write =
+ builder.newWrite()
+ .withWriteType(
+
table.rowType().project(Arrays.asList("name", "dt")));
+ BatchTableCommit commit = builder.newCommit()) {
+ // Partial rows do not contain the partition field at its
full-schema position.
+ FileStoreWrite<InternalRow> fileStoreWrite =
+ ((TableWriteImpl<InternalRow>) write).getWrite();
+ for (int id = 39; id >= 1; id -= 2) {
+ fileStoreWrite.write(
+ partition,
+ 0,
+ GenericRow.of(
+ BinaryString.fromString("updated-" + id),
+ BinaryString.fromString("A")));
+ }
+ List<CommitMessage> messages = write.prepareCommit();
+ for (CommitMessage message : messages) {
+ List<DataFileMeta> files =
+ ((CommitMessageImpl)
message).newFilesIncrement().newFiles();
+ List<DataFileMeta> assigned =
+ files.stream()
+ .map(file -> file.assignFirstRowId(0L))
+ .collect(Collectors.toList());
+ files.clear();
+ files.addAll(assigned);
+ }
+ commit.commit(messages);
+ }
+ }
+
+ private static void assertClusteredFileOrder(FileStoreTable table, String
mode)
+ throws Exception {
+ List<DataFileMeta> files = new ArrayList<>();
+ List<List<Integer>> fileIds = new ArrayList<>();
+ for (Split split : table.newScan().plan().splits()) {
+ DataSplit dataSplit = (DataSplit) split;
+ if (!dataSplit.partition().getString(0).toString().equals("A")) {
+ continue;
+ }
+ for (DataFileMeta file : dataSplit.dataFiles()) {
+ files.add(file);
+ DataSplit fileSplit =
+ DataSplit.builder()
+ .withSnapshot(dataSplit.snapshotId())
+ .withPartition(dataSplit.partition())
+ .withBucket(dataSplit.bucket())
+ .withBucketPath(dataSplit.bucketPath())
+ .withDataFiles(Collections.singletonList(file))
+ .build();
+ List<Integer> ids = new ArrayList<>();
+ try (RecordReader<InternalRow> reader =
table.newRead().createReader(fileSplit)) {
+ reader.forEachRemaining(row -> ids.add(row.getInt(0)));
+ }
+ assertThat(ids).isNotEmpty().isSorted();
+ fileIds.add(ids);
+ }
+ }
+ // Verify ordering across actual output files, as well as within each
file.
+ assertThat(files).hasSizeGreaterThanOrEqualTo(2);
+ assertThat(files).allSatisfy(file ->
assertThat(file.level()).isGreaterThan(0));
+ fileIds.sort(Comparator.comparingInt(ids -> ids.get(0)));
+ boolean overlappingRanges = false;
+ for (int i = 1; i < fileIds.size(); i++) {
+ List<Integer> previous = fileIds.get(i - 1);
+ overlappingRanges |= previous.get(previous.size() - 1) >=
fileIds.get(i).get(0);
+ }
+ if (mode.equals("global-sort")) {
+ assertThat(overlappingRanges)
+ .as("global-sort output file ranges must not overlap")
+ .isFalse();
+ } else {
+ assertThat(overlappingRanges)
+ .as("local-sort preserves the interleaved input ranges
across readers")
+ .isTrue();
+ }
+ }
+
private void createGlobalIndex() {
sql(
"CALL sys.create_global_index(`table` => 'default.T', "
diff --git
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/IncrementalClusterActionITCase.java
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/IncrementalClusterActionITCase.java
index 9914bc68d3..a9da8ef0f3 100644
---
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/IncrementalClusterActionITCase.java
+++
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/IncrementalClusterActionITCase.java
@@ -64,10 +64,23 @@ import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** IT cases for incremental clustering action. */
public class IncrementalClusterActionITCase extends ActionITCaseBase {
+ @Test
+ public void testStreamingDataEvolutionClusteringIsRejected() throws
Exception {
+ Map<String, String> options = commonOptions();
+ options.put(CoreOptions.ROW_TRACKING_ENABLED.key(), "true");
+ options.put(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true");
+ createTable(null, options);
+
+ assertThatThrownBy(() -> runAction(true, Collections.emptyList()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Data evolution incremental clustering only
supports batch mode yet.");
+ }
+
@Test
public void testClusterUnpartitionedTable() throws Exception {
FileStoreTable table = createTable(null);
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/CompactProcedure.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/CompactProcedure.java
index 7acca09d85..ada67b342b 100644
---
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/CompactProcedure.java
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/CompactProcedure.java
@@ -37,6 +37,7 @@ import org.apache.paimon.manifest.PartitionEntry;
import org.apache.paimon.operation.BaseAppendFileStoreWrite;
import org.apache.paimon.partition.PartitionPredicate;
import org.apache.paimon.partition.PartitionValuesTimeExpireStrategy;
+import org.apache.paimon.spark.SparkTable;
import org.apache.paimon.spark.SparkUtils;
import
org.apache.paimon.spark.commands.DataEvolutionCompactMergeConflictRewriter;
import org.apache.paimon.spark.commands.PaimonSparkWriter;
@@ -75,6 +76,7 @@ import org.apache.spark.sql.catalyst.InternalRow;
import org.apache.spark.sql.connector.catalog.Identifier;
import org.apache.spark.sql.connector.catalog.TableCatalog;
import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation;
+import org.apache.spark.sql.paimon.shims.SparkShimLoader;
import org.apache.spark.sql.types.DataTypes;
import org.apache.spark.sql.types.Metadata;
import org.apache.spark.sql.types.StructField;
@@ -104,7 +106,6 @@ import java.util.function.Predicate;
import java.util.stream.Collectors;
import scala.collection.JavaConverters;
-import scala.collection.Seq;
import static org.apache.paimon.CoreOptions.createCommitUser;
import static org.apache.paimon.format.blob.BlobFileFormat.isBlobFile;
@@ -328,16 +329,20 @@ public class CompactProcedure extends BaseProcedure {
javaSparkContext);
break;
case BUCKET_UNAWARE:
- if (table.coreOptions().dataEvolutionEnabled()) {
+ if (clusterIncrementalEnabled) {
+ clusterIncrementalUnAwareBucketTable(
+ table,
+ partitionPredicate,
+ partitionIdleTime,
+ fullCompact,
+ relation);
+ } else if (table.coreOptions().dataEvolutionEnabled()) {
compactDataEvolutionTable(
table,
relation,
partitionPredicate,
partitionIdleTime,
javaSparkContext);
- } else if (clusterIncrementalEnabled) {
- clusterIncrementalUnAwareBucketTable(
- table, partitionPredicate, fullCompact,
relation);
} else {
compactUnAwareBucketTable(
table, partitionPredicate, partitionIdleTime,
javaSparkContext);
@@ -841,22 +846,57 @@ public class CompactProcedure extends BaseProcedure {
private void clusterIncrementalUnAwareBucketTable(
FileStoreTable table,
@Nullable PartitionPredicate partitionPredicate,
+ @Nullable Duration partitionIdleTime,
boolean fullCompaction,
DataSourceV2Relation relation) {
+ Snapshot snapshot = table.snapshotManager().latestSnapshot();
+ if (snapshot == null) {
+ return;
+ }
IncrementalClusterManager incrementalClusterManager =
- new IncrementalClusterManager(
- table, partitionPredicate,
table.snapshotManager().latestSnapshot());
+ new IncrementalClusterManager(table, partitionPredicate,
snapshot);
Map<BinaryRow, CompactUnit> compactUnits =
incrementalClusterManager.createCompactUnits(fullCompaction);
+ if (partitionIdleTime != null) {
+ Set<BinaryRow> idlePartitions =
+ getPartitionsToCompact(
+ table.newSnapshotReader().withSnapshot(snapshot),
partitionIdleTime);
+ compactUnits.keySet().retainAll(idlePartitions);
+ }
LOG.info(
"Planned {} compact units to incrementally cluster for table
{}, full compaction {}.",
compactUnits.size(),
table.fullName(),
fullCompaction);
+ if (compactUnits.isEmpty()) {
+ return;
+ }
+
Map<BinaryRow, Pair<List<DataSplit>, CommitMessage>> partitionSplits =
incrementalClusterManager.toSplitsAndRewriteDvFiles(compactUnits);
+ final FileStoreTable clusterTable;
+ final DataSourceV2Relation clusterRelation;
+ if (table.coreOptions().dataEvolutionEnabled()) {
+ Map<String, String> clusterOptions = new HashMap<>();
+ // Preserve inline references and the layout already produced by
the sorter.
+ clusterOptions.put(CoreOptions.BLOB_AS_DESCRIPTOR.key(), "true");
+ clusterOptions.put(CoreOptions.BLOB_VIEW_RESOLVE_ENABLED.key(),
"false");
+ clusterOptions.put(CoreOptions.PARTITION_SINK_STRATEGY.key(),
"none");
+
clusterOptions.put(CoreOptions.CLUSTERING_INCREMENTAL_OPTIMIZE_WRITE.key(),
"false");
+ clusterTable = table.copy(clusterOptions);
+ clusterRelation =
+ SparkShimLoader.shim()
+ .copyDataSourceV2Relation(
+ relation,
+ ((SparkTable)
relation.table()).copy(clusterTable),
+ relation.output());
+ } else {
+ clusterTable = table;
+ clusterRelation = relation;
+ }
+
// sort in partition
TableSorter sorter =
TableSorter.getSorter(
@@ -876,6 +916,7 @@ public class CompactProcedure extends BaseProcedure {
Dataset<Row> datasetForWrite =
partitionSplits.values().stream()
.map(Pair::getKey)
+ .filter(splits -> !splits.isEmpty())
.map(
splits -> {
Dataset<Row> dataset =
@@ -883,7 +924,7 @@ public class CompactProcedure extends BaseProcedure {
spark(),
ScanPlanHelper$.MODULE$.createNewScanPlan(
splits.toArray(new
DataSplit[0]),
- relation));
+ clusterRelation));
// Use sortLocal() for LOCAL_SORT, sort()
for GLOBAL_SORT
if (mode ==
CoreOptions.ClusteringIncrementalMode.LOCAL_SORT) {
return sorter.sortLocal(dataset);
@@ -893,73 +934,82 @@ public class CompactProcedure extends BaseProcedure {
})
.reduce(Dataset::union)
.orElse(null);
- if (datasetForWrite != null) {
- // set to write only to prevent invoking compaction
- // do not use overwrite, we don't need to overwrite the whole
partition
- PaimonSparkWriter writer =
PaimonSparkWriter.apply(table).writeOnly();
- Seq<CommitMessage> commitMessages = writer.write(datasetForWrite);
- if (LOG.isDebugEnabled()) {
- LOG.debug("Commit messages after writing:{}", commitMessages);
- }
+ // set to write only to prevent invoking compaction
+ // do not use overwrite, we don't need to overwrite the whole partition
+ PaimonSparkWriter writer =
PaimonSparkWriter.apply(clusterTable).writeOnly();
+ List<CommitMessage> commitMessages =
+ datasetForWrite == null
+ ? Collections.emptyList()
+ :
JavaConverters.seqAsJavaList(writer.write(datasetForWrite));
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Commit messages after writing:{}", commitMessages);
+ }
- // re-organize the commit messages to generate the compact messages
- Map<BinaryRow, List<DataFileMeta>> partitionClustered = new
HashMap<>();
- for (CommitMessage commitMessage :
JavaConverters.seqAsJavaList(commitMessages)) {
- checkArgument(commitMessage.bucket() == 0);
- partitionClustered
- .computeIfAbsent(commitMessage.partition(), k -> new
ArrayList<>())
- .addAll(((CommitMessageImpl)
commitMessage).newFilesIncrement().newFiles());
- }
+ // re-organize the commit messages to generate the compact messages
+ Map<BinaryRow, List<DataFileMeta>> partitionClustered = new
HashMap<>();
+ for (CommitMessage commitMessage : commitMessages) {
+ checkArgument(commitMessage.bucket() == 0);
+ partitionClustered
+ .computeIfAbsent(commitMessage.partition(), k -> new
ArrayList<>())
+ .addAll(((CommitMessageImpl)
commitMessage).newFilesIncrement().newFiles());
+ }
- List<CommitMessage> clusterMessages = new ArrayList<>();
- for (Map.Entry<BinaryRow, List<DataFileMeta>> entry :
partitionClustered.entrySet()) {
- BinaryRow partition = entry.getKey();
- CommitMessageImpl dvCommitMessage =
- (CommitMessageImpl)
partitionSplits.get(partition).getValue();
- List<DataFileMeta> clusterBefore =
compactUnits.get(partition).files();
- // upgrade the clustered file to outputLevel
- List<DataFileMeta> clusterAfter =
- IncrementalClusterManager.upgrade(
- entry.getValue(),
compactUnits.get(partition).outputLevel());
- LOG.info(
- "Partition {}: upgrade file level to {}",
- partition,
- compactUnits.get(partition).outputLevel());
-
- List<IndexFileMeta> newIndexFiles = new ArrayList<>();
- List<IndexFileMeta> deletedIndexFiles = new ArrayList<>();
- if (dvCommitMessage != null) {
- newIndexFiles =
dvCommitMessage.compactIncrement().newIndexFiles();
- deletedIndexFiles =
dvCommitMessage.compactIncrement().deletedIndexFiles();
- }
-
- // get the dv index messages
- CompactIncrement compactIncrement =
- new CompactIncrement(
- clusterBefore,
- clusterAfter,
- Collections.emptyList(),
- newIndexFiles,
- deletedIndexFiles);
- clusterMessages.add(
- new CommitMessageImpl(
- partition,
- // bucket 0 is bucket for unaware-bucket table
- // for compatibility with the old design
- 0,
- table.coreOptions().bucket(),
- DataIncrement.emptyIncrement(),
- compactIncrement));
- }
- if (LOG.isDebugEnabled()) {
- LOG.debug("Commit messages after reorganizing:{}",
clusterMessages);
+ List<CommitMessage> clusterMessages = new ArrayList<>();
+ for (Map.Entry<BinaryRow, CompactUnit> entry :
compactUnits.entrySet()) {
+ BinaryRow partition = entry.getKey();
+ CommitMessageImpl dvCommitMessage =
+ (CommitMessageImpl)
partitionSplits.get(partition).getValue();
+ List<DataFileMeta> clusterBefore = entry.getValue().files();
+ // upgrade the clustered file to outputLevel
+ List<DataFileMeta> clusterAfter =
+ IncrementalClusterManager.upgrade(
+ partitionClustered.getOrDefault(partition,
Collections.emptyList()),
+ entry.getValue().outputLevel());
+ LOG.info(
+ "Partition {}: upgrade file level to {}",
+ partition,
+ compactUnits.get(partition).outputLevel());
+
+ List<IndexFileMeta> newIndexFiles = new ArrayList<>();
+ List<IndexFileMeta> deletedIndexFiles = new ArrayList<>();
+ if (dvCommitMessage != null) {
+ newIndexFiles =
dvCommitMessage.compactIncrement().newIndexFiles();
+ deletedIndexFiles =
dvCommitMessage.compactIncrement().deletedIndexFiles();
}
-
writer.commit(JavaConverters.asScalaBuffer(clusterMessages).toSeq());
+ // get the dv index messages
+ CompactIncrement compactIncrement =
+ new CompactIncrement(
+ clusterBefore,
+ clusterAfter,
+ Collections.emptyList(),
+ newIndexFiles,
+ deletedIndexFiles);
+ clusterMessages.add(
+ new CommitMessageImpl(
+ partition,
+ // bucket 0 is bucket for unaware-bucket table
+ // for compatibility with the old design
+ 0,
+ table.coreOptions().bucket(),
+ DataIncrement.emptyIncrement(),
+ compactIncrement));
+ }
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Commit messages after reorganizing:{}",
clusterMessages);
+ }
+
+ if (table.coreOptions().dataEvolutionEnabled()) {
+ List<CommitMessage> preparedMessages =
+
incrementalClusterManager.prepareCommitMessages(clusterMessages);
+ String commitUser =
createCommitUser(table.coreOptions().toConfiguration());
+ try (TableCommitImpl commit =
table.newCommit(commitUser).materializeDvRowIdCheck()) {
+ commit.commit(preparedMessages);
+ } catch (Exception e) {
+ throw new RuntimeException("Commit data evolution clustering
failed.", e);
+ }
} else {
- LOG.info(
- "No data split to incrementally cluster for table {}, skip
this compact job.",
- table.fullName());
+
writer.commit(JavaConverters.asScalaBuffer(clusterMessages).toSeq());
}
}
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/CompactProcedureTestBase.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/CompactProcedureTestBase.scala
index 9364ad824b..2df8a5936c 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/CompactProcedureTestBase.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/CompactProcedureTestBase.scala
@@ -18,10 +18,10 @@
package org.apache.paimon.spark.procedure
-import org.apache.paimon.Snapshot
+import org.apache.paimon.{CoreOptions, Snapshot}
import org.apache.paimon.Snapshot.CommitKind
import org.apache.paimon.append.dataevolution.{DataEvolutionCompactTask,
DataEvolutionNormalCompactTask}
-import org.apache.paimon.data.BinaryRow
+import org.apache.paimon.data.{BinaryRow, BlobDescriptor, BlobViewStruct}
import
org.apache.paimon.deletionvectors.DeletionVectorsIndexFile.DELETION_VECTORS_INDEX
import org.apache.paimon.format.blob.BlobFileFormat
import org.apache.paimon.fs.Path
@@ -2678,6 +2678,319 @@ abstract class CompactProcedureTestBase extends
PaimonSparkTestBase with StreamT
}
}
+ test("Paimon Procedure: data evolution clustering rewrites partial updates
and deletions") {
+ withTable("T") {
+ sql("""
+ |CREATE TABLE T (id INT, value INT)
+ |TBLPROPERTIES (
+ | 'bucket' = '-1',
+ | 'write-only' = 'true',
+ | 'row-tracking.enabled' = 'true',
+ | 'data-evolution.enabled' = 'true',
+ | 'deletion-vectors.enabled' = 'true',
+ | 'clustering.incremental' = 'true',
+ | 'clustering.strategy' = 'order',
+ | 'clustering.columns' = 'value',
+ | 'num-sorted-run.compaction-trigger' = '2')
+ |""".stripMargin)
+ sql("INSERT INTO T SELECT /*+ REPARTITION(1) */ * FROM VALUES (3, 30),
(1, 10)")
+ sql("INSERT INTO T VALUES (2, 20), (4, 40)")
+ sql("UPDATE T SET value = 5 WHERE id = 2")
+ sql("DELETE FROM T WHERE id = 4")
+ val table = loadTable("T")
+ val nextRowId = table.latestSnapshot().get().nextRowId().longValue()
+
+ checkAnswer(sql("CALL sys.compact(table => 'T')"), Row(true) :: Nil)
+
+ checkAnswer(sql("SELECT id, value FROM T"), Seq(Row(2, 5), Row(1, 10),
Row(3, 30)))
+ assertDataEvolutionFileOrder("T", "value")
+ assert(
+ sql("SELECT _ROW_ID FROM T ORDER BY _ROW_ID")
+ .collect()
+ .map(_.getLong(0))
+ .sameElements(nextRowId until nextRowId + 3))
+ Assertions.assertThat(deletionVectorCardinality(table)).isZero
+ assert(normalDataFiles(table).forall(_.level() > 0))
+
+ // New partial updates must use the row IDs assigned by clustering.
+ sql("UPDATE T SET value = 15 WHERE id = 2")
+ checkAnswer(sql("SELECT id, value FROM T"), Seq(Row(1, 10), Row(2, 15),
Row(3, 30)))
+ sql("INSERT INTO T VALUES (5, 0)")
+ checkAnswer(sql("CALL sys.compact(table => 'T')"), Row(true) :: Nil)
+ checkAnswer(
+ sql("SELECT id, value FROM T"),
+ Seq(Row(5, 0), Row(1, 10), Row(2, 15), Row(3, 30)))
+ assertDataEvolutionFileOrder("T", "value")
+ }
+ }
+
+ test("Paimon Procedure: data evolution clustering preserves inline blob
descriptors") {
+ withTable("cluster_descriptor") {
+ val blobData = "descriptor content".getBytes("UTF-8")
+ val externalFile = new java.io.File(tempDBDir,
"clustering_descriptor_blob")
+ java.nio.file.Files.write(externalFile.toPath, blobData)
+ val descriptor = new BlobDescriptor(externalFile.toURI.toString, 0,
blobData.length)
+ val descriptorHex = descriptor.serialize().map(value => f"${value &
0xff}%02X").mkString
+ sql("""
+ |CREATE TABLE cluster_descriptor (id INT, picture BINARY)
+ |TBLPROPERTIES (
+ | 'bucket' = '-1',
+ | 'row-tracking.enabled' = 'true',
+ | 'data-evolution.enabled' = 'true',
+ | 'blob-descriptor-field' = 'picture',
+ | 'clustering.incremental' = 'true',
+ | 'clustering.columns' = 'id')
+ |""".stripMargin)
+ sql(s"INSERT INTO cluster_descriptor VALUES (3, X'$descriptorHex'), (1,
NULL)")
+ val nextRowId =
loadTable("cluster_descriptor").latestSnapshot().get().nextRowId().longValue()
+
+ sql("CALL sys.compact(table => 'cluster_descriptor', compact_strategy =>
'full')")
+
+ checkAnswer(
+ sql("SELECT id, picture FROM cluster_descriptor"),
+ Seq(Row(1, null), Row(3, blobData)))
+ val descriptorOption =
+
s"spark.paimon.paimon.$dbName0.cluster_descriptor.${CoreOptions.BLOB_AS_DESCRIPTOR.key()}"
+ withSparkSQLConf(descriptorOption -> "true") {
+ val rows = sql("SELECT picture FROM cluster_descriptor ORDER BY
id").collect()
+ assert(rows(0).isNullAt(0))
+ assert(util.Arrays.equals(rows(1).getAs[Array[Byte]](0),
descriptor.serialize()))
+ }
+ assert(
+ sql("SELECT _ROW_ID FROM
cluster_descriptor").collect().forall(_.getLong(0) >= nextRowId))
+ assertDataEvolutionFileOrder("cluster_descriptor", "id")
+ assert(normalDataFiles(loadTable("cluster_descriptor")).forall(_.level()
> 0))
+ }
+ }
+
+ test("Paimon Procedure: data evolution clustering preserves inline blob
views") {
+ withTable("cluster_view_source", "cluster_view") {
+ sql("""
+ |CREATE TABLE cluster_view_source (id INT, picture BINARY)
+ |TBLPROPERTIES (
+ | 'row-tracking.enabled' = 'true',
+ | 'data-evolution.enabled' = 'true',
+ | 'blob-field' = 'picture')
+ |""".stripMargin)
+ sql("INSERT INTO cluster_view_source VALUES (3, X'48656C6C6F'), (1,
NULL)")
+ sql("""
+ |CREATE TABLE cluster_view (id INT, picture BINARY)
+ |TBLPROPERTIES (
+ | 'bucket' = '-1',
+ | 'row-tracking.enabled' = 'true',
+ | 'data-evolution.enabled' = 'true',
+ | 'blob-view-field' = 'picture',
+ | 'clustering.incremental' = 'true',
+ | 'clustering.columns' = 'id')
+ |""".stripMargin)
+ sql(
+ s"INSERT INTO cluster_view SELECT id, " +
+ s"sys.blob_view('$dbName0.cluster_view_source', 'picture', _ROW_ID)
" +
+ "FROM `cluster_view_source$row_tracking`")
+ sql("INSERT INTO cluster_view VALUES (2, NULL)")
+ val preserveViewOption =
+
s"spark.paimon.paimon.$dbName0.cluster_view.${CoreOptions.BLOB_VIEW_RESOLVE_ENABLED.key()}"
+ var originalReferences = Array.empty[Row]
+ withSparkSQLConf(preserveViewOption -> "false") {
+ originalReferences = sql("SELECT picture FROM cluster_view ORDER BY
id").collect()
+ }
+ // A non-null view of an upstream NULL must remain a reference after the
rewrite.
+ assert(!originalReferences(0).isNullAt(0))
+ assert(originalReferences(1).isNullAt(0))
+ assert(
+ BlobViewStruct
+ .deserialize(originalReferences(0).getAs[Array[Byte]](0))
+ .identifier()
+ .getFullName == s"$dbName0.cluster_view_source")
+ val nextRowId =
loadTable("cluster_view").latestSnapshot().get().nextRowId().longValue()
+
+ sql("CALL sys.compact(table => 'cluster_view', compact_strategy =>
'full')")
+
+ checkAnswer(
+ sql("SELECT id, picture FROM cluster_view"),
+ Seq(Row(1, null), Row(2, null), Row(3, "Hello".getBytes("UTF-8"))))
+ withSparkSQLConf(preserveViewOption -> "false") {
+ val rewrittenReferences = sql("SELECT picture FROM cluster_view ORDER
BY id").collect()
+ assert(rewrittenReferences.length == originalReferences.length)
+ rewrittenReferences.zip(originalReferences).foreach {
+ case (rewritten, original) =>
+ assert(
+ util.Arrays.equals(rewritten.getAs[Array[Byte]](0),
original.getAs[Array[Byte]](0)))
+ }
+ }
+ assert(sql("SELECT _ROW_ID FROM
cluster_view").collect().forall(_.getLong(0) >= nextRowId))
+ assertDataEvolutionFileOrder("cluster_view", "id")
+ assert(normalDataFiles(loadTable("cluster_view")).forall(_.level() > 0))
+ }
+ }
+
+ test("Paimon Procedure: data evolution historical clustering applies
partition idle filter") {
+ withTable("T") {
+ sql("""
+ |CREATE TABLE T (id INT, pt STRING)
+ |PARTITIONED BY (pt)
+ |TBLPROPERTIES (
+ | 'bucket' = '-1',
+ | 'write-only' = 'true',
+ | 'row-tracking.enabled' = 'true',
+ | 'data-evolution.enabled' = 'true',
+ | 'clustering.incremental' = 'true',
+ | 'clustering.strategy' = 'order',
+ | 'clustering.columns' = 'id',
+ | 'clustering.history-partition.idle-to-full-sort' = '0 s')
+ |""".stripMargin)
+ sql("INSERT INTO T SELECT /*+ REPARTITION(1) */ * FROM VALUES (3,
'old'), (1, 'old')")
+ sql("INSERT INTO T SELECT /*+ REPARTITION(1) */ * FROM VALUES (4,
'new'), (2, 'new')")
+ val table = loadTable("T")
+ val nextRowId = table.latestSnapshot().get().nextRowId().longValue()
+
+ checkAnswer(
+ sql(
+ "CALL sys.compact(table => 'T', partitions => 'pt=new', " +
+ "compact_strategy => 'full', partition_idle_time => '0 s')"),
+ Row(true) :: Nil)
+
+ checkAnswer(sql("SELECT id FROM T WHERE pt = 'old'"), Seq(Row(1),
Row(3)))
+ checkAnswer(sql("SELECT id FROM T WHERE pt = 'new'"), Seq(Row(2),
Row(4)))
+ assertDataEvolutionFileOrder("T", "id")
+ assert(sql("SELECT _ROW_ID FROM T").collect().forall(_.getLong(0) >=
nextRowId))
+ assert(normalDataFiles(table).forall(_.level() ==
table.coreOptions().numLevels() - 1))
+ }
+ }
+
+ test("Paimon Procedure: data evolution clustering respects minor and full
strategies") {
+ withTable("T") {
+ sql("""
+ |CREATE TABLE T (id INT)
+ |TBLPROPERTIES (
+ | 'bucket' = '-1',
+ | 'write-only' = 'true',
+ | 'row-tracking.enabled' = 'true',
+ | 'data-evolution.enabled' = 'true',
+ | 'clustering.incremental' = 'true',
+ | 'clustering.strategy' = 'order',
+ | 'clustering.columns' = 'id',
+ | 'num-sorted-run.compaction-trigger' = '10')
+ |""".stripMargin)
+ sql("INSERT INTO T SELECT /*+ REPARTITION(1) */ * FROM VALUES (3), (1),
(2)")
+ val table = loadTable("T")
+ val before = lastSnapshotId(table)
+ checkAnswer(sql("CALL sys.compact(table => 'T')"), Row(true) :: Nil)
+ Assertions.assertThat(lastSnapshotId(table)).isEqualTo(before)
+
+ checkAnswer(
+ sql("CALL sys.compact(table => 'T', compact_strategy => 'full')"),
+ Row(true) :: Nil)
+ Assertions.assertThat(lastSnapshotId(table)).isEqualTo(before + 1)
+ checkAnswer(sql("SELECT id FROM T"), Seq(Row(1), Row(2), Row(3)))
+ assertDataEvolutionFileOrder("T", "id")
+ assert(normalDataFiles(table).forall(_.level() ==
table.coreOptions().numLevels() - 1))
+ }
+ }
+
+ Seq("local-sort", "global-sort").foreach {
+ mode =>
+ Seq("", "partition.sink-strategy=hash",
"clustering.incremental.optimize-write=true")
+ .foreach {
+ writeOption =>
+ val suffix = if (writeOption.isEmpty) "" else s" with $writeOption"
+ test(s"Paimon Procedure: data evolution clustering uses
distributed $mode$suffix") {
+ withSparkSQLConf(
+ "spark.sql.shuffle.partitions" -> "2",
+ "spark.sql.adaptive.enabled" -> "false") {
+ withTable("T") {
+ sql(s"""
+ |CREATE TABLE T (id INT, value INT, pt STRING)
+ |PARTITIONED BY (pt)
+ |TBLPROPERTIES (
+ | 'bucket' = '-1',
+ | 'write-only' = 'true',
+ | 'row-tracking.enabled' = 'true',
+ | 'data-evolution.enabled' = 'true',
+ | 'deletion-vectors.enabled' = 'true',
+ | 'clustering.incremental' = 'true',
+ | 'clustering.incremental.mode' = '$mode',
+ | 'clustering.strategy' = 'order',
+ | 'clustering.columns' = 'value',
+ | 'source.split.target-size' = '1 b')
+ |""".stripMargin)
+ (0 until 4).foreach {
+ batch =>
+ sql(s"""
+ |INSERT INTO T
+ |SELECT /*+ REPARTITION(1) */
+ | CAST(59 - (id * 4 + $batch) AS INT),
+ | CAST((59 - (id * 4 + $batch)) * 10 AS INT), 'A'
+ |FROM range(15)
+ |""".stripMargin)
+ }
+ sql("UPDATE T SET value = -2 WHERE id = 2")
+ sql("DELETE FROM T WHERE id = 4")
+ val table = loadTable("T")
+ val nextRowId =
table.latestSnapshot().get().nextRowId().longValue()
+
+ checkAnswer(
+ sql("CALL sys.compact(table => 'T', compact_strategy =>
'full', " +
+ s"options => '$writeOption')"),
+ Row(true) :: Nil)
+
+ val expected =
+ (0 until 60).filter(_ != 4).map(id => Row(id, if (id == 2)
-2 else id * 10))
+ checkAnswer(sql("SELECT id, value FROM T"), expected)
+ val fileValues = assertDataEvolutionFileOrder("T", "value")
+ assert(fileValues.size > 1)
+ val ranges = fileValues.map(values => (values.head,
values.last)).sortBy(_._1)
+ if (mode == "global-sort") {
+ assert(fileValues.size == 2)
+ assert(ranges.sliding(2).forall(pair => pair.head._2 <
pair.last._1))
+ } else {
+ assert(ranges.sliding(2).exists(pair => pair.head._2 >
pair.last._1))
+ }
+ assert(sql("SELECT _ROW_ID FROM
T").collect().forall(_.getLong(0) >= nextRowId))
+ assert(
+ normalDataFiles(table).forall(_.level() ==
table.coreOptions().numLevels() - 1))
+
Assertions.assertThat(deletionVectorCardinality(table)).isZero
+
+ sql("UPDATE T SET value = 12 WHERE id = 2")
+ checkAnswer(sql("SELECT value FROM T WHERE id = 2"), Row(12)
:: Nil)
+ }
+ }
+ }
+ }
+ }
+
+ test("Paimon Procedure: data evolution clustering removes fully deleted
partitions") {
+ withTable("T") {
+ sql("""
+ |CREATE TABLE T (id INT, pt STRING)
+ |PARTITIONED BY (pt)
+ |TBLPROPERTIES (
+ | 'bucket' = '-1',
+ | 'write-only' = 'true',
+ | 'row-tracking.enabled' = 'true',
+ | 'data-evolution.enabled' = 'true',
+ | 'deletion-vectors.enabled' = 'true',
+ | 'clustering.incremental' = 'true',
+ | 'clustering.strategy' = 'order',
+ | 'clustering.columns' = 'id')
+ |""".stripMargin)
+ sql("INSERT INTO T VALUES (3, 'dead'), (1, 'dead'), (4, 'live'), (2,
'live')")
+ sql("DELETE FROM T WHERE id IN (1, 3)")
+ val table = loadTable("T")
+ val beforeFiles = normalDataFiles(table).map(_.fileName()).toSet
+ Assertions.assertThat(deletionVectorCardinality(table)).isEqualTo(2)
+
+ checkAnswer(
+ sql("CALL sys.compact(table => 'T', compact_strategy => 'full')"),
+ Row(true) :: Nil)
+
+ checkAnswer(sql("SELECT id, pt FROM T"), Seq(Row(2, "live"), Row(4,
"live")))
+ assert(normalDataFiles(table).forall(file =>
!beforeFiles.contains(file.fileName())))
+ Assertions.assertThat(deletionVectorCardinality(table)).isZero
+ assertDataEvolutionFileOrder("T", "id")
+ }
+ }
+
test("Paimon Procedure: materialize deletion vectors enables snapshot
expiration") {
withTable("T") {
sql("""
@@ -2822,6 +3135,23 @@ abstract class CompactProcedureTestBase extends
PaimonSparkTestBase with StreamT
.get
}
+ private def assertDataEvolutionFileOrder(tableName: String, columnName:
String): Seq[Seq[Int]] = {
+ val rows = sql(s"SELECT _ROW_ID, `$columnName` FROM $tableName ORDER BY
_ROW_ID")
+ .collect()
+ .map(row => (row.getLong(0), row.getInt(1)))
+ normalDataFiles(loadTable(tableName)).map {
+ file =>
+ val firstRowId = file.nonNullFirstRowId()
+ val values = rows
+ .filter { case (rowId, _) => rowId >= firstRowId && rowId <
firstRowId + file.rowCount() }
+ .map(_._2)
+ .toSeq
+ assert(values.size.toLong == file.rowCount())
+ assert(values == values.sorted)
+ values
+ }
+ }
+
private def normalDataFiles(table: FileStoreTable): Seq[DataFileMeta] = {
table
.newSnapshotReader()