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 6528cb5d3a [core] Compact Data Evolution column versions alongside
clustering (#10684)
6528cb5d3a is described below
commit 6528cb5d3a9577507d1f8a14aee5acfdd5f515f3
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Oct 20 12:56:01 2026 +0800
[core] Compact Data Evolution column versions alongside clustering (#10684)
---
docs/docs/append-table/incremental-clustering.mdx | 2 +
.../data-evolution-maintenance.mdx | 31 +-
.../cluster/DataEvolutionClusterCompaction.java | 112 ++++++
.../DataEvolutionIncrementalClusterRewriter.java | 18 +-
.../dataevolution/ClusteringIndexRewriter.java | 48 ++-
.../DataEvolutionCompactTaskSerializer.java | 8 +-
.../DataEvolutionNormalCompactTask.java | 27 +-
.../paimon/operation/FileStoreCommitImpl.java | 26 +-
.../commit/DataEvolutionConflictDetection.java | 19 +-
.../commit/RowIdRangeConflictChecker.java | 20 +-
.../apache/paimon/utils/DataEvolutionUtils.java | 66 ++++
.../DataEvolutionClusterCompactionTest.java | 277 ++++++++++++++
.../dataevolution/DataEvolutionClusterTest.java | 414 +++++++++++++++++++++
.../DataEvolutionCompactTaskSerializerTest.java | 100 +++++
.../DataEvolutionNormalCompactTaskTest.java | 80 ++++
.../paimon/utils/DataEvolutionUtilsTest.java | 100 +++++
.../flink/compact/IncrementalClusterCompact.java | 32 +-
.../paimon/flink/DataEvolutionDeleteSqlITCase.java | 207 +++++++++++
.../paimon/spark/procedure/CompactProcedure.java | 18 +-
.../procedure/DataEvolutionRewriteExecutor.java | 111 +++---
.../spark/procedure/CompactProcedureTestBase.scala | 72 ++++
21 files changed, 1666 insertions(+), 122 deletions(-)
diff --git a/docs/docs/append-table/incremental-clustering.mdx
b/docs/docs/append-table/incremental-clustering.mdx
index 49f1763712..d0152cde9e 100644
--- a/docs/docs/append-table/incremental-clustering.mdx
+++ b/docs/docs/append-table/incremental-clustering.mdx
@@ -138,6 +138,8 @@ 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.
+Already-clustered groups with accumulated non-clustering column versions can
instead be compacted in the same job,
+preserving their row IDs and logical deletes; see [Data Evolution
clustering](../multimodal-table/data-evolution-maintenance#incremental-clustering).
## Run Incremental Clustering
diff --git a/docs/docs/multimodal-table/data-evolution-maintenance.mdx
b/docs/docs/multimodal-table/data-evolution-maintenance.mdx
index 25646225fe..64d6bedb77 100644
--- a/docs/docs/multimodal-table/data-evolution-maintenance.mdx
+++ b/docs/docs/multimodal-table/data-evolution-maintenance.mdx
@@ -184,8 +184,15 @@ 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. Updating only non-clustering columns does not make an already
clustered
-group eligible again: its row order remains valid, so even a full clustering
call
-can skip it and preserve its row IDs and indexes on unchanged columns.
+group require sorting again: its row order remains valid. Unselected groups
with
+at least `compaction.min.file-num` normal files (default 5) are instead
compacted
+within the same job and snapshot. This merges their latest column values while
+preserving row IDs, clustering level, logical deletes, and indexes on unchanged
+columns. If minor selection skips a group whose sorting order is no longer
+valid, column-version compaction keeps it at level 0 for later clustering.
+Each such task handles one complete row-ID group; adjacent groups are
+not combined because row-ID order alone does not prove clustering-key order.
+Groups below the threshold can be skipped even by a full clustering call.
Updating a clustering column
makes the group eligible again. When a group is selected, all its column
versions
are rewritten together, including newer values of non-clustering columns.
@@ -203,11 +210,15 @@ 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. Flink clustering also rewrites
+in batch mode. Explicit compact jobs combine clustering and column-version
+compaction while this option is enabled. A group selected for clustering is
+rewritten only once. To run ordinary Data Evolution compaction across adjacent
+row-ID groups, disable clustering for the invocation with
+`options => 'clustering.incremental=false'`.
+
+The sorting path removes the applied deletion vectors; column-version
compaction
+retains logical deletes and moves their vectors to the replacement files.
+Flink clustering also rewrites
B-tree and bitmap index files with the new row IDs, reusing their stored keys.
Indexes outside the rewritten row-ID ranges are preserved, including those in
the same partition. Partially indexed data retains its exact coverage after
sorting.
@@ -219,8 +230,8 @@ The job records the old row IDs alongside each output file
so that concurrent
index creation and optimistic commit retries use the final assigned row IDs.
Indexes with stale keys after indexed-column updates, and affected index types
-without RowID rewrite support, are dropped. Spark clustering currently drops
-global indexes in the affected partitions. Rebuild dropped indexes, or use
+without RowID rewrite support, are dropped. Spark clustering drops index files
+whose coverage intersects the row-ID ranges selected for sorting. Rebuild
dropped indexes, or use
`scalar-index.search-mode = full` for complete scalar query results while part
of the table remains unindexed.
Concurrent updates or deletes that overlap the selected row-ID groups cause
@@ -237,7 +248,7 @@ the previous files until expiration.
| 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. |
+| Data Evolution incremental clustering | Assigns new IDs to surviving rows in
groups selected for sorting; column-version compaction preserves IDs. |
| 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/paimon-core/src/main/java/org/apache/paimon/append/cluster/DataEvolutionClusterCompaction.java
b/paimon-core/src/main/java/org/apache/paimon/append/cluster/DataEvolutionClusterCompaction.java
new file mode 100644
index 0000000000..58ee72a1ff
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/append/cluster/DataEvolutionClusterCompaction.java
@@ -0,0 +1,112 @@
+/*
+ * 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.DataEvolutionCompactTask;
+import org.apache.paimon.append.dataevolution.DataEvolutionNormalCompactTask;
+import org.apache.paimon.compact.CompactUnit;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.partition.PartitionPredicate;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.utils.RangeHelper;
+
+import javax.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+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;
+
+/** Compacts column versions left outside the incremental clustering
selection. */
+public final class DataEvolutionClusterCompaction {
+
+ private DataEvolutionClusterCompaction() {}
+
+ public static List<DataEvolutionCompactTask> plan(
+ FileStoreTable table,
+ Snapshot snapshot,
+ @Nullable PartitionPredicate predicate,
+ Map<BinaryRow, CompactUnit> clusteringUnits) {
+ if (!table.coreOptions().dataEvolutionEnabled()) {
+ return Collections.emptyList();
+ }
+ Map<BinaryRow, List<DataFileMeta>> partitions = new HashMap<>();
+ for (DataSplit split :
+ table.newSnapshotReader()
+ .dropStats()
+ .withSnapshot(snapshot)
+ .withPartitionFilter(predicate)
+ .read()
+ .dataSplits()) {
+ partitions
+ .computeIfAbsent(split.partition(), ignored -> new
ArrayList<>())
+ .addAll(split.dataFiles());
+ }
+ DataEvolutionIncrementalClusterRewriter rewriter =
+ new DataEvolutionIncrementalClusterRewriter(table, snapshot);
+ RangeHelper<DataFileMeta> helper = new
RangeHelper<>(DataFileMeta::nonNullRowIdRange);
+ List<DataEvolutionCompactTask> tasks = new ArrayList<>();
+ partitions.forEach(
+ (partition, files) -> {
+ CompactUnit clustering = clusteringUnits.get(partition);
+ Set<String> selected =
+ clustering == null
+ ? Collections.emptySet()
+ : clustering.files().stream()
+ .map(DataFileMeta::fileName)
+ .collect(Collectors.toSet());
+ List<DataFileMeta> normalFiles =
+ files.stream()
+ .filter(
+ file ->
+
!isBlobFile(file.fileName())
+ &&
!isVectorStoreFile(file.fileName()))
+ .collect(Collectors.toList());
+ for (List<DataFileMeta> group :
helper.mergeOverlappingRanges(normalFiles)) {
+ checkArgument(
+ helper.areAllRangesSame(group),
+ "Normal files must have equal row-id ranges.");
+ if (group.size() < Math.max(2,
table.coreOptions().compactionMinFileNum())
+ || group.stream()
+ .anyMatch(file ->
selected.contains(file.fileName()))) {
+ continue;
+ }
+ // A single group preserves even LOCAL_SORT order when
its level is trusted.
+ // Dirty groups still compact at L0 without claiming
that they are sorted.
+ tasks.add(
+ new DataEvolutionNormalCompactTask(
+ partition,
+ group,
+ Collections.emptyList(),
+ rewriter.groupLevel(group)));
+ }
+ });
+ return tasks;
+ }
+}
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
index f50acb29a0..90269cd4bb 100644
---
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
@@ -107,6 +107,10 @@ class DataEvolutionIncrementalClusterRewriter extends
IncrementalClusterRewriter
return runs;
}
+ int groupLevel(List<DataFileMeta> group) {
+ return groupLevel(group, retrieveAnchorFile(group, file -> file));
+ }
+
private int groupLevel(List<DataFileMeta> group, DataFileMeta anchor) {
int level = anchor.level();
CoreOptions anchorOptions =
CoreOptions.fromMap(schema(anchor.schemaId()).options());
@@ -162,11 +166,21 @@ class DataEvolutionIncrementalClusterRewriter extends
IncrementalClusterRewriter
@Override
public List<CommitMessage> prepareCommitMessages(List<CommitMessage>
messages) {
Map<BinaryRow, CompactIncrement> increments = new HashMap<>();
+ List<CommitMessage> prepared = new ArrayList<>();
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 increment = impl.compactIncrement();
+ if (!increment.compactAfter().isEmpty()
+ && increment.compactAfter().stream()
+ .allMatch(file -> file.firstRowId() != null)) {
+ // Keep row-preserving tasks separate: their DVs move to the
replacement anchor,
+ // whereas clustering outputs have already applied DVs and
need fresh row IDs.
+ prepared.add(message);
+ continue;
+ }
CompactIncrement merged =
increments.computeIfAbsent(
message.partition(),
@@ -177,14 +191,12 @@ class DataEvolutionIncrementalClusterRewriter extends
IncrementalClusterRewriter
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(
@@ -194,7 +206,7 @@ class DataEvolutionIncrementalClusterRewriter extends
IncrementalClusterRewriter
new CommitMessageImpl(
partition, 0, null,
DataIncrement.emptyIncrement(), increment));
});
- // The committer invalidates global indexes against the latest
snapshot on every attempt.
+ // The committer maintains global indexes against the latest snapshot
on every attempt.
prepared.addAll(
new DataEvolutionCompactDeletionVectorRewriter(table, snapshot)
.rewriteDeletionVectors(prepared));
diff --git
a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/ClusteringIndexRewriter.java
b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/ClusteringIndexRewriter.java
index 3cf1f2f687..36d360dac4 100644
---
a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/ClusteringIndexRewriter.java
+++
b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/ClusteringIndexRewriter.java
@@ -68,6 +68,7 @@ import java.util.Set;
import java.util.TreeMap;
import java.util.stream.Collectors;
+import static
org.apache.paimon.utils.DataEvolutionUtils.materializedRowIdRanges;
import static org.apache.paimon.utils.Preconditions.checkArgument;
/** Rewrites scalar index postings after clustering output files receive their
final row IDs. */
@@ -88,6 +89,7 @@ public final class ClusteringIndexRewriter {
List<ManifestEntry> dataFiles,
List<IndexManifestEntry> indexFiles,
Set<String> mappedClusteringOutputs,
+ Set<String> rowIdPreservingOutputs,
long newSnapshotId,
List<Path> createdFiles)
throws IOException {
@@ -121,13 +123,6 @@ public final class ClusteringIndexRewriter {
.filter(e -> e.kind() == FileKind.ADD)
.map(ManifestEntry::file)
.collect(Collectors.toList());
- // Other materialization callers do not supply a permutation and
retain their existing
- // invalidation behavior. A mixed mapping must never silently
retain a stale posting.
- if (after.stream()
- .anyMatch(file ->
!mappedClusteringOutputs.contains(file.fileName()))) {
- result.addAll(group.getValue());
- continue;
- }
List<ManifestEntry> before =
files.stream()
.filter(e -> e.kind() == FileKind.DELETE)
@@ -144,16 +139,41 @@ public final class ClusteringIndexRewriter {
result.addAll(group.getValue());
continue;
}
+ List<Range> removed =
+ materializedRowIdRanges(
+
before.stream().map(ManifestEntry::file).collect(Collectors.toList()),
+ after.stream()
+ .filter(
+ file ->
+
rowIdPreservingOutputs.contains(
+ file.fileName()))
+ .collect(Collectors.toList()));
+ if (removed.isEmpty()) {
+ // Preserve explicitly requested index deletions for ordinary
compaction callers.
+ result.addAll(group.getValue());
+ continue;
+ }
+ after.removeIf(file ->
rowIdPreservingOutputs.contains(file.fileName()));
+ // Engines without a permutation still invalidate affected
indexes. Independently
+ // compacted ranges in the same partition retain their original
index files.
+ if (after.stream()
+ .anyMatch(file ->
!mappedClusteringOutputs.contains(file.fileName()))) {
+ group.getValue().stream()
+ .filter(
+ entry ->
+ !Range.and(
+
Collections.singletonList(
+
entry.indexFile()
+
.globalIndexMeta()
+
.rowRange()),
+ removed)
+ .isEmpty())
+ .forEach(result::add);
+ continue;
+ }
after.sort(Comparator.comparingLong(DataFileMeta::nonNullFirstRowId));
long firstAssignedRowId =
after.isEmpty() ? Long.MAX_VALUE :
after.get(0).nonNullFirstRowId();
- List<Range> removed =
- Range.sortAndMergeOverlap(
- before.stream()
- .map(ManifestEntry::file)
- .map(DataFileMeta::nonNullRowIdRange)
- .collect(Collectors.toList()),
- true);
DataFilePathFactory dataPath =
pathFactory.createDataFilePathFactory(
group.getKey().getLeft(),
group.getKey().getRight());
diff --git
a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionCompactTaskSerializer.java
b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionCompactTaskSerializer.java
index 5b4e463dcb..cd6129a170 100644
---
a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionCompactTaskSerializer.java
+++
b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionCompactTaskSerializer.java
@@ -43,7 +43,7 @@ import static
org.apache.paimon.utils.SerializationUtils.serializeBinaryRow;
public class DataEvolutionCompactTaskSerializer
implements VersionedSerializer<DataEvolutionCompactTask> {
- private static final int CURRENT_VERSION = 4;
+ private static final int CURRENT_VERSION = 5;
private final DataFileMetaSerializer dataFileSerializer;
@@ -83,6 +83,7 @@ public class DataEvolutionCompactTaskSerializer
view.writeLong(range.from);
view.writeLong(range.to);
}
+ view.writeInt(((DataEvolutionNormalCompactTask)
task).outputLevel());
} else if (task.type() ==
DataEvolutionCompactTask.TaskType.MATERIALIZE_DELETION) {
DeletionFile.serializeList(
view, ((DataEvolutionMaterializeDeletionCompactTask)
task).deletionFiles());
@@ -108,7 +109,7 @@ public class DataEvolutionCompactTaskSerializer
}
private void checkVersion(int version) {
- if (version != CURRENT_VERSION) {
+ if (version != 4 && version != CURRENT_VERSION) {
throw new UnsupportedOperationException(
"Expecting DataEvolutionCompactTask version to be "
+ CURRENT_VERSION
@@ -132,7 +133,8 @@ public class DataEvolutionCompactTaskSerializer
for (int i = 0; i < rangeCount; i++) {
ranges.add(new Range(view.readLong(), view.readLong()));
}
- return new DataEvolutionNormalCompactTask(partition, files,
ranges);
+ int outputLevel = version >= 5 ? view.readInt() : 0;
+ return new DataEvolutionNormalCompactTask(partition, files,
ranges, outputLevel);
case BLOB:
return new DataEvolutionBlobCompactTask(partition, files);
case MATERIALIZE_DELETION:
diff --git
a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionNormalCompactTask.java
b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionNormalCompactTask.java
index 7065a37bc1..2191e5026b 100644
---
a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionNormalCompactTask.java
+++
b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionNormalCompactTask.java
@@ -69,6 +69,7 @@ public class DataEvolutionNormalCompactTask extends
DataEvolutionCompactTask {
private static final Logger LOG =
LoggerFactory.getLogger(DataEvolutionNormalCompactTask.class);
private final List<Range> protectedRanges;
+ private final int outputLevel;
public DataEvolutionNormalCompactTask(BinaryRow partition,
List<DataFileMeta> files) {
this(partition, files, Collections.emptyList());
@@ -76,8 +77,19 @@ public class DataEvolutionNormalCompactTask extends
DataEvolutionCompactTask {
public DataEvolutionNormalCompactTask(
BinaryRow partition, List<DataFileMeta> files, List<Range>
protectedRanges) {
+ this(partition, files, protectedRanges, 0);
+ }
+
+ /** Preserves a clustering level only when the caller has verified the
complete input group. */
+ public DataEvolutionNormalCompactTask(
+ BinaryRow partition,
+ List<DataFileMeta> files,
+ List<Range> protectedRanges,
+ int outputLevel) {
super(partition, files);
+ checkArgument(outputLevel >= 0, "Output level must not be negative.");
checkContiguousRowRange(files);
+ this.outputLevel = outputLevel;
this.protectedRanges =
Collections.unmodifiableList(Range.sortAndMergeOverlap(protectedRanges));
}
@@ -86,15 +98,20 @@ public class DataEvolutionNormalCompactTask extends
DataEvolutionCompactTask {
return protectedRanges;
}
+ public int outputLevel() {
+ return outputLevel;
+ }
+
@Override
public boolean equals(Object other) {
return super.equals(other)
- && protectedRanges.equals(((DataEvolutionNormalCompactTask)
other).protectedRanges);
+ && protectedRanges.equals(((DataEvolutionNormalCompactTask)
other).protectedRanges)
+ && outputLevel == ((DataEvolutionNormalCompactTask)
other).outputLevel;
}
@Override
public int hashCode() {
- return Objects.hash(super.hashCode(), protectedRanges);
+ return Objects.hash(super.hashCode(), protectedRanges, outputLevel);
}
@Override
@@ -178,7 +195,8 @@ public class DataEvolutionNormalCompactTask extends
DataEvolutionCompactTask {
long maxSequenceNumber = maxSequenceId(compactBefore);
long nextRowId = firstRowId;
long[] writeColsSequences =
- options.ignoreIndexColumnUpdate() && !writeResult.isEmpty()
+ (options.ignoreIndexColumnUpdate() ||
options.clusteringIncrementalEnabled())
+ && !writeResult.isEmpty()
? compactedWriteColsSequences(
table,
writeResult
@@ -192,6 +210,9 @@ public class DataEvolutionNormalCompactTask extends
DataEvolutionCompactTask {
if (writeColsSequences != null) {
dataFileMeta =
dataFileMeta.withWriteColsSequences(writeColsSequences);
}
+ if (outputLevel > 0) {
+ dataFileMeta = dataFileMeta.upgrade(outputLevel);
+ }
compactAfter.add(dataFileMeta);
nextRowId += dataFileMeta.rowCount();
}
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 5ff13ba2f1..70a4f2ac95 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
@@ -111,7 +111,6 @@ import java.util.stream.Collectors;
import static java.util.Collections.emptyList;
import static
org.apache.paimon.deletionvectors.DeletionVectorsIndexFile.DELETION_VECTORS_INDEX;
-import static org.apache.paimon.format.blob.BlobFileFormat.isBlobFile;
import static org.apache.paimon.manifest.ManifestEntry.nullableRecordCount;
import static org.apache.paimon.manifest.ManifestEntry.recordCountAdd;
import static org.apache.paimon.manifest.ManifestEntry.recordCountDelete;
@@ -119,7 +118,7 @@ import static
org.apache.paimon.operation.commit.ManifestEntryChanges.changedPar
import static
org.apache.paimon.operation.commit.RowTrackingCommitUtils.assignRowTracking;
import static
org.apache.paimon.partition.PartitionPredicate.createBinaryPartitions;
import static
org.apache.paimon.partition.PartitionPredicate.createPartitionPredicate;
-import static org.apache.paimon.types.VectorType.isVectorStoreFile;
+import static
org.apache.paimon.utils.DataEvolutionUtils.materializedRowIdRanges;
import static org.apache.paimon.utils.Preconditions.checkArgument;
import static org.apache.paimon.utils.Preconditions.checkNotNull;
@@ -845,16 +844,12 @@ public class FileStoreCommitImpl implements
FileStoreCommit {
Set<Pair<BinaryRow, Integer>> result = new HashSet<>();
for (CommitMessage message : commitMessages) {
CommitMessageImpl commitMessage = (CommitMessageImpl) message;
- if (commitMessage.compactIncrement().compactBefore().stream()
- .noneMatch(
- file ->
- !isBlobFile(file.fileName())
- &&
!isVectorStoreFile(file.fileName()))
- || commitMessage.compactIncrement().compactAfter().stream()
- .anyMatch(file -> file.firstRowId() != null)) {
- continue;
+ if (!materializedRowIdRanges(
+ commitMessage.compactIncrement().compactBefore(),
+ commitMessage.compactIncrement().compactAfter())
+ .isEmpty()) {
+ result.add(Pair.of(commitMessage.partition(),
commitMessage.bucket()));
}
- result.add(Pair.of(commitMessage.partition(),
commitMessage.bucket()));
}
return result;
}
@@ -1172,6 +1167,14 @@ public class FileStoreCommitImpl implements
FileStoreCommit {
.map(entry -> entry.file().fileName())
.collect(Collectors.toSet())
: Collections.emptySet();
+ Set<String> rowIdPreservingOutputs =
+ commitKind == CommitKind.COMPACT &&
options.dataEvolutionEnabled()
+ ? deltaFiles.stream()
+ .filter(entry -> entry.kind() == FileKind.ADD)
+ .filter(entry -> entry.file().firstRowId() !=
null)
+ .map(entry -> entry.file().fileName())
+ .collect(Collectors.toSet())
+ : Collections.emptySet();
Set<Long> rebasedReassignments =
changesProvider.rebasedReassignments();
long newSnapshotId = Snapshot.FIRST_SNAPSHOT_ID;
@@ -1359,6 +1362,7 @@ public class FileStoreCommitImpl implements
FileStoreCommit {
deltaFiles,
indexFiles,
mappedClusteringOutputs,
+ rowIdPreservingOutputs,
newSnapshotId,
clusteringIndexOutputs);
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/DataEvolutionConflictDetection.java
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/DataEvolutionConflictDetection.java
index b3a7c82fae..344c9e3892 100644
---
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/DataEvolutionConflictDetection.java
+++
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/DataEvolutionConflictDetection.java
@@ -63,6 +63,7 @@ import java.util.stream.Collectors;
import static org.apache.paimon.format.blob.BlobFileFormat.isBlobFile;
import static
org.apache.paimon.operation.commit.ManifestEntryChanges.changedPartitions;
import static org.apache.paimon.types.VectorType.isVectorStoreFile;
+import static
org.apache.paimon.utils.DataEvolutionUtils.materializedRowIdRanges;
import static org.apache.paimon.utils.Preconditions.checkState;
/** Conflict detection for Data Evolution tables. */
@@ -566,19 +567,19 @@ public class DataEvolutionConflictDetection extends
ConflictDetection {
SchemaManager schemaManager,
List<ManifestEntry> deltaFiles,
boolean nestedFieldEnabled) {
- // Materializing deletion vectors rewrites complete row ranges. A
concurrent ADD in a
- // deleted normal-file range can otherwise restore logically
deleted rows.
- List<DataFileMeta> deletedNormalFiles =
+ // Only materialized ranges require exclusive access.
Row-ID-preserving compaction
+ // keeps column sequences and can coexist with later
partial-column writes.
+ List<DataFileMeta> before =
deltaFiles.stream()
.filter(entry -> entry.kind() == FileKind.DELETE)
.map(ManifestEntry::file)
- .filter(file -> file.firstRowId() != null)
- .filter(
- file ->
- !isBlobFile(file.fileName())
- &&
!isVectorStoreFile(file.fileName()))
.collect(Collectors.toList());
- return RowIdRangeConflictChecker.fromDataFiles(deletedNormalFiles);
+ List<DataFileMeta> after =
+ deltaFiles.stream()
+ .filter(entry -> entry.kind() == FileKind.ADD)
+ .map(ManifestEntry::file)
+ .collect(Collectors.toList());
+ return
RowIdRangeConflictChecker.fromRanges(materializedRowIdRanges(before, after));
}
@Override
diff --git
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/RowIdRangeConflictChecker.java
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/RowIdRangeConflictChecker.java
index cf6bea0a51..d0c65aaa5c 100644
---
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/RowIdRangeConflictChecker.java
+++
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/RowIdRangeConflictChecker.java
@@ -19,6 +19,7 @@
package org.apache.paimon.operation.commit;
import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.utils.Range;
import org.apache.paimon.utils.RowRangeIndex;
import java.util.List;
@@ -29,17 +30,20 @@ public class RowIdRangeConflictChecker implements
RowIdConflictChecker {
private final RowRangeIndex rowRangeIndex;
- private RowIdRangeConflictChecker(List<DataFileMeta> deltaFiles) {
- this.rowRangeIndex =
- RowRangeIndex.create(
- deltaFiles.stream()
- .filter(file -> file.firstRowId() != null)
- .map(DataFileMeta::nonNullRowIdRange)
- .collect(Collectors.toList()));
+ private RowIdRangeConflictChecker(List<Range> ranges) {
+ this.rowRangeIndex = RowRangeIndex.create(ranges);
}
public static RowIdRangeConflictChecker fromDataFiles(List<DataFileMeta>
deltaFiles) {
- return new RowIdRangeConflictChecker(deltaFiles);
+ return fromRanges(
+ deltaFiles.stream()
+ .filter(file -> file.firstRowId() != null)
+ .map(DataFileMeta::nonNullRowIdRange)
+ .collect(Collectors.toList()));
+ }
+
+ public static RowIdRangeConflictChecker fromRanges(List<Range> ranges) {
+ return new RowIdRangeConflictChecker(ranges);
}
@Override
diff --git
a/paimon-core/src/main/java/org/apache/paimon/utils/DataEvolutionUtils.java
b/paimon-core/src/main/java/org/apache/paimon/utils/DataEvolutionUtils.java
index f8a2112eb6..d38bccc754 100644
--- a/paimon-core/src/main/java/org/apache/paimon/utils/DataEvolutionUtils.java
+++ b/paimon-core/src/main/java/org/apache/paimon/utils/DataEvolutionUtils.java
@@ -331,6 +331,72 @@ public class DataEvolutionUtils {
return anchor;
}
+ /** Returns normal-file row ranges removed without row-ID-preserving
replacement files. */
+ public static List<Range> materializedRowIdRanges(
+ List<DataFileMeta> before, List<DataFileMeta> after) {
+ List<Range> retained =
+ Range.sortAndMergeOverlap(
+ after.stream()
+ .filter(file -> file.firstRowId() != null)
+ .filter(
+ file ->
+ !isBlobFile(file.fileName())
+ &&
!isVectorStoreFile(file.fileName()))
+ .map(DataFileMeta::nonNullRowIdRange)
+ .collect(Collectors.toList()));
+ List<Range> removed =
+ Range.sortAndMergeOverlap(
+ before.stream()
+ .filter(file -> file.firstRowId() != null)
+ .filter(
+ file ->
+ !isBlobFile(file.fileName())
+ &&
!isVectorStoreFile(file.fileName()))
+ .map(DataFileMeta::nonNullRowIdRange)
+ .collect(Collectors.toList()));
+ List<Range> result = new ArrayList<>();
+ int retainedIndex = 0;
+ for (Range range : removed) {
+ long cursor = range.from;
+ boolean fullyRetained = false;
+ while (retainedIndex < retained.size()) {
+ Range output = retained.get(retainedIndex);
+ if (output.to < cursor) {
+ retainedIndex++;
+ continue;
+ }
+ if (output.from > range.to) {
+ break;
+ }
+ if (output.from > cursor) {
+ addMaterializedRange(result, cursor, output.from - 1);
+ }
+ if (output.to >= range.to) {
+ fullyRetained = true;
+ break;
+ }
+ // The preceding comparison guarantees this cannot overflow at
Long.MAX_VALUE.
+ cursor = output.to + 1;
+ retainedIndex++;
+ }
+ if (!fullyRetained) {
+ addMaterializedRange(result, cursor, range.to);
+ }
+ }
+ return result;
+ }
+
+ private static void addMaterializedRange(List<Range> ranges, long from,
long to) {
+ if (!ranges.isEmpty()) {
+ Range previous = ranges.get(ranges.size() - 1);
+ if (previous.to != Long.MAX_VALUE && previous.to + 1 == from) {
+ ranges.set(ranges.size() - 1, new Range(previous.from, to));
+ return;
+ }
+ }
+ ranges.add(new Range(from, to));
+ }
+
/** Check files row ranges. */
public static Range checkContiguousRowRange(List<DataFileMeta> files) {
checkArgument(!files.isEmpty(), "%s should not be empty.", "Data
evolution compact files");
diff --git
a/paimon-core/src/test/java/org/apache/paimon/append/cluster/DataEvolutionClusterCompactionTest.java
b/paimon-core/src/test/java/org/apache/paimon/append/cluster/DataEvolutionClusterCompactionTest.java
new file mode 100644
index 0000000000..5129266344
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/append/cluster/DataEvolutionClusterCompactionTest.java
@@ -0,0 +1,277 @@
+/*
+ * 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.CoreOptions;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.append.dataevolution.DataEvolutionCompactTask;
+import org.apache.paimon.append.dataevolution.DataEvolutionNormalCompactTask;
+import org.apache.paimon.compact.CompactUnit;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.partition.PartitionPredicate;
+import org.apache.paimon.schema.FileSystemSchemaManager;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.schema.SchemaChange;
+import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.FileStoreTableFactory;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.snapshot.SnapshotReader;
+import org.apache.paimon.types.DataTypes;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static org.apache.paimon.stats.SimpleStats.EMPTY_STATS;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/** Compaction fallback for accumulated data-evolution column versions. */
+class DataEvolutionClusterCompactionTest {
+
+ @TempDir java.nio.file.Path tempDir;
+
+ @Test
+ void testThresholdIncludesEveryVersionAndPreservesEachGroup() throws
Exception {
+ FileStoreTable table = createTable();
+ List<DataFileMeta> files = versions(table, 0, 5);
+ files.addAll(versions(table, 3, 4));
+ List<DataEvolutionCompactTask> tasks = plan(table, files,
Collections.emptyMap());
+ assertThat(tasks).hasSize(1);
+ assertThat(tasks.get(0).compactBefore())
+ .containsExactlyInAnyOrderElementsOf(files.subList(0, 5));
+ assertThat(((DataEvolutionNormalCompactTask)
tasks.get(0)).outputLevel()).isEqualTo(2);
+
+ files.add(file(table, "fifth-second-group", 3, 30, 0, "payload"));
+ tasks = plan(table, files, Collections.emptyMap());
+ assertThat(tasks).hasSize(2);
+ assertThat(tasks)
+ .allSatisfy(
+ task -> {
+ assertThat(task.compactBefore()).hasSize(5);
+ assertThat(task.compactBefore())
+
.extracting(DataFileMeta::nonNullRowIdRange)
+
.containsOnly(task.compactBefore().get(0).nonNullRowIdRange());
+ });
+ }
+
+ @Test
+ void testSingleFileIsNotRewrittenEvenWithLowThreshold() throws Exception {
+ FileStoreTable table =
+ createTable()
+ .copy(
+ Collections.singletonMap(
+
CoreOptions.COMPACTION_MIN_FILE_NUM.key(), "1"));
+ assertThat(plan(table, versions(table, 0, 1),
Collections.emptyMap())).isEmpty();
+ }
+
+ @Test
+ void testClusteringSelectedGroupIsNotCompactedAgain() throws Exception {
+ FileStoreTable table = createTable();
+ List<DataFileMeta> files = versions(table, 0, 5);
+ List<DataFileMeta> secondGroup = versions(table, 3, 5);
+ files.addAll(secondGroup);
+ Map<BinaryRow, CompactUnit> clustering =
+ Collections.singletonMap(
+ BinaryRow.EMPTY_ROW, CompactUnit.fromFiles(2,
files.subList(0, 5), true));
+ List<DataEvolutionCompactTask> tasks = plan(table, files, clustering);
+ assertThat(tasks).hasSize(1);
+
assertThat(tasks.get(0).compactBefore()).containsExactlyInAnyOrderElementsOf(secondGroup);
+ }
+
+ @Test
+ void testKeyUpdatesAndChangedDefinitionsDoNotInheritLevel() throws
Exception {
+ FileStoreTable table = createTable();
+ List<DataFileMeta> keyUpdated = versions(table, 0, 5);
+ keyUpdated.add(file(table, "key-update", 0, 30, 0, "id"));
+ assertUnsortedCompaction(plan(table, keyUpdated,
Collections.emptyMap()), keyUpdated);
+
+ List<DataFileMeta> versions = versions(table, 0, 5);
+ for (String[] definition : new String[][] {{"payload", "order"},
{"id", "zorder"}}) {
+ FileStoreTable changed =
+ table.copy(
+ table.schemaManager()
+ .commitChanges(
+ Arrays.asList(
+ SchemaChange.setOption(
+
CoreOptions.CLUSTERING_COLUMNS.key(),
+ definition[0]),
+ SchemaChange.setOption(
+
CoreOptions.CLUSTERING_STRATEGY.key(),
+ definition[1]))));
+ assertUnsortedCompaction(plan(changed, versions,
Collections.emptyMap()), versions);
+ }
+ FileStoreTable newKey =
+ table.copy(
+ table.schemaManager()
+ .commitChanges(
+ Arrays.asList(
+
SchemaChange.addColumn("new_key", DataTypes.INT()),
+ SchemaChange.setOption(
+
CoreOptions.CLUSTERING_COLUMNS.key(),
+ "new_key"))));
+ assertUnsortedCompaction(plan(newKey, versions,
Collections.emptyMap()), versions);
+ }
+
+ @Test
+ void testUnsortedOrIncompleteAnchorDoesNotInheritLevel() throws Exception {
+ FileStoreTable table = createTable();
+ List<DataFileMeta> unsorted = versions(table, 0, 5);
+ unsorted.set(0, file(table, "unsorted-anchor", 0, 10, 0, null));
+ assertUnsortedCompaction(plan(table, unsorted,
Collections.emptyMap()), unsorted);
+
+ List<DataFileMeta> incomplete = versions(table, 0, 5);
+ incomplete.set(0, file(table, "partial-anchor", 0, 10, 2, "payload"));
+ assertUnsortedCompaction(plan(table, incomplete,
Collections.emptyMap()), incomplete);
+ }
+
+ @Test
+ void testSingleDirtyRunSkippedByMinorStillCompactsColumnVersions() throws
Exception {
+ FileStoreTable table = createTable();
+ List<DataFileMeta> files = versions(table, 0, 5);
+ files.set(4, file(table, "key-update", 0, 30, 0, "id"));
+ IncrementalClusterStrategy strategy =
+ new IncrementalClusterStrategy(
+ table.schemaManager(),
table.coreOptions().clusteringColumns(), 25, 1, 2);
+ assertThat(
+ strategy.pick(
+ 3,
+ new
DataEvolutionIncrementalClusterRewriter(table, null)
+ .constructPartitionLevels(files),
+ false))
+ .isEmpty();
+
+ assertUnsortedCompaction(plan(table, files, Collections.emptyMap()),
files);
+ }
+
+ private static void assertUnsortedCompaction(
+ List<DataEvolutionCompactTask> tasks, List<DataFileMeta> input) {
+ assertThat(tasks).hasSize(1);
+
assertThat(tasks.get(0).compactBefore()).containsExactlyInAnyOrderElementsOf(input);
+ assertThat(((DataEvolutionNormalCompactTask)
tasks.get(0)).outputLevel()).isZero();
+ }
+
+ private List<DataEvolutionCompactTask> plan(
+ FileStoreTable table,
+ List<DataFileMeta> files,
+ Map<BinaryRow, CompactUnit> clustering) {
+ FileStoreTable input = spy(table);
+ Snapshot snapshot = mock(Snapshot.class);
+ PartitionPredicate predicate = mock(PartitionPredicate.class);
+ SnapshotReader reader = mock(SnapshotReader.class);
+ SnapshotReader.Plan plan = mock(SnapshotReader.Plan.class);
+ doReturn(reader).when(input).newSnapshotReader();
+ when(reader.dropStats()).thenReturn(reader);
+ when(reader.withSnapshot(snapshot)).thenReturn(reader);
+ when(reader.withPartitionFilter(predicate)).thenReturn(reader);
+ when(reader.read()).thenReturn(plan);
+ // Scan splits may cut between physical column versions. Planning must
regroup them.
+ List<DataSplit> splits = new ArrayList<>();
+ for (DataFileMeta file : files) {
+ splits.add(
+ DataSplit.builder()
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(Collections.singletonList(file))
+ .build());
+ }
+ when(plan.dataSplits()).thenReturn(splits);
+ List<DataEvolutionCompactTask> result =
+ DataEvolutionClusterCompaction.plan(input, snapshot,
predicate, clustering);
+ verify(reader).withSnapshot(snapshot);
+ verify(reader).withPartitionFilter(predicate);
+ return result;
+ }
+
+ private FileStoreTable createTable() throws Exception {
+ Schema schema =
+ Schema.newBuilder()
+ .column("id", DataTypes.INT())
+ .column("payload", DataTypes.STRING())
+ .option(CoreOptions.BUCKET.key(), "-1")
+ .option(CoreOptions.ROW_TRACKING_ENABLED.key(), "true")
+ .option(CoreOptions.DATA_EVOLUTION_ENABLED.key(),
"true")
+ .option(CoreOptions.CLUSTERING_INCREMENTAL.key(),
"true")
+ .option(CoreOptions.CLUSTERING_COLUMNS.key(), "id")
+ .option(CoreOptions.NUM_LEVELS.key(), "3")
+ .option(CoreOptions.COMPACTION_MIN_FILE_NUM.key(), "5")
+ .build();
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path path = new Path(tempDir.toString());
+ SchemaManager schemaManager = new FileSystemSchemaManager(fileIO,
path);
+ return FileStoreTableFactory.create(fileIO, path,
schemaManager.createTable(schema));
+ }
+
+ private static List<DataFileMeta> versions(FileStoreTable table, long
firstRowId, int count) {
+ List<DataFileMeta> files = new ArrayList<>();
+ files.add(file(table, "base-" + firstRowId, firstRowId, 10, 2, null));
+ for (int i = 1; i < count; i++) {
+ files.add(
+ file(
+ table,
+ "update-" + firstRowId + "-" + i,
+ firstRowId,
+ 10 + i,
+ 0,
+ "payload"));
+ }
+ return files;
+ }
+
+ private static DataFileMeta file(
+ FileStoreTable table,
+ String name,
+ long firstRowId,
+ long sequence,
+ int level,
+ String writeColumn) {
+ DataFileMeta file =
+ DataFileMeta.forAppend(
+ name + ".parquet",
+ 100,
+ 3,
+ EMPTY_STATS,
+ sequence,
+ sequence,
+ table.schema().id(),
+ Collections.emptyList(),
+ null,
+ FileSource.APPEND,
+ null,
+ null,
+ firstRowId,
+ writeColumn == null ? null :
Collections.singletonList(writeColumn));
+ return level == 0 ? file : file.upgrade(level);
+ }
+}
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
index 1a0d1da601..adb355dfc9 100644
---
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
@@ -1179,6 +1179,420 @@ class DataEvolutionClusterTest extends TableTestBase {
.containsExactly("a|10|base-10|3", "a|20|base-20|4",
"a|30|base-30|5");
}
+ @ParameterizedTest
+ @CsvSource({"btree,false", "btree,true", "bitmap,false", "bitmap,true"})
+ void testMixedClusteringAndCompactionPreservesUnchangedIndexSegments(
+ String indexType, boolean deletionVectors) throws Exception {
+ FileStoreTable table = createMixedMaintenanceTable(deletionVectors);
+ commit(table, prepareMixedIndexes(table, table.latestSnapshot().get(),
"id", indexType));
+ List<String> retainedIndexes = globalIndexNames(table, new Range(3,
5));
+ List<String> remappedIndexes = globalIndexNames(table, new Range(6,
8));
+ assertThat(retainedIndexes).isNotEmpty();
+ assertThat(remappedIndexes).isNotEmpty();
+ if (deletionVectors) {
+ delete(table, "a", new Range(3, 5), 4L);
+ delete(table, "a", new Range(6, 8), 7L);
+ }
+ Snapshot snapshot = table.latestSnapshot().get();
+ List<CommitMessage> messages = prepareMixedMaintenance(table,
snapshot);
+ commitCluster(table, snapshot, messages);
+
+ assertThat(globalIndexNames(table))
+ .containsAll(retainedIndexes)
+ .doesNotContainAnyElementsOf(remappedIndexes);
+ assertThat(globalIndexNames(table, new Range(3,
5))).isEqualTo(retainedIndexes);
+ assertThat(filesInRange(table, new Range(3, 5))).hasSize(1);
+ assertThat(readRows(table))
+ .containsExactlyElementsOf(
+ deletionVectors
+ ? Arrays.asList(
+ "a|40|updated-40|3",
+ "a|60|updated-60|5",
+ "a|20|base-20|9",
+ "a|30|base-30|10")
+ : Arrays.asList(
+ "a|40|updated-40|3",
+ "a|50|updated-50|4",
+ "a|60|updated-60|5",
+ "a|10|base-10|9",
+ "a|20|base-20|10",
+ "a|30|base-30|11"));
+ int id = table.rowType().getFieldIndex("id");
+ PredicateBuilder predicates = new PredicateBuilder(table.rowType());
+ assertIndexRows(table, predicates.equal(id, 40), new Range(3, 3));
+ assertIndexRows(
+ table,
+ predicates.equal(id, 20),
+ new Range(deletionVectors ? 9 : 10, deletionVectors ? 9 : 10));
+
assertThat(table.latestSnapshot().get().nextRowId()).isEqualTo(deletionVectors
? 11L : 12L);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"btree", "bitmap"})
+ void
testFullyDeletedClusteringAndCompactionRetainsOtherIndexSegments(String
indexType)
+ throws Exception {
+ FileStoreTable table = createMixedMaintenanceTable(true);
+ commit(table, prepareMixedIndexes(table, table.latestSnapshot().get(),
"id", indexType));
+ List<String> retainedIndexes = globalIndexNames(table, new Range(3,
5));
+ delete(table, "a", new Range(3, 5), 4L);
+ delete(table, "a", new Range(6, 8), 6L, 7L, 8L);
+ Snapshot snapshot = table.latestSnapshot().get();
+ commitCluster(table, snapshot, prepareMixedMaintenance(table,
snapshot));
+
+ assertThat(globalIndexNames(table)).isEqualTo(retainedIndexes);
+ assertThat(readRows(table)).containsExactly("a|40|updated-40|3",
"a|60|updated-60|5");
+ assertThat(filesInRange(table, new Range(3, 5))).hasSize(1);
+ assertThat(table.latestSnapshot().get().nextRowId()).isEqualTo(9L);
+ PredicateBuilder predicates = new PredicateBuilder(table.rowType());
+ assertIndexRows(
+ table, predicates.equal(table.rowType().getFieldIndex("id"),
40), new Range(3, 3));
+ assertIndexRows(table,
predicates.equal(table.rowType().getFieldIndex("id"), 20));
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testNormalCompactionDoesNotInvalidateIndexes(boolean deletionVectors)
throws Exception {
+ FileStoreTable table = createMixedMaintenanceTable(deletionVectors);
+ if (deletionVectors) {
+ delete(table, "a", new Range(3, 5), 4L);
+ }
+ commit(table, prepareGlobalIndex(table, table.latestSnapshot().get(),
"id"));
+ List<String> indexes = globalIndexNames(table);
+ Snapshot snapshot = table.latestSnapshot().get();
+ List<String> rows = readRows(table);
+ commitCluster(
+ table,
+ snapshot,
+ Collections.singletonList(prepareNormalCompaction(table, new
Range(3, 5))));
+
+ assertThat(globalIndexNames(table)).isEqualTo(indexes);
+ assertThat(readRows(table)).isEqualTo(rows);
+ assertThat(filesInRange(table, new Range(3, 5))).hasSize(1);
+ assertThat(table.latestSnapshot().get().nextRowId()).isEqualTo(9L);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"btree", "bitmap"})
+ void
testMixedMaintenanceSplitsIndexSpanningRetainedAndReassignedRows(String
indexType)
+ throws Exception {
+ FileStoreTable table = createMixedMaintenanceTable(false);
+ commit(
+ table,
+ prepareGlobalIndex(table, table.latestSnapshot().get(), "id",
indexType, null));
+ assertThat(globalIndexNames(table, new Range(3, 8))).isNotEmpty();
+ Snapshot snapshot = table.latestSnapshot().get();
+ commitCluster(table, snapshot, prepareMixedMaintenance(table,
snapshot));
+
+ assertThat(
+
table.store().newIndexFileHandler().scanEntries().stream()
+ .filter(entry ->
entry.indexFile().globalIndexMeta() != null)
+ .map(entry ->
entry.indexFile().globalIndexMeta().rowRange()))
+ .containsExactlyInAnyOrder(new Range(3, 5), new Range(9, 11));
+ PredicateBuilder predicates = new PredicateBuilder(table.rowType());
+ int id = table.rowType().getFieldIndex("id");
+ assertIndexRows(table, predicates.equal(id, 40), new Range(3, 3));
+ assertIndexRows(table, predicates.equal(id, 20), new Range(10, 10));
+ assertIndexRows(table, predicates.notEqual(id, 40), new Range(4, 5),
new Range(9, 11));
+ }
+
+ @ParameterizedTest
+ @CsvSource({"btree,false", "btree,true", "bitmap,false", "bitmap,true"})
+ void testMixedMaintenanceRetryRemapsConcurrentIndexAfterAppend(
+ String indexType, 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 =
createMixedMaintenanceTable(deletionVectors).copy(retryOptions);
+ if (deletionVectors) {
+ delete(original, "a", new Range(3, 5), 4L);
+ delete(original, "a", new Range(6, 8), 7L);
+ }
+ commit(
+ original,
+ prepareMixedIndexes(original, original.latestSnapshot().get(),
"id", indexType));
+ Snapshot scanSnapshot = original.latestSnapshot().get();
+ List<CommitMessage> messages = prepareMixedMaintenance(original,
scanSnapshot);
+ List<String> retainedIndexes = new
ArrayList<>(globalIndexNames(original, new Range(3, 5)));
+ 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 {
+ int attempt = attempts.incrementAndGet();
+ if (attempt == 1) {
+ DataEvolutionClusterTest.this.commit(
+ original,
+ prepareMixedIndexes(
+ original, scanSnapshot,
"payload", indexType));
+ retainedIndexes.clear();
+ retainedIndexes.addAll(
+ globalIndexNames(original, new
Range(3, 5)));
+ append(original, "a", 90, 70, 80);
+ }
+ 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() +
3);
+ assertThat(globalIndexNames(original, new Range(3,
5))).isEqualTo(retainedIndexes);
+ assertThat(original.latestSnapshot().get().nextRowId())
+ .isEqualTo(deletionVectors ? 14L : 15L);
+ PredicateBuilder predicates = new PredicateBuilder(original.rowType());
+ int id = original.rowType().getFieldIndex("id");
+ assertIndexRows(original, predicates.equal(id, 40), new Range(3, 3));
+ assertIndexRows(
+ original,
+ predicates.equal(id, 20),
+ new Range(deletionVectors ? 12 : 13, deletionVectors ? 12 :
13));
+ assertIndexRows(
+ original,
+ predicates.equal(
+ original.rowType().getFieldIndex("payload"),
+ BinaryString.fromString("updated-40")),
+ new Range(3, 3));
+ assertIndexRows(
+ original,
+ predicates.equal(
+ original.rowType().getFieldIndex("payload"),
+ BinaryString.fromString("base-20")),
+ new Range(deletionVectors ? 12 : 13, deletionVectors ? 12 :
13));
+ assertThat(readRows(original))
+ .contains("a|90|base-90|9", "a|70|base-70|10",
"a|80|base-80|11");
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void
testMixedMaintenanceAllowsConcurrentPartialUpdateOfRetainedRows(boolean
deletionVectors)
+ throws Exception {
+ FileStoreTable table = createMixedMaintenanceTable(deletionVectors);
+ commit(table, prepareGlobalIndex(table, table.latestSnapshot().get(),
"id"));
+ Snapshot snapshot = table.latestSnapshot().get();
+ List<CommitMessage> messages = prepareMixedMaintenance(table,
snapshot);
+ updatePayload(table, "a", 3L, 400, 500, 600);
+ commitCluster(table, snapshot, messages);
+
+ assertThat(readRows(table))
+ .containsExactly(
+ "a|40|updated-400|3",
+ "a|50|updated-500|4",
+ "a|60|updated-600|5",
+ "a|10|base-10|9",
+ "a|20|base-20|10",
+ "a|30|base-30|11");
+ assertThat(filesInRange(table, new Range(3, 5))).hasSize(2);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void
testMixedMaintenanceRejectsConcurrentPartialUpdateOfReassignedRows(boolean
deletionVectors)
+ throws Exception {
+ FileStoreTable table = createMixedMaintenanceTable(deletionVectors);
+ Snapshot snapshot = table.latestSnapshot().get();
+ List<CommitMessage> messages = prepareMixedMaintenance(table,
snapshot);
+ updatePayload(table, "a", 6L, 300, 100, 200);
+ long updatedSnapshot = 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(updatedSnapshot);
+ assertThat(readRows(table))
+ .contains("a|30|updated-300|6", "a|10|updated-100|7",
"a|20|updated-200|8");
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testMixedMaintenanceAllowsOldIndexOnlyForRetainedRows(boolean
deletionVectors)
+ throws Exception {
+ FileStoreTable table = createMixedMaintenanceTable(deletionVectors);
+ Snapshot snapshot = table.latestSnapshot().get();
+ List<CommitMessage> retainedIndex =
+ prepareGlobalIndex(table, snapshot, "id", "bitmap", new
Range(3, 5));
+ List<CommitMessage> reassignedIndex =
+ prepareGlobalIndex(table, snapshot, "id", "bitmap", new
Range(6, 8));
+ commitCluster(table, snapshot, prepareMixedMaintenance(table,
snapshot));
+ commit(table, retainedIndex);
+ List<String> indexes = globalIndexNames(table);
+ long committedSnapshot = table.latestSnapshot().get().id();
+ assertThatThrownBy(() -> commit(table, reassignedIndex))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("Global index row ID existence
conflict");
+
assertThat(table.latestSnapshot().get().id()).isEqualTo(committedSnapshot);
+ assertThat(globalIndexNames(table)).isEqualTo(indexes);
+ assertIndexRows(
+ table,
+ new PredicateBuilder(table.rowType())
+ .equal(table.rowType().getFieldIndex("id"), 40),
+ new Range(3, 3));
+ }
+
+ @ParameterizedTest
+ @ValueSource(longs = {3L, 6L})
+ void testMixedMaintenanceRejectsConcurrentDeletionVectorChange(long
firstRowId)
+ throws Exception {
+ FileStoreTable table = createMixedMaintenanceTable(true);
+ Snapshot snapshot = table.latestSnapshot().get();
+ List<CommitMessage> messages = prepareMixedMaintenance(table,
snapshot);
+ delete(table, "a", new Range(firstRowId, firstRowId + 2), firstRowId +
1);
+ List<String> deletedRows = readRows(table);
+ long deletedSnapshot = table.latestSnapshot().get().id();
+ assertThatThrownBy(() -> commitCluster(table, snapshot, messages))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("File deletion conflicts detected!");
+
assertThat(table.latestSnapshot().get().id()).isEqualTo(deletedSnapshot);
+ assertThat(readRows(table)).isEqualTo(deletedRows);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testMixedMaintenanceAllowsPreviouslyPreparedUpdateOnlyForRetainedRows(
+ boolean deletionVectors) throws Exception {
+ FileStoreTable table = createMixedMaintenanceTable(deletionVectors);
+ Snapshot snapshot = table.latestSnapshot().get();
+ List<CommitMessage> retainedUpdate =
+ preparePayloadUpdate(table, "a", 3L, 400, 500, 600).stream()
+ .map(
+ message ->
+ ((CommitMessageImpl) message)
+
.withCheckFromSnapshot(snapshot.id()))
+ .collect(Collectors.toList());
+ List<CommitMessage> reassignedUpdate =
+ preparePayloadUpdate(table, "a", 6L, 300, 100, 200).stream()
+ .map(
+ message ->
+ ((CommitMessageImpl) message)
+
.withCheckFromSnapshot(snapshot.id()))
+ .collect(Collectors.toList());
+ commitCluster(table, snapshot, prepareMixedMaintenance(table,
snapshot));
+ commit(table, retainedUpdate);
+ long committedSnapshot = table.latestSnapshot().get().id();
+ assertThatThrownBy(() -> commit(table, reassignedUpdate))
+ .isInstanceOf(RowIdExistenceConflictException.class)
+ .hasMessageContaining("Row ID existence conflict");
+
assertThat(table.latestSnapshot().get().id()).isEqualTo(committedSnapshot);
+ assertThat(readRows(table))
+ .contains("a|40|updated-400|3", "a|50|updated-500|4",
"a|60|updated-600|5");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"btree", "bitmap"})
+ void
testMixedMaintenanceWithoutMappingOnlyInvalidatesReassignedIndexRange(String
indexType)
+ throws Exception {
+ FileStoreTable table = createMixedMaintenanceTable(false);
+ commit(table, prepareMixedIndexes(table, table.latestSnapshot().get(),
"id", indexType));
+ List<String> retainedIndexes = globalIndexNames(table, new Range(3,
5));
+ Snapshot snapshot = table.latestSnapshot().get();
+ commitCluster(table, snapshot, prepareMixedMaintenance(table,
snapshot, false));
+
+ assertThat(globalIndexNames(table)).isEqualTo(retainedIndexes);
+ assertThat(readRows(table))
+ .containsExactly(
+ "a|40|updated-40|3",
+ "a|50|updated-50|4",
+ "a|60|updated-60|5",
+ "a|10|base-10|9",
+ "a|20|base-20|10",
+ "a|30|base-30|11");
+ assertIndexRows(
+ table,
+ new PredicateBuilder(table.rowType())
+ .equal(table.rowType().getFieldIndex("id"), 40),
+ new Range(3, 3));
+ }
+
+ private FileStoreTable createMixedMaintenanceTable(boolean
deletionVectors) throws Exception {
+ FileStoreTable table = createTable(deletionVectors);
+ append(table, "a", 60, 40, 50);
+ Snapshot initial = table.latestSnapshot().get();
+ commitCluster(table, initial, prepareMappedCluster(table, initial,
true));
+ for (int i = 0; i < 4; i++) {
+ updatePayload(table, "a", 3L, 40, 50, 60);
+ }
+ append(table, "a", 30, 10, 20);
+ assertThat(filesInRange(table, new Range(3, 5))).hasSize(5);
+ return table;
+ }
+
+ private List<CommitMessage> prepareMixedMaintenance(FileStoreTable table,
Snapshot snapshot)
+ throws Exception {
+ return prepareMixedMaintenance(table, snapshot, true);
+ }
+
+ private List<CommitMessage> prepareMixedMaintenance(
+ FileStoreTable table, Snapshot snapshot, boolean preserveIndexes)
throws Exception {
+ List<DataFileMeta> selected =
+
table.store().newScan().withSnapshot(snapshot).plan().files().stream()
+ .map(entry -> entry.file())
+ .filter(file -> file.nonNullRowIdRange().equals(new
Range(6, 8)))
+ .collect(Collectors.toList());
+ assertThat(selected).isNotEmpty();
+ IncrementalClusterManager manager = new
IncrementalClusterManager(table, null, snapshot);
+ List<CommitMessage> messages =
+ rewriteClusterUnits(
+ table,
+ manager,
+ Collections.singletonMap(
+ partition(table, "a"),
CompactUnit.fromFiles(3, selected, true)),
+ preserveIndexes);
+ messages.add(prepareNormalCompaction(table, new Range(3, 5)));
+ return messages;
+ }
+
+ private CommitMessage prepareNormalCompaction(FileStoreTable table, Range
range)
+ throws Exception {
+ List<DataFileMeta> files =
+
table.store().newScan().withSnapshot(table.latestSnapshot().get()).plan().files()
+ .stream()
+ .map(entry -> entry.file())
+ .filter(file -> file.nonNullRowIdRange().equals(range))
+ .collect(Collectors.toList());
+ return new DataEvolutionNormalCompactTask(partition(table, "a"), files)
+ .doCompact(table, "test-normal-compact");
+ }
+
+ private List<String> globalIndexNames(FileStoreTable table, Range range) {
+ return table.store().newIndexFileHandler().scanEntries().stream()
+ .filter(entry -> entry.indexFile().globalIndexMeta() != null)
+ .filter(entry ->
entry.indexFile().globalIndexMeta().rowRange().equals(range))
+ .map(entry -> entry.indexFile().fileName())
+ .sorted()
+ .collect(Collectors.toList());
+ }
+
+ private List<CommitMessage> prepareMixedIndexes(
+ FileStoreTable table, Snapshot snapshot, String column, String
indexType)
+ throws Exception {
+ List<CommitMessage> indexes =
+ new ArrayList<>(
+ prepareGlobalIndex(table, snapshot, column, indexType,
new Range(3, 5)));
+ indexes.addAll(prepareGlobalIndex(table, snapshot, column, indexType,
new Range(6, 8)));
+ return indexes;
+ }
+
@ParameterizedTest
@ValueSource(booleans = {false, true})
void testClusterRetryDropsIndexCommittedBeforeSnapshotPublication(boolean
deletionVectors)
diff --git
a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionCompactTaskSerializerTest.java
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionCompactTaskSerializerTest.java
new file mode 100644
index 0000000000..98f273969f
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionCompactTaskSerializerTest.java
@@ -0,0 +1,100 @@
+/*
+ * 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.data.BinaryRow;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataFileMetaSerializer;
+import org.apache.paimon.io.DataInputDeserializer;
+import org.apache.paimon.io.DataOutputSerializer;
+import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.utils.Range;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.apache.paimon.stats.SimpleStats.EMPTY_STATS;
+import static org.apache.paimon.utils.SerializationUtils.serializeBinaryRow;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Task serialization keeps clustering provenance without changing ordinary
compaction defaults. */
+class DataEvolutionCompactTaskSerializerTest {
+
+ @Test
+ void testOutputLevelsSurviveMixedTaskList() throws Exception {
+ DataEvolutionCompactTaskSerializer serializer = new
DataEvolutionCompactTaskSerializer();
+ List<DataEvolutionCompactTask> tasks =
+ Arrays.asList(
+ new DataEvolutionNormalCompactTask(
+ BinaryRow.EMPTY_ROW,
+ files(),
+ Collections.singletonList(new Range(0, 2)),
+ 2),
+ new
DataEvolutionNormalCompactTask(BinaryRow.EMPTY_ROW, files()),
+ new DataEvolutionBlobCompactTask(BinaryRow.EMPTY_ROW,
files()));
+ DataOutputSerializer out = new DataOutputSerializer(128);
+ serializer.serializeList(tasks, out);
+ List<DataEvolutionCompactTask> restored =
+ serializer.deserializeList(
+ serializer.getVersion(), new
DataInputDeserializer(out.getCopyOfBuffer()));
+ assertThat(restored).containsExactlyElementsOf(tasks);
+ assertThat(((DataEvolutionNormalCompactTask)
restored.get(0)).outputLevel()).isEqualTo(2);
+ assertThat(((DataEvolutionNormalCompactTask)
restored.get(1)).outputLevel()).isZero();
+ }
+
+ @Test
+ void testVersionFourDefaultsToUnsortedOutput() throws Exception {
+ DataEvolutionCompactTaskSerializer serializer = new
DataEvolutionCompactTaskSerializer();
+ DataOutputSerializer out = new DataOutputSerializer(128);
+ List<DataFileMeta> files = files();
+ serializeBinaryRow(BinaryRow.EMPTY_ROW, out);
+ new DataFileMetaSerializer().serializeList(files, out);
+ out.writeInt(DataEvolutionCompactTask.TaskType.NORMAL.code());
+ out.writeInt(1);
+ out.writeLong(0);
+ out.writeLong(2);
+ DataEvolutionNormalCompactTask restored =
+ (DataEvolutionNormalCompactTask) serializer.deserialize(4,
out.getCopyOfBuffer());
+ assertThat(restored.outputLevel()).isZero();
+ assertThat(restored.protectedRanges()).containsExactly(new Range(0,
2));
+ assertThat(restored.compactBefore()).containsExactlyElementsOf(files);
+ }
+
+ private static List<DataFileMeta> files() {
+ return Collections.singletonList(
+ DataFileMeta.forAppend(
+ "normal.parquet",
+ 100,
+ 3,
+ EMPTY_STATS,
+ 10,
+ 10,
+ 0,
+ Collections.emptyList(),
+ null,
+ FileSource.APPEND,
+ null,
+ null,
+ 0L,
+ null));
+ }
+}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionNormalCompactTaskTest.java
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionNormalCompactTaskTest.java
index 2464cbf892..22395b9d33 100644
---
a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionNormalCompactTaskTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionNormalCompactTaskTest.java
@@ -81,6 +81,57 @@ public class DataEvolutionNormalCompactTaskTest extends
TableTestBase {
.build();
}
+ @ParameterizedTest
+ @ValueSource(ints = {0, 2})
+ public void testExplicitOutputLevelPreservesRowsAndRowIds(int outputLevel)
throws Exception {
+ write();
+ FileStoreTable table = getTableDefault();
+ List<DataFileMeta> input =
+ table.store().newScan().plan().files().stream()
+ .map(ManifestEntry::file)
+ .collect(Collectors.toList());
+ if (outputLevel == 0) {
+ // Ordinary compaction must still reset an already clustered input
to level zero.
+ DataEvolutionNormalCompactTask seed =
+ new DataEvolutionNormalCompactTask(
+
table.store().newScan().plan().files().get(0).partition(),
+ input,
+ Collections.emptyList(),
+ 2);
+ try (BatchTableCommit commit =
table.newBatchWriteBuilder().newCommit()) {
+ commit.commit(Collections.singletonList(seed.doCompact(table,
"seed-level")));
+ }
+ input = seed.compactAfter();
+ }
+ DataEvolutionNormalCompactTask task =
+ outputLevel == 0
+ ? new DataEvolutionNormalCompactTask(
+
table.store().newScan().plan().files().get(0).partition(), input)
+ : new DataEvolutionNormalCompactTask(
+
table.store().newScan().plan().files().get(0).partition(),
+ input,
+ Collections.emptyList(),
+ outputLevel);
+ DataEvolutionCompactTaskSerializer serializer = new
DataEvolutionCompactTaskSerializer();
+ task =
+ (DataEvolutionNormalCompactTask)
+ serializer.deserialize(serializer.getVersion(),
serializer.serialize(task));
+ CommitMessage message = task.doCompact(table,
"preserve-clustering-level");
+ assertThat(task.compactAfter()).hasSize(1);
+ assertThat(task.compactAfter().get(0).level()).isEqualTo(outputLevel);
+ assertThat(task.compactAfter().get(0).nonNullRowIdRange())
+ .isEqualTo(input.get(0).nonNullRowIdRange());
+ try (BatchTableCommit commit =
table.newBatchWriteBuilder().newCommit()) {
+ commit.commit(Collections.singletonList(message));
+ }
+ List<InternalRow> rows = read(table);
+ assertThat(rows).hasSize(ROW_COUNT);
+ for (int i = 0; i < ROW_COUNT; i++) {
+ assertThat(rows.get(i).getInt(1)).isEqualTo(i);
+ assertThat(rows.get(i).getString(2).toString()).isEqualTo("f1_" +
i);
+ }
+ }
+
@Test
public void testPropagateWriteColsSequencesAcrossCompactions() throws
Exception {
write();
@@ -130,6 +181,22 @@ public class DataEvolutionNormalCompactTaskTest extends
TableTestBase {
.isLessThan(partialUpdate.maxSequenceNumber());
}
+ @Test
+ public void testLevelZeroClusteringCompactionPreservesFieldSequences()
throws Exception {
+ write();
+ DataFileMeta compacted =
+ updateColumnsAndCompact(
+ Collections.singletonList("f1"),
+ 1,
+ CoreOptions.GlobalIndexColumnUpdateAction.THROW_ERROR,
+ true);
+ assertThat(compacted.level()).isZero();
+ assertThat(writeColSequence(compacted,
getTableDefault().rowType().getField("f0").id()))
+ .isLessThan(compacted.maxSequenceNumber());
+ assertThat(writeColSequence(compacted,
getTableDefault().rowType().getField("f1").id()))
+ .isEqualTo(compacted.maxSequenceNumber());
+ }
+
@Test
public void testOmitWriteColsSequencesUnlessUpdatesAreIgnored() throws
Exception {
write();
@@ -740,9 +807,22 @@ public class DataEvolutionNormalCompactTaskTest extends
TableTestBase {
int updateRound,
CoreOptions.GlobalIndexColumnUpdateAction updateAction)
throws Exception {
+ return updateColumnsAndCompact(columns, updateRound, updateAction,
false);
+ }
+
+ private DataFileMeta updateColumnsAndCompact(
+ List<String> columns,
+ int updateRound,
+ CoreOptions.GlobalIndexColumnUpdateAction updateAction,
+ boolean incrementalClustering)
+ throws Exception {
Map<String, String> writeOptions = new HashMap<>();
writeOptions.put(
CoreOptions.GLOBAL_INDEX_COLUMN_UPDATE_ACTION.key(),
updateAction.toString());
+ if (incrementalClustering) {
+ writeOptions.put(CoreOptions.CLUSTERING_INCREMENTAL.key(), "true");
+ writeOptions.put(CoreOptions.CLUSTERING_COLUMNS.key(), "f0");
+ }
FileStoreTable table = getTableDefault().copy(writeOptions);
List<String> writeColumns = new ArrayList<>();
writeColumns.add("dt");
diff --git
a/paimon-core/src/test/java/org/apache/paimon/utils/DataEvolutionUtilsTest.java
b/paimon-core/src/test/java/org/apache/paimon/utils/DataEvolutionUtilsTest.java
index 028c12ee44..066bace5a6 100644
---
a/paimon-core/src/test/java/org/apache/paimon/utils/DataEvolutionUtilsTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/utils/DataEvolutionUtilsTest.java
@@ -426,6 +426,106 @@ public class DataEvolutionUtilsTest {
.isSameAs(smallerFileName);
}
+ @Test
+ public void testMaterializedRowIdRangesExcludePreservedCompactionOutputs()
{
+ List<DataFileMeta> before =
+ Arrays.asList(
+ rangedDataFile("first.parquet", 0L, 5L),
+ rangedDataFile("first-update.parquet", 0L, 5L),
+ rangedDataFile("second.parquet", 8L, 3L));
+ List<DataFileMeta> after =
+ Arrays.asList(
+ rangedDataFile("retained-first.parquet", 1L, 2L),
+ rangedDataFile("retained-second.parquet", 3L, 1L),
+ rangedDataFile("fresh.parquet", null, 4L),
+ rangedDataFile("blob-output.blob", 0L, 11L),
+ rangedDataFile("vector-output.vector.lance", 0L, 11L));
+
+ assertThat(DataEvolutionUtils.materializedRowIdRanges(before, after))
+ .containsExactly(new Range(0, 0), new Range(4, 4), new
Range(8, 10));
+ assertThat(DataEvolutionUtils.materializedRowIdRanges(before,
before)).isEmpty();
+ assertThat(DataEvolutionUtils.materializedRowIdRanges(before,
Collections.emptyList()))
+ .containsExactly(new Range(0, 4), new Range(8, 10));
+ assertThat(
+ DataEvolutionUtils.materializedRowIdRanges(
+ Arrays.asList(
+ rangedDataFile("input.blob", 0L, 5L),
+ rangedDataFile("input.vector.lance",
0L, 5L)),
+ Collections.emptyList()))
+ .isEmpty();
+ }
+
+ @Test
+ public void testMaterializedRowIdRangesWalkInterleavedGroupsAndOutputs() {
+ List<DataFileMeta> before =
+ Arrays.asList(
+ rangedDataFile("last.parquet", 20L, 5L),
+ rangedDataFile("first.parquet", 0L, 5L),
+ rangedDataFile("middle.parquet", 10L, 5L),
+ rangedDataFile("adjacent.parquet", 5L, 2L));
+ List<DataFileMeta> after =
+ Arrays.asList(
+ rangedDataFile("middle-cut.parquet", 12L, 1L),
+ rangedDataFile("spanning-gaps.parquet", 3L, 9L),
+ rangedDataFile("outside.parquet", 30L, 3L),
+ rangedDataFile("prefix.parquet", 0L, 1L),
+ rangedDataFile("suffix.parquet", 24L, 3L));
+
+ assertThat(DataEvolutionUtils.materializedRowIdRanges(before, after))
+ .containsExactly(new Range(1, 2), new Range(13, 14), new
Range(20, 23));
+ assertThat(DataEvolutionUtils.materializedRowIdRanges(before,
Collections.emptyList()))
+ .containsExactly(new Range(0, 6), new Range(10, 14), new
Range(20, 24));
+ assertThat(
+ DataEvolutionUtils.materializedRowIdRanges(
+ before,
+
Collections.singletonList(rangedDataFile("all.parquet", 0L, 100L))))
+ .isEmpty();
+
assertThat(DataEvolutionUtils.materializedRowIdRanges(Collections.emptyList(),
after))
+ .isEmpty();
+ }
+
+ @Test
+ public void testMaterializedRowIdRangesHandleMaximumRowIdWithoutOverflow()
{
+ List<DataFileMeta> before =
+ Arrays.asList(
+ rangedDataFile("last.parquet", Long.MAX_VALUE - 2, 3L),
+ rangedDataFile("last-update.parquet", Long.MAX_VALUE -
2, 3L),
+ rangedDataFile("adjacent.parquet", Long.MAX_VALUE - 3,
1L),
+ rangedDataFile("first.parquet", 0L, 1L));
+ assertThat(
+ DataEvolutionUtils.materializedRowIdRanges(
+ before,
+ Collections.singletonList(
+ rangedDataFile("suffix.parquet",
Long.MAX_VALUE - 1, 2L))))
+ .containsExactly(
+ new Range(0, 0), new Range(Long.MAX_VALUE - 3,
Long.MAX_VALUE - 2));
+ assertThat(
+ DataEvolutionUtils.materializedRowIdRanges(
+ before,
+ Collections.singletonList(
+ rangedDataFile("prefix.parquet",
Long.MAX_VALUE - 3, 3L))))
+ .containsExactly(new Range(0, 0), new Range(Long.MAX_VALUE,
Long.MAX_VALUE));
+ assertThat(DataEvolutionUtils.materializedRowIdRanges(before,
before)).isEmpty();
+ }
+
+ private static DataFileMeta rangedDataFile(String fileName, Long
firstRowId, long rowCount) {
+ return DataFileMeta.forAppend(
+ fileName,
+ 1L,
+ rowCount,
+ SimpleStats.EMPTY_STATS,
+ 1L,
+ 1L,
+ 1L,
+ Collections.emptyList(),
+ null,
+ null,
+ null,
+ null,
+ firstRowId,
+ null);
+ }
+
private static DataFileMeta dataFile(String fileName, long
maxSequenceNumber) {
return dataFile(fileName, maxSequenceNumber, Collections.emptyList());
}
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 b848f2467e..6fa55b1c4a 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
@@ -20,7 +20,9 @@ package org.apache.paimon.flink.compact;
import org.apache.paimon.CoreOptions;
import org.apache.paimon.Snapshot;
+import org.apache.paimon.append.cluster.DataEvolutionClusterCompaction;
import org.apache.paimon.append.cluster.IncrementalClusterManager;
+import org.apache.paimon.append.dataevolution.DataEvolutionCompactTask;
import org.apache.paimon.codegen.CodeGenUtils;
import org.apache.paimon.compact.CompactUnit;
import org.apache.paimon.data.BinaryRow;
@@ -34,6 +36,8 @@ import
org.apache.paimon.flink.cluster.RewriteIncrementalClusterCommittableOpera
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.DataEvolutionCompactionTaskTypeInfo;
+import org.apache.paimon.flink.sink.DataEvolutionCompactionWorkerOperator;
import org.apache.paimon.flink.sink.DataEvolutionDeletionVectorMaterializeSink;
import org.apache.paimon.flink.sink.FlinkSinkBuilder;
import org.apache.paimon.flink.sink.RowAppendTableSink;
@@ -64,6 +68,7 @@ import org.apache.flink.table.data.RowData;
import javax.annotation.Nullable;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
@@ -75,6 +80,7 @@ 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.flink.FlinkConnectorOptions.SINK_MANAGED_WRITER_BUFFER_MEMORY;
import static
org.apache.paimon.flink.FlinkConnectorOptions.SINK_USE_MANAGED_MEMORY;
+import static
org.apache.paimon.flink.FlinkConnectorOptions.UNAWARE_BUCKET_COMPACTION_PARALLELISM;
import static
org.apache.paimon.flink.utils.ManagedMemoryUtils.declareManagedMemory;
import static org.apache.paimon.table.PrimaryKeyTableUtils.addKeyNamePrefix;
@@ -91,6 +97,7 @@ public class IncrementalClusterCompact {
protected final int localSampleMagnification;
protected final Map<BinaryRow, CompactUnit> compactUnits;
protected final Map<BinaryRow, Pair<List<DataSplit>, CommitMessage>>
compactSplits;
+ private final List<DataEvolutionCompactTask> normalCompactTasks;
public IncrementalClusterCompact(
StreamExecutionEnvironment env,
@@ -126,11 +133,16 @@ public class IncrementalClusterCompact {
// non-full strategy as default for incremental clustering
this.compactUnits =
clusterManager.createCompactUnits(fullCompaction != null &&
fullCompaction);
+ this.normalCompactTasks =
+ clusterManager.snapshot() == null
+ ? Collections.emptyList()
+ : DataEvolutionClusterCompaction.plan(
+ table, clusterManager.snapshot(),
partitionPredicate, compactUnits);
this.compactSplits =
clusterManager.toSplitsAndRewriteDvFiles(compactUnits);
}
public void build() throws Exception {
- if (compactUnits.isEmpty()) {
+ if (compactUnits.isEmpty() && normalCompactTasks.isEmpty()) {
env.fromSequence(0, 0).name("Nothing to Cluster
Source").sinkTo(new DiscardingSink<>());
return;
}
@@ -145,6 +157,24 @@ public class IncrementalClusterCompact {
entry.getValue().getRight(),
parallelism));
}
+ if (!normalCompactTasks.isEmpty()) {
+ Integer workerParallelism =
+
Options.fromMap(table.options()).get(UNAWARE_BUCKET_COMPACTION_PARALLELISM);
+ dataStreams.add(
+ env.fromCollection(
+ normalCompactTasks, new
DataEvolutionCompactionTaskTypeInfo())
+ .name("Data Evolution Cluster Compaction Tasks")
+ .rebalance()
+ .transform(
+ "Compact data evolution column versions",
+ new CommittableTypeInfo(),
+ new
DataEvolutionCompactionWorkerOperator.Factory(
+ table, commitUser))
+ .setParallelism(
+ workerParallelism == null
+ ? env.getParallelism()
+ : workerParallelism));
+ }
buildCommitOperator(dataStreams);
}
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 01e17a2bd9..2ba7e87e8a 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
@@ -50,6 +50,7 @@ import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
+import java.util.Random;
import java.util.stream.Collectors;
import static
org.apache.paimon.deletionvectors.DeletionVectorsIndexFile.DELETION_VECTORS_INDEX;
@@ -349,6 +350,204 @@ public class DataEvolutionDeleteSqlITCase extends
CatalogITCaseBase {
assertThat(scalarIndexFiles(table, indexType, "A")).isEmpty();
}
+ @ParameterizedTest
+ @CsvSource({"btree,global-sort", "bitmap,local-sort"})
+ public void testIncrementalClusteringCompactsRepeatedColumnUpdates(
+ String indexType, String mode) throws Exception {
+ tEnv.getConfig().set(TableConfigOptions.TABLE_DML_SYNC, true);
+ createTable();
+ sql(
+ "ALTER TABLE T SET ('write-only' = 'true', "
+ + "'clustering.incremental' = 'true', "
+ + "'clustering.strategy' = 'order',
'clustering.columns' = 'id', "
+ + "'clustering.incremental.mode' = '"
+ + mode
+ + "', 'compaction.min.file-num' = '5', "
+ + "'scan.parallelism' = '1', 'sink.parallelism' =
'1')");
+ sql("INSERT INTO T VALUES (3, 'three', 'A'), (1, 'one', 'A'), (2,
'two', 'A')");
+ sql("CALL sys.compact(`table` => 'default.T', compact_strategy =>
'full')");
+ createIdIndex(indexType);
+ sql("DELETE FROM T WHERE id = 2");
+
+ FileStoreTable table = paimonTable("T");
+ DataFileMeta base =
table.store().newScan().plan().files().get(0).file();
+ List<String> indexes = scalarIndexFiles(table, indexType, "A");
+ List<Row> originalIds = sql("SELECT id, _ROW_ID FROM T$row_tracking
ORDER BY id");
+ for (int version = 0; version < 4; version++) {
+ writePartialGroup(
+ table,
+ "name",
+ base.nonNullFirstRowId(),
+ "one-" + version,
+ "two-" + version,
+ "three-" + version);
+ }
+ Snapshot before = table.latestSnapshot().get();
+ List<String> beforeFiles = plannedFiles(table);
+ assertThat(beforeFiles).hasSize(5);
+
+ sql("CALL sys.compact(`table` => 'default.T', compact_strategy =>
'full')");
+
+ Snapshot compacted = table.latestSnapshot().get();
+ assertThat(compacted.id()).isEqualTo(before.id() + 1);
+
assertThat(compacted.commitKind()).isEqualTo(Snapshot.CommitKind.COMPACT);
+ assertThat(compacted.nextRowId()).isEqualTo(before.nextRowId());
+ List<DataFileMeta> afterFiles =
+ table.store().newScan().plan().files().stream()
+ .map(entry -> entry.file())
+ .collect(Collectors.toList());
+ assertThat(afterFiles)
+ .singleElement()
+ .satisfies(
+ file -> {
+ assertThat(file.nonNullRowIdRange())
+ .isEqualTo(base.nonNullRowIdRange());
+ assertThat(file.level()).isEqualTo(base.level());
+ assertThat(file.rowCount()).isEqualTo(3);
+ });
+
assertThat(plannedFiles(table)).doesNotContainAnyElementsOf(beforeFiles);
+ assertThat(scalarIndexFiles(table, indexType, "A")).isEqualTo(indexes);
+ assertThat(deletionVectorCardinality(table)).isEqualTo(1L);
+ assertThat(sql("SELECT id, _ROW_ID FROM T$row_tracking ORDER BY id"))
+ .containsExactlyElementsOf(originalIds);
+ sql("ALTER TABLE T SET ('scalar-index.search-mode' = 'fast')");
+ assertThat(sql("SELECT id, name FROM T WHERE id IN (1, 2, 3) ORDER BY
id"))
+ .containsExactly(Row.of(1, "one-3"), Row.of(3, "three-3"));
+
+ sql("CALL sys.compact(`table` => 'default.T', compact_strategy =>
'full')");
+
+
assertThat(table.latestSnapshot().get().id()).isEqualTo(compacted.id());
+
assertThat(plannedFiles(table)).containsExactly(afterFiles.get(0).fileName());
+ assertThat(scalarIndexFiles(table, indexType, "A")).isEqualTo(indexes);
+
+ // The compacted file contains all columns, but its id values still
predate the index.
+ // Retaining per-column sequences lets later clustering reuse that
valid index.
+ sql("INSERT INTO T VALUES (4, 'four', 'A')");
+ Snapshot beforeClustering = table.latestSnapshot().get();
+
+ sql("CALL sys.compact(`table` => 'default.T', compact_strategy =>
'full')");
+
+
assertThat(table.latestSnapshot().get().id()).isEqualTo(beforeClustering.id() +
1);
+ assertThat(table.latestSnapshot().get().nextRowId())
+ .isEqualTo(beforeClustering.nextRowId() + 3);
+ assertThat(scalarIndexFiles(table, indexType, "A"))
+ .isNotEmpty()
+ .doesNotContainAnyElementsOf(indexes);
+ assertThat(sql("SELECT id, name FROM T WHERE id IN (1, 2, 3) ORDER BY
id"))
+ .containsExactly(Row.of(1, "one-3"), Row.of(3, "three-3"));
+ assertThat(sql("SELECT id, _ROW_ID FROM T$row_tracking ORDER BY id"))
+ .containsExactly(
+ Row.of(1, beforeClustering.nextRowId()),
+ Row.of(3, beforeClustering.nextRowId() + 1),
+ Row.of(4, beforeClustering.nextRowId() + 2));
+ assertThat(deletionVectorCardinality(table)).isZero();
+ }
+
+ @Test
+ public void testIncrementalClusteringCombinesCompactionInSamePartition()
throws Exception {
+ tEnv.getConfig().set(TableConfigOptions.TABLE_DML_SYNC, true);
+ createTable();
+ sql(
+ "ALTER TABLE T SET ('write-only' = 'true', "
+ + "'clustering.incremental' = 'true', "
+ + "'clustering.strategy' = 'order',
'clustering.columns' = 'id', "
+ + "'clustering.incremental.mode' = 'global-sort', "
+ + "'compaction.min.file-num' = '5', "
+ + "'num-sorted-run.compaction-trigger' = '2', "
+ + "'compaction.max-size-amplification-percent' =
'10000', "
+ + "'compaction.size-ratio' = '0', 'file.compression' =
'none', "
+ + "'target-file-row-num' = '3', "
+ + "'scan.parallelism' = '1', 'sink.parallelism' =
'1')");
+ FileStoreTable table = paimonTable("T");
+ // A large clustered run must stay outside the minor clustering
candidate. Its column
+ // versions still require normal compaction in the same partition and
commit.
+ BatchWriteBuilder builder = table.newBatchWriteBuilder();
+ try (BatchTableWrite write = builder.newWrite();
+ BatchTableCommit commit = builder.newCommit()) {
+ for (int id : new int[] {3, 1, 2}) {
+ Random random = new Random(id);
+ StringBuilder name = new StringBuilder(65536);
+ for (int i = 0; i < 65536; i++) {
+ name.append((char) ('a' + random.nextInt(26)));
+ }
+ write.write(
+ GenericRow.of(
+ id,
+ BinaryString.fromString(name.toString()),
+ BinaryString.fromString("A")));
+ }
+ commit.commit(write.prepareCommit());
+ }
+ sql("CALL sys.compact(`table` => 'default.T', compact_strategy =>
'full')");
+ createIdIndex("btree");
+ sql("DELETE FROM T WHERE id = 2");
+ DataFileMeta base =
table.store().newScan().plan().files().get(0).file();
+ List<String> retainedIndexes = scalarIndexFiles(table, "btree", "A");
+ List<Row> originalIds = sql("SELECT id, _ROW_ID FROM T$row_tracking
ORDER BY id");
+ for (int version = 0; version < 4; version++) {
+ writePartialGroup(
+ table,
+ "name",
+ base.nonNullFirstRowId(),
+ "one-" + version,
+ "two-" + version,
+ "three-" + version);
+ }
+ sql("INSERT INTO T VALUES (6, 'six', 'A'), (4, 'four', 'A'), (5,
'five', 'A')");
+ sql("INSERT INTO T VALUES (9, 'nine', 'A'), (7, 'seven', 'A'), (8,
'eight', 'A')");
+ createIdIndex("btree");
+ List<String> allIndexes = scalarIndexFiles(table, "btree", "A");
+ assertThat(allIndexes)
+ .containsAll(retainedIndexes)
+ .hasSizeGreaterThan(retainedIndexes.size());
+ List<String> rewrittenIndexes = new ArrayList<>(allIndexes);
+ rewrittenIndexes.removeAll(retainedIndexes);
+ Snapshot before = table.latestSnapshot().get();
+ List<String> beforeFiles = plannedFiles(table);
+ assertThat(beforeFiles).hasSize(7);
+
+ sql("CALL sys.compact(`table` => 'default.T', compact_strategy =>
'minor')");
+
+ Snapshot compacted = table.latestSnapshot().get();
+ assertThat(compacted.id()).isEqualTo(before.id() + 1);
+
assertThat(compacted.commitKind()).isEqualTo(Snapshot.CommitKind.COMPACT);
+ assertThat(compacted.nextRowId()).isEqualTo(before.nextRowId() + 6);
+ List<DataFileMeta> afterFiles =
+ table.store().newScan().plan().files().stream()
+ .map(entry -> entry.file())
+ .collect(Collectors.toList());
+ assertThat(afterFiles).hasSize(3);
+ assertThat(afterFiles)
+ .filteredOn(file -> file.nonNullFirstRowId() ==
base.nonNullFirstRowId())
+ .singleElement()
+ .satisfies(
+ file -> {
+ assertThat(file.level()).isEqualTo(base.level());
+ assertThat(file.nonNullRowIdRange())
+ .isEqualTo(base.nonNullRowIdRange());
+ });
+
assertThat(plannedFiles(table)).doesNotContainAnyElementsOf(beforeFiles);
+ assertThat(scalarIndexFiles(table, "btree", "A"))
+ .containsAll(retainedIndexes)
+ .doesNotContainAnyElementsOf(rewrittenIndexes);
+ assertThat(sql("SELECT id, _ROW_ID FROM T$row_tracking WHERE id <= 3
ORDER BY id"))
+ .containsExactlyElementsOf(originalIds);
+ assertThat(sql("SELECT _ROW_ID FROM T$row_tracking WHERE id >= 4"))
+ .hasSize(6)
+ .allSatisfy(
+ row ->
+ assertThat((Long) row.getField(0))
+
.isGreaterThanOrEqualTo(before.nextRowId()));
+ assertThat(deletionVectorCardinality(table)).isEqualTo(1L);
+ sql("ALTER TABLE T SET ('scalar-index.search-mode' = 'fast')");
+ assertThat(sql("SELECT id, name FROM T WHERE id IN (1, 2, 3, 4, 9)
ORDER BY id"))
+ .containsExactly(
+ Row.of(1, "one-3"),
+ Row.of(3, "three-3"),
+ Row.of(4, "four"),
+ Row.of(9, "nine"));
+ }
+
@ParameterizedTest
@ValueSource(strings = {"global-sort", "local-sort"})
public void testIncrementalClusteringPreservesInlineBlobViews(String mode)
throws Exception {
@@ -760,6 +959,14 @@ public class DataEvolutionDeleteSqlITCase extends
CatalogITCaseBase {
+ "index_column => 'name', index_type => 'btree')");
}
+ private void createIdIndex(String indexType) {
+ sql(
+ "CALL sys.create_global_index(`table` => 'default.T', "
+ + "index_column => 'id', index_type => '"
+ + indexType
+ + "')");
+ }
+
private void createTable() {
sql(
"CREATE TABLE T (id INT, name STRING, dt STRING) PARTITIONED
BY (dt) WITH ("
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 ada67b342b..5cdac96c4b 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
@@ -23,6 +23,7 @@ import org.apache.paimon.CoreOptions.OrderType;
import org.apache.paimon.Snapshot;
import org.apache.paimon.append.AppendCompactCoordinator;
import org.apache.paimon.append.AppendCompactTask;
+import org.apache.paimon.append.cluster.DataEvolutionClusterCompaction;
import org.apache.paimon.append.cluster.IncrementalClusterManager;
import org.apache.paimon.append.dataevolution.DataEvolutionCompactCoordinator;
import org.apache.paimon.append.dataevolution.DataEvolutionCompactTask;
@@ -857,11 +858,15 @@ public class CompactProcedure extends BaseProcedure {
new IncrementalClusterManager(table, partitionPredicate,
snapshot);
Map<BinaryRow, CompactUnit> compactUnits =
incrementalClusterManager.createCompactUnits(fullCompaction);
+ List<DataEvolutionCompactTask> compactionTasks =
+ DataEvolutionClusterCompaction.plan(
+ table, snapshot, partitionPredicate, compactUnits);
if (partitionIdleTime != null) {
Set<BinaryRow> idlePartitions =
getPartitionsToCompact(
table.newSnapshotReader().withSnapshot(snapshot),
partitionIdleTime);
compactUnits.keySet().retainAll(idlePartitions);
+ compactionTasks.removeIf(task ->
!idlePartitions.contains(task.partition()));
}
LOG.info(
"Planned {} compact units to incrementally cluster for table
{}, full compaction {}.",
@@ -869,7 +874,7 @@ public class CompactProcedure extends BaseProcedure {
table.fullName(),
fullCompaction);
- if (compactUnits.isEmpty()) {
+ if (compactUnits.isEmpty() && compactionTasks.isEmpty()) {
return;
}
@@ -1000,10 +1005,17 @@ public class CompactProcedure extends BaseProcedure {
}
if (table.coreOptions().dataEvolutionEnabled()) {
- List<CommitMessage> preparedMessages =
-
incrementalClusterManager.prepareCommitMessages(clusterMessages);
String commitUser =
createCommitUser(table.coreOptions().toConfiguration());
try (TableCommitImpl commit =
table.newCommit(commitUser).materializeDvRowIdCheck()) {
+ clusterMessages.addAll(
+ DataEvolutionRewriteExecutor.executeTasks(
+ clusterTable,
+ compactionTasks,
+ new JavaSparkContext(spark().sparkContext()),
+ spark(),
+ commitUser));
+ List<CommitMessage> preparedMessages =
+
incrementalClusterManager.prepareCommitMessages(clusterMessages);
commit.commit(preparedMessages);
} catch (Exception e) {
throw new RuntimeException("Commit data evolution clustering
failed.", e);
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/DataEvolutionRewriteExecutor.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/DataEvolutionRewriteExecutor.java
index a6610bfff8..cd942f9ab2 100644
---
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/DataEvolutionRewriteExecutor.java
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/DataEvolutionRewriteExecutor.java
@@ -105,7 +105,6 @@ final class DataEvolutionRewriteExecutor {
CommitConfigurer commitConfigurer,
@Nullable CommitMessageRewriter commitMessageRewriter,
@Nullable Consumer<List<CommitMessage>> commitObserver) {
- CommitMessageSerializer messageSerializer = new
CommitMessageSerializer();
String commitUser =
createCommitUser(table.coreOptions().toConfiguration());
Snapshot preparationSnapshot = initialSnapshot;
int round = 0;
@@ -122,64 +121,14 @@ final class DataEvolutionRewriteExecutor {
table.fullName());
continue;
}
- boolean containsMaterializeDeletion =
- compactionTasks.stream()
- .anyMatch(
- task ->
- task.type()
- ==
DataEvolutionCompactTask.TaskType
-
.MATERIALIZE_DELETION);
-
- DataEvolutionCompactTaskSerializer serializer =
- new DataEvolutionCompactTaskSerializer();
- List<byte[]> serializedTasks = new ArrayList<>();
- try {
- for (DataEvolutionCompactTask compactionTask :
compactionTasks) {
-
serializedTasks.add(serializer.serialize(compactionTask));
- }
- } catch (IOException e) {
- throw new RuntimeException("Serialize data evolution
rewrite task failed.", e);
- }
-
- int readParallelism = readParallelism(serializedTasks,
sparkSession);
- LOG.info(
- "Starting to execute {} data evolution rewrite tasks
of table {} in round {} "
- + "with read parallelism {}, contains
materialize deletion {}.",
- serializedTasks.size(),
- table.fullName(),
- round,
- readParallelism,
- containsMaterializeDeletion);
- JavaRDD<byte[]> commitMessageJavaRDD =
- javaSparkContext
- .parallelize(serializedTasks, readParallelism)
- .mapPartitions(
- (FlatMapFunction<Iterator<byte[]>,
byte[]>)
- taskIterator -> {
-
DataEvolutionCompactTaskSerializer ser =
- new
DataEvolutionCompactTaskSerializer();
- List<byte[]> messagesBytes
= new ArrayList<>();
- CommitMessageSerializer
messageSer =
- new
CommitMessageSerializer();
- while
(taskIterator.hasNext()) {
-
DataEvolutionCompactTask task =
-
ser.deserialize(
-
ser.getVersion(),
-
taskIterator.next());
- messagesBytes.add(
-
messageSer.serialize(
-
task.doCompact(
-
table,
-
commitUser)));
- }
- return
messagesBytes.iterator();
- });
-
- List<byte[]> serializedMessages = new
ArrayList<>(commitMessageJavaRDD.collect());
try {
List<CommitMessage> compactMessages =
- deserializeCommitMessagesAndReleaseSerializedBytes(
- messageSerializer, serializedMessages);
+ executeTasks(
+ table,
+ compactionTasks,
+ javaSparkContext,
+ sparkSession,
+ commitUser);
Snapshot committedSnapshot =
commitWithMergeConflictRetry(
table,
@@ -209,6 +158,54 @@ final class DataEvolutionRewriteExecutor {
}
}
+ /** Executes one task batch without publishing it, so callers can share a
commit. */
+ static List<CommitMessage> executeTasks(
+ FileStoreTable table,
+ List<DataEvolutionCompactTask> tasks,
+ JavaSparkContext javaSparkContext,
+ SparkSession sparkSession,
+ String commitUser)
+ throws IOException {
+ if (tasks.isEmpty()) {
+ return Collections.emptyList();
+ }
+ DataEvolutionCompactTaskSerializer serializer = new
DataEvolutionCompactTaskSerializer();
+ List<byte[]> serializedTasks = new ArrayList<>(tasks.size());
+ for (DataEvolutionCompactTask task : tasks) {
+ serializedTasks.add(serializer.serialize(task));
+ }
+ int parallelism = readParallelism(serializedTasks, sparkSession);
+ LOG.info(
+ "Executing {} data evolution rewrite tasks for table {} with
parallelism {}.",
+ tasks.size(),
+ table.fullName(),
+ parallelism);
+ JavaRDD<byte[]> serializedMessages =
+ javaSparkContext
+ .parallelize(serializedTasks, parallelism)
+ .mapPartitions(
+ (FlatMapFunction<Iterator<byte[]>, byte[]>)
+ taskIterator -> {
+ DataEvolutionCompactTaskSerializer
taskSerializer =
+ new
DataEvolutionCompactTaskSerializer();
+ CommitMessageSerializer
messageSerializer =
+ new
CommitMessageSerializer();
+ List<byte[]> messages = new
ArrayList<>();
+ while (taskIterator.hasNext()) {
+ DataEvolutionCompactTask task =
+
taskSerializer.deserialize(
+
taskSerializer.getVersion(),
+
taskIterator.next());
+ messages.add(
+
messageSerializer.serialize(
+
task.doCompact(table, commitUser)));
+ }
+ return messages.iterator();
+ });
+ return deserializeCommitMessagesAndReleaseSerializedBytes(
+ new CommitMessageSerializer(), new
ArrayList<>(serializedMessages.collect()));
+ }
+
private static Snapshot commitWithMergeConflictRetry(
FileStoreTable table,
Snapshot taskSnapshot,
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 2df8a5936c..1cd9ed2ca6 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
@@ -2724,6 +2724,78 @@ abstract class CompactProcedureTestBase extends
PaimonSparkTestBase with StreamT
}
}
+ Seq("local-sort", "global-sort").foreach {
+ mode =>
+ test(s"Paimon Procedure: data evolution clustering compacts column
versions with $mode") {
+ 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.columns' = 'id')
+ |""".stripMargin)
+ sql(
+ "INSERT INTO T SELECT /*+ REPARTITION(1) */ * FROM VALUES " +
+ "(3, 30, 'a'), (1, 10, 'a'), (2, 20, 'a')")
+ sql("CALL sys.compact(table => 'T', compact_strategy => 'full')")
+ val table = loadTable("T")
+ val maxLevel = table.coreOptions().numLevels() - 1
+ val originalIds = sql("SELECT id, _ROW_ID FROM T ORDER BY
id").collect().toSeq
+ (1 to 3).foreach(i => sql(s"UPDATE T SET value = ${100 + i} WHERE pt
= 'a'"))
+ val belowThreshold = lastSnapshotId(table)
+ sql("CALL sys.compact(table => 'T')")
+ assert(lastSnapshotId(table) == belowThreshold)
+ assert(normalDataFiles(table).size == 4)
+
+ sql("UPDATE T SET value = 104 WHERE pt = 'a'")
+ sql("DELETE FROM T WHERE id = 2")
+ val before = table.latestSnapshot().get()
+ assert(normalDataFiles(table).size == 5)
+ sql(
+ "CALL sys.compact(table => 'T', compact_strategy => 'full', " +
+ "partitions => 'pt=a', partition_idle_time => '0 s')")
+
+ assert(lastSnapshotId(table) == before.id() + 1)
+ assert(table.latestSnapshot().get().nextRowId() ==
before.nextRowId())
+ assert(normalDataFiles(table).size == 1)
+ assert(normalDataFiles(table).head.level() == maxLevel)
+ assert(normalDataFiles(table).head.rowCount() == 3)
+ assert(deletionVectorCardinality(table) == 1)
+ checkAnswer(sql("SELECT id, value FROM T"), Seq(Row(1, 104), Row(3,
104)))
+ checkAnswer(sql("SELECT id, _ROW_ID FROM T"),
originalIds.filter(_.getInt(0) != 2))
+ val compactedSnapshot = lastSnapshotId(table)
+ sql("CALL sys.compact(table => 'T', compact_strategy => 'full')")
+ assert(lastSnapshotId(table) == compactedSnapshot)
+
+ // The same invocation also clusters another partition, with one
shared commit.
+ (1 to 4).foreach(i => sql(s"UPDATE T SET value = ${200 + i} WHERE pt
= 'a'"))
+ sql(
+ "INSERT INTO T SELECT /*+ REPARTITION(1) */ * FROM VALUES " +
+ "(8, 80, 'b'), (6, 60, 'b')")
+ val mixed = table.latestSnapshot().get()
+ sql("CALL sys.compact(table => 'T', compact_strategy => 'full')")
+ assert(lastSnapshotId(table) == mixed.id() + 1)
+ assert(table.latestSnapshot().get().nextRowId() == mixed.nextRowId()
+ 2)
+ assert(normalDataFiles(table).size == 2)
+ assert(normalDataFiles(table).forall(_.level() == maxLevel))
+ assert(deletionVectorCardinality(table) == 1)
+ checkAnswer(
+ sql("SELECT id, _ROW_ID FROM T WHERE pt = 'a'"),
+ originalIds.filter(_.getInt(0) != 2))
+ checkAnswer(
+ sql("SELECT id, value FROM T"),
+ Seq(Row(1, 204), Row(3, 204), Row(6, 60), Row(8, 80)))
+ }
+ }
+ }
+
test("Paimon Procedure: data evolution clustering preserves inline blob
descriptors") {
withTable("cluster_descriptor") {
val blobData = "descriptor content".getBytes("UTF-8")