diff --git a/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestEntrySortKey.java b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestEntrySortKey.java new file mode 100644 index 000000000000..6859e1113018 --- /dev/null +++ b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestEntrySortKey.java @@ -0,0 +1,143 @@ +/* + * 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.manifest; + +import org.apache.paimon.codegen.CodeGenUtils; +import org.apache.paimon.codegen.RecordComparator; +import org.apache.paimon.data.BinaryRow; +import org.apache.paimon.types.DataType; +import org.apache.paimon.utils.SerializationUtils; + +import java.io.Serializable; +import java.util.List; +import java.util.Objects; + +/** + * A lightweight, serializable sort key for {@link ManifestEntry}. Entries are ordered by {@code + * partition -> bucket -> level -> fileName}, which co-locates the ADD and DELETE of the same file + * (they share the same key) so they always land in the same Spark partition after {@code sortByKey} + * and can be cancelled during the manifest rewrite. The {@code partition -> bucket -> level} prefix + * also keeps {@link ManifestFileMeta} statistics (partitionStats / minBucket / maxBucket / minLevel + * / maxLevel) compact for scan pruning. + * + *

The partition is kept as serialized bytes and deserialized lazily on the first comparison; the + * generated {@link RecordComparator} is also created lazily per executor to avoid being serialized + * across the shuffle. This class is intentionally small so that it can be used as the key of a + * Spark {@code sortByKey} shuffle without moving the whole {@link ManifestEntry} payload. + * + *

Kryo compatibility: the partition is stored as a plain {@code byte[]} rather than a + * {@link BinaryRow} because this key travels through Spark's shuffle, where the serializer is + * {@code KryoSerializer} (Paimon's Spark test base randomly picks Kryo). {@code BinaryRow} only + * implements Java serialization (its {@code BinarySection.writeObject/readObject} callbacks), and + * Kryo does not invoke those callbacks while also skipping the {@code transient segments} field — + * so a {@code BinaryRow} key ends up with {@code null} segments after a Kryo shuffle and NPEs on + * the first comparison. {@code byte[]} and {@code String} are transparent to both Kryo and Java + * serialization, and the {@link BinaryRow} / {@link RecordComparator} are rebuilt lazily on the + * executor after the shuffle. + */ +public class ManifestEntrySortKey implements Serializable, Comparable { + + private static final long serialVersionUID = 1L; + + private final byte[] partitionBytes; + private final int bucket; + private final int level; + private final String fileName; + + private final List partitionFieldTypes; + + private transient BinaryRow partition; + private transient RecordComparator partitionComparator; + + public ManifestEntrySortKey( + BinaryRow partition, + int bucket, + int level, + String fileName, + List partitionFieldTypes) { + this.partitionBytes = SerializationUtils.serializeBinaryRow(partition); + this.bucket = bucket; + this.level = level; + this.fileName = fileName; + this.partitionFieldTypes = partitionFieldTypes; + } + + @Override + public int compareTo(ManifestEntrySortKey other) { + // 1. partition + int cmp = partitionComparator().compare(partition(), other.partition()); + if (cmp != 0) { + return cmp; + } + // 2. bucket + cmp = Integer.compare(bucket, other.bucket); + if (cmp != 0) { + return cmp; + } + // 3. level + cmp = Integer.compare(level, other.level); + if (cmp != 0) { + return cmp; + } + // 4. fileName — co-locates ADD and DELETE of the same file (same key) + return fileName.compareTo(other.fileName); + } + + private BinaryRow partition() { + if (partition == null) { + partition = SerializationUtils.deserializeBinaryRow(partitionBytes); + } + return partition; + } + + private RecordComparator partitionComparator() { + if (partitionComparator == null) { + partitionComparator = CodeGenUtils.newRecordComparator(partitionFieldTypes); + } + return partitionComparator; + } + + public int bucket() { + return bucket; + } + + public int level() { + return level; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ManifestEntrySortKey that = (ManifestEntrySortKey) o; + return bucket == that.bucket + && level == that.level + && Objects.deepEquals(partitionBytes, that.partitionBytes) + && Objects.equals(fileName, that.fileName); + } + + @Override + public int hashCode() { + return Objects.hash(bucket, level, fileName, java.util.Arrays.hashCode(partitionBytes)); + } +} diff --git a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommit.java b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommit.java index b039ffb9e9fc..7caa89e2da28 100644 --- a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommit.java +++ b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommit.java @@ -23,6 +23,7 @@ import org.apache.paimon.disk.IOManager; import org.apache.paimon.fs.FileIO; import org.apache.paimon.manifest.ManifestCommittable; +import org.apache.paimon.manifest.ManifestFileMeta; import org.apache.paimon.operation.metrics.CommitMetrics; import org.apache.paimon.stats.Statistics; import org.apache.paimon.table.sink.CommitMessage; @@ -83,6 +84,16 @@ FileStoreCommit rowIdCheckConflictForMaterializeDvCompaction( /** Compact the manifest entries only. */ void compactManifest(); + /** + * Replace the manifest entries with the given rewritten manifests. The {@code removedManifests} + * are the manifests the caller read and sorted (used for conflict detection); the {@code + * addedManifests} are the sorted rewrite result produced by the caller. The commit reuses the + * optimistic concurrency mode of {@link #compactManifest()}: on conflict, new delta manifests + * added by other commits are appended to the tail of {@code addedManifests}. + */ + void replaceManifest( + List removedManifests, List addedManifests); + /** Roll back to the target snapshot and materialize it as the latest snapshot. */ boolean rollbackToAsLatest(Snapshot targetSnapshot); 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 7f9a02f6022a..107faba81db6 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 @@ -1675,6 +1675,98 @@ public void compactManifest() { } } + @Override + public void replaceManifest( + List removedManifests, List addedManifests) { + int retryCount = 0; + long startMillis = System.currentTimeMillis(); + Set removedPathSet = + removedManifests.stream() + .map(ManifestFileMeta::fileName) + .collect(Collectors.toSet()); + while (true) { + Snapshot latestSnapshot = snapshotManager.latestSnapshot(); + if (latestSnapshot == null) { + throw new RuntimeException("Cannot replace manifests: the table has no snapshot."); + } + + List currentBase = manifestList.readDataManifests(latestSnapshot); + Set currentPathSet = + currentBase.stream() + .map(ManifestFileMeta::fileName) + .collect(Collectors.toSet()); + + if (!currentPathSet.containsAll(removedPathSet)) { + cleanUpRewrittenManifests(addedManifests); + throw new RuntimeException( + "Manifest conflict: the current snapshot does not contain all the " + + "manifests to replace. Another manifest rewrite may have " + + "happened concurrently; please retry."); + } + + List manifestsKept = + currentBase.stream() + .filter(m -> !removedPathSet.contains(m.fileName())) + .collect(Collectors.toList()); + + List manifestsToCommit = new ArrayList<>(manifestsKept); + manifestsToCommit.addAll(addedManifests); + + Pair baseManifestList = manifestList.write(manifestsToCommit); + Pair deltaManifestList = manifestList.write(emptyList()); + + Snapshot newSnapshot = + new Snapshot( + latestSnapshot.id() + 1, + latestSnapshot.schemaId(), + baseManifestList.getLeft(), + baseManifestList.getRight(), + deltaManifestList.getLeft(), + deltaManifestList.getRight(), + null, + null, + latestSnapshot.indexManifest(), + commitUser, + Long.MAX_VALUE, + CommitKind.COMPACT, + System.currentTimeMillis(), + latestSnapshot.totalRecordCount(), + 0L, + null, + latestSnapshot.watermark(), + latestSnapshot.statistics(), + latestSnapshot.properties(), + latestSnapshot.nextRowId(), + null); + + if (commitSnapshotImpl(latestSnapshot, newSnapshot, emptyList())) { + return; + } + + manifestList.delete(deltaManifestList.getLeft()); + manifestList.delete(baseManifestList.getLeft()); + + if (System.currentTimeMillis() - startMillis > options.commitTimeout() + || retryCount >= options.commitMaxRetries()) { + cleanUpRewrittenManifests(addedManifests); + throw new RuntimeException( + String.format( + "Commit failed after %s millis with %s retries, there maybe exist commit conflicts between multiple jobs.", + options.commitTimeout(), retryCount)); + } + + retryWaiter.retryWait(retryCount); + retryCount++; + } + } + + /** Delete the rewritten manifest files produced by this rewrite. */ + private void cleanUpRewrittenManifests(List addedManifests) { + for (ManifestFileMeta manifest : addedManifests) { + manifestFile.delete(manifest.fileName()); + } + } + private boolean compactManifestOnce() { Snapshot latestSnapshot = snapshotManager.latestSnapshot(); diff --git a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableCommit.java b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableCommit.java index 278e4c4f369f..2b1c483cc75b 100644 --- a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableCommit.java +++ b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableCommit.java @@ -27,6 +27,7 @@ import org.apache.paimon.fs.FileStatus; import org.apache.paimon.fs.Path; import org.apache.paimon.fs.TwoPhaseOutputStream; +import org.apache.paimon.manifest.ManifestFileMeta; import org.apache.paimon.metrics.MetricRegistry; import org.apache.paimon.options.CatalogOptions; import org.apache.paimon.options.Options; @@ -318,6 +319,12 @@ public void compactManifests() { throw new UnsupportedOperationException(); } + @Override + public void replaceManifests( + List removedManifests, List addedManifests) { + throw new UnsupportedOperationException(); + } + @Override public TableCommit withMetricRegistry(MetricRegistry registry) { throw new UnsupportedOperationException(); diff --git a/paimon-core/src/main/java/org/apache/paimon/table/sink/BatchTableCommit.java b/paimon-core/src/main/java/org/apache/paimon/table/sink/BatchTableCommit.java index 2cfc181eada2..09a4c88d306b 100644 --- a/paimon-core/src/main/java/org/apache/paimon/table/sink/BatchTableCommit.java +++ b/paimon-core/src/main/java/org/apache/paimon/table/sink/BatchTableCommit.java @@ -21,6 +21,7 @@ import org.apache.paimon.Snapshot; import org.apache.paimon.Snapshot.CommitKind; import org.apache.paimon.annotation.Public; +import org.apache.paimon.manifest.ManifestFileMeta; import org.apache.paimon.stats.Statistics; import java.util.List; @@ -73,6 +74,14 @@ public interface BatchTableCommit extends TableCommit { /** Compact the manifest entries. Generates a snapshot with {@link CommitKind#COMPACT}. */ void compactManifests(); + /** + * Replace the manifest entries with the given rewritten manifests. The {@code removedManifests} + * are the manifests the caller read and sorted; the {@code addedManifests} are the sorted + * rewrite result. Generates a snapshot with {@link CommitKind#COMPACT}. + */ + void replaceManifests( + List removedManifests, List addedManifests); + /** Set the logical operation type (e.g. WRITE, DELETE, MERGE) recorded in the snapshot. */ default BatchTableCommit withOperation(Snapshot.Operation operation) { return this; diff --git a/paimon-core/src/main/java/org/apache/paimon/table/sink/TableCommitImpl.java b/paimon-core/src/main/java/org/apache/paimon/table/sink/TableCommitImpl.java index 014b5e64daa1..a3a010c483b6 100644 --- a/paimon-core/src/main/java/org/apache/paimon/table/sink/TableCommitImpl.java +++ b/paimon-core/src/main/java/org/apache/paimon/table/sink/TableCommitImpl.java @@ -28,6 +28,7 @@ import org.apache.paimon.io.DataFileMeta; import org.apache.paimon.io.DataFilePathFactory; import org.apache.paimon.manifest.ManifestCommittable; +import org.apache.paimon.manifest.ManifestFileMeta; import org.apache.paimon.metrics.MetricRegistry; import org.apache.paimon.operation.FileStoreCommit; import org.apache.paimon.operation.PartitionExpire; @@ -235,6 +236,12 @@ public void compactManifests() { commit.compactManifest(); } + @Override + public void replaceManifests( + List removedManifests, List addedManifests) { + commit.replaceManifest(removedManifests, addedManifests); + } + public boolean rollbackToAsLatest(Tag targetTag) { checkCommitted(); boolean success = commit.rollbackToAsLatest(targetTag.trimToSnapshot()); diff --git a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestEntrySortKeyTest.java b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestEntrySortKeyTest.java new file mode 100644 index 000000000000..02030364c5e1 --- /dev/null +++ b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestEntrySortKeyTest.java @@ -0,0 +1,173 @@ +/* + * 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.manifest; + +import org.apache.paimon.data.BinaryRow; +import org.apache.paimon.data.BinaryRowWriter; +import org.apache.paimon.data.BinaryString; +import org.apache.paimon.types.DataType; +import org.apache.paimon.types.DataTypes; +import org.apache.paimon.types.RowType; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Tests for {@link ManifestEntrySortKey}. */ +public class ManifestEntrySortKeyTest { + + // partition type: (dt INT, region STRING) + private final List partitionFieldTypes = + RowType.of(DataTypes.INT(), DataTypes.STRING()).getFieldTypes(); + + private ManifestEntrySortKey key(int dt, String region, int bucket, int level) { + return key(dt, region, bucket, level, "f1"); + } + + private ManifestEntrySortKey key(int dt, String region, int bucket, int level, String file) { + return new ManifestEntrySortKey( + partition(dt, region), bucket, level, file, partitionFieldTypes); + } + + private BinaryRow partition(int dt, String region) { + BinaryRow row = new BinaryRow(2); + BinaryRowWriter writer = new BinaryRowWriter(row); + writer.writeInt(0, dt); + writer.writeString(1, BinaryString.fromString(region)); + writer.complete(); + return row; + } + + @Test + public void testOrderByPartitionFirst() { + // same bucket & level, different partition -> ordered by partition + assertThat(key(1, "a", 0, 0)).isLessThan(key(2, "a", 0, 0)); + assertThat(key(2, "a", 0, 0)).isGreaterThan(key(1, "a", 0, 0)); + } + + @Test + public void testPartitionComparesAllFields() { + // dt equal, region differs -> ordered by region (second partition field) + assertThat(key(1, "a", 0, 0)).isLessThan(key(1, "b", 0, 0)); + assertThat(key(1, "b", 0, 0)).isGreaterThan(key(1, "a", 0, 0)); + // dt differs -> dt wins regardless of region + assertThat(key(2, "a", 0, 0)).isGreaterThan(key(1, "z", 0, 0)); + } + + @Test + public void testOrderByBucketWhenPartitionEquals() { + assertThat(key(1, "a", 0, 0)).isLessThan(key(1, "a", 1, 0)); + assertThat(key(1, "a", 1, 0)).isGreaterThan(key(1, "a", 0, 0)); + } + + @Test + public void testOrderByLevelWhenPartitionAndBucketEqual() { + assertThat(key(1, "a", 0, 0)).isLessThan(key(1, "a", 0, 1)); + assertThat(key(1, "a", 0, 1)).isGreaterThan(key(1, "a", 0, 0)); + } + + @Test + public void testOrderByFileNameWhenPartitionBucketLevelEqual() { + // same (p,b,l), different fileName -> ordered by fileName + assertThat(key(1, "a", 0, 0, "f1")).isLessThan(key(1, "a", 0, 0, "f2")); + assertThat(key(1, "a", 0, 0, "f2")).isGreaterThan(key(1, "a", 0, 0, "f1")); + } + + @Test + public void testAddAndDeleteShareSameKey() { + // ADD and DELETE of the same file share the same sort key, so they always land in the + // same Spark partition after sortByKey and can be cancelled. + ManifestEntrySortKey add = key(1, "a", 0, 0, "f1"); + ManifestEntrySortKey delete = key(1, "a", 0, 0, "f1"); + assertThat(add.compareTo(delete)).isEqualTo(0); + assertThat(add).isEqualTo(delete); + } + + @Test + public void testGlobalSort() { + // build keys in a deliberately shuffled order + List keys = + new ArrayList<>( + Arrays.asList( + key(2, "a", 1, 0, "f1"), + key(1, "b", 0, 1, "f1"), + key(1, "a", 0, 0, "f2"), + key(1, "a", 0, 0, "f1"), + key(2, "a", 0, 0, "f2"), + key(1, "a", 0, 1, "f1"), + key(2, "a", 0, 0, "f1"), + key(1, "a", 1, 0, "f1"))); + Collections.sort(keys); + + // expected: partition asc -> bucket asc -> level asc -> fileName asc + List expected = + Arrays.asList( + key(1, "a", 0, 0, "f1"), + key(1, "a", 0, 0, "f2"), + key(1, "a", 0, 1, "f1"), + key(1, "a", 1, 0, "f1"), + key(1, "b", 0, 1, "f1"), + key(2, "a", 0, 0, "f1"), + key(2, "a", 0, 0, "f2"), + key(2, "a", 1, 0, "f1")); + assertThat(keys).isEqualTo(expected); + } + + @Test + public void testEqualsAndHashCode() { + // keys with the same partition/bucket/level/fileName are equal even if built from different + // BinaryRow instances, because the partition is compared by serialized bytes + ManifestEntrySortKey a = key(1, "a", 3, 5, "f1"); + ManifestEntrySortKey b = key(1, "a", 3, 5, "f1"); + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + + // any field difference breaks equality + assertThat(a).isNotEqualTo(key(2, "a", 3, 5, "f1")); + assertThat(a).isNotEqualTo(key(1, "b", 3, 5, "f1")); + assertThat(a).isNotEqualTo(key(1, "a", 4, 5, "f1")); + assertThat(a).isNotEqualTo(key(1, "a", 3, 6, "f1")); + assertThat(a).isNotEqualTo(key(1, "a", 3, 5, "f2")); + } + + @Test + public void testSurvivesSerialization() throws Exception { + // the comparator and partition are transient; after Java serialization they must be + // rebuilt lazily and comparison must still work + ManifestEntrySortKey original = key(1, "a", 0, 0, "f1"); + + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + try (java.io.ObjectOutputStream oos = new java.io.ObjectOutputStream(baos)) { + oos.writeObject(original); + } + try (java.io.ObjectInputStream ois = + new java.io.ObjectInputStream( + new java.io.ByteArrayInputStream(baos.toByteArray()))) { + ManifestEntrySortKey roundTripped = (ManifestEntrySortKey) ois.readObject(); + assertThat(roundTripped.compareTo(key(1, "a", 0, 0, "f1"))).isEqualTo(0); + assertThat(roundTripped).isLessThan(key(2, "a", 0, 0, "f1")); + assertThat(roundTripped).isLessThan(key(1, "a", 0, 0, "f2")); + } + } +} diff --git a/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java b/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java index f326be18dcab..abfc2c93db8d 100644 --- a/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java +++ b/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java @@ -50,6 +50,7 @@ import org.apache.paimon.operation.commit.ConflictDetection; import org.apache.paimon.operation.commit.ManifestEntryChanges; import org.apache.paimon.operation.commit.RetryCommitResult; +import org.apache.paimon.options.MemorySize; import org.apache.paimon.predicate.PredicateBuilder; import org.apache.paimon.schema.Schema; import org.apache.paimon.schema.SchemaManager; @@ -98,6 +99,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.atomic.AtomicReference; @@ -2369,4 +2371,102 @@ private void logData(Supplier> supplier, String name) { } LOG.debug("========== End of " + name + " =========="); } + + // --------------------------------------------------------------------------------------------- + // Tests for replaceManifest + // --------------------------------------------------------------------------------------------- + + @Test + public void testReplaceManifestsIdentity() throws Exception { + TestFileStore store = createStore(false, 2); + store.commitData(generateDataList(20), gen::getPartition, kv -> 0); + + Snapshot snapshot = store.snapshotManager().latestSnapshot(); + ManifestList manifestList = store.manifestListFactory().create(); + List manifests = manifestList.readDataManifests(snapshot); + assertThat(manifests).isNotEmpty(); + + Set fileNamesBefore = + manifests.stream().map(ManifestFileMeta::fileName).collect(Collectors.toSet()); + + try (FileStoreCommit commit = store.newCommit()) { + commit.replaceManifest(manifests, manifests); + } + + Snapshot newSnapshot = store.snapshotManager().latestSnapshot(); + assertThat(newSnapshot.id()).isEqualTo(snapshot.id() + 1); + assertThat(newSnapshot.commitKind()).isEqualTo(Snapshot.CommitKind.COMPACT); + List newManifests = manifestList.readDataManifests(newSnapshot); + Set fileNamesAfter = + newManifests.stream().map(ManifestFileMeta::fileName).collect(Collectors.toSet()); + assertThat(fileNamesAfter).isEqualTo(fileNamesBefore); + } + + @Test + public void testReplaceManifestsPreservesConcurrentDelta() throws Exception { + TestFileStore store = createStore(false, 2); + store.commitData(generateDataList(20), gen::getPartition, kv -> 0); + + Snapshot snapshotBefore = store.snapshotManager().latestSnapshot(); + ManifestList manifestList = store.manifestListFactory().create(); + List manifestsBefore = manifestList.readDataManifests(snapshotBefore); + + store.commitData(generateDataList(20), gen::getPartition, kv -> 0); + Snapshot snapshotAfter = store.snapshotManager().latestSnapshot(); + List manifestsAfter = manifestList.readDataManifests(snapshotAfter); + + Set beforeNames = + manifestsBefore.stream() + .map(ManifestFileMeta::fileName) + .collect(Collectors.toSet()); + List deltaManifests = + manifestsAfter.stream() + .filter(m -> !beforeNames.contains(m.fileName())) + .collect(Collectors.toList()); + assertThat(deltaManifests).isNotEmpty(); + + try (FileStoreCommit commit = store.newCommit()) { + commit.replaceManifest(manifestsBefore, manifestsBefore); + } + + Snapshot finalSnapshot = store.snapshotManager().latestSnapshot(); + List finalManifests = manifestList.readDataManifests(finalSnapshot); + Set finalNames = + finalManifests.stream().map(ManifestFileMeta::fileName).collect(Collectors.toSet()); + + for (String name : beforeNames) { + assertThat(finalNames).contains(name); + } + for (ManifestFileMeta delta : deltaManifests) { + assertThat(finalNames).contains(delta.fileName()); + } + } + + @Test + public void testReplaceManifestsThrowsOnConcurrentCompact() throws Exception { + // use a small manifest target size so compactManifest actually rewrites the manifests + // (changes file names), which makes the stale manifestsBefore absent from the current base + Map options = new HashMap<>(); + options.put(CoreOptions.MANIFEST_TARGET_FILE_SIZE.key(), "1KB"); + options.put( + CoreOptions.MANIFEST_FULL_COMPACTION_FILE_SIZE.key(), + MemorySize.ofBytes(1).toString()); + TestFileStore store = createStore(false, 2, CoreOptions.ChangelogProducer.NONE, options); + for (int i = 0; i < 3; i++) { + store.commitData(generateDataList(10), gen::getPartition, kv -> 0); + } + + Snapshot snapshotBefore = store.snapshotManager().latestSnapshot(); + ManifestList manifestList = store.manifestListFactory().create(); + List manifestsBefore = manifestList.readDataManifests(snapshotBefore); + + try (FileStoreCommit commit = store.newCommit()) { + commit.compactManifest(); + } + + try (FileStoreCommit commit = store.newCommit()) { + assertThatThrownBy(() -> commit.replaceManifest(manifestsBefore, manifestsBefore)) + .hasMessageContaining("Manifest conflict"); + } + } } diff --git a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkProcedures.java b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkProcedures.java index 60b5747e3db7..facdbec37753 100644 --- a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkProcedures.java +++ b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/SparkProcedures.java @@ -58,6 +58,7 @@ import org.apache.paimon.spark.procedure.RescaleProcedure; import org.apache.paimon.spark.procedure.ResetConsumerProcedure; import org.apache.paimon.spark.procedure.RewriteFileIndexProcedure; +import org.apache.paimon.spark.procedure.RewriteManifestProcedure; import org.apache.paimon.spark.procedure.RollbackProcedure; import org.apache.paimon.spark.procedure.RollbackToTimestampProcedure; import org.apache.paimon.spark.procedure.RollbackToWatermarkProcedure; @@ -124,6 +125,7 @@ private static Map> initProcedureBuilders() { procedureBuilders.put("reset_consumer", ResetConsumerProcedure::builder); procedureBuilders.put("mark_partition_done", MarkPartitionDoneProcedure::builder); procedureBuilders.put("compact_manifest", CompactManifestProcedure::builder); + procedureBuilders.put("rewrite_manifest", RewriteManifestProcedure::builder); procedureBuilders.put("clear_consumers", ClearConsumersProcedure::builder); procedureBuilders.put("alter_view_dialect", AlterViewDialectProcedure::builder); procedureBuilders.put("create_function", CreateFunctionProcedure::builder); diff --git a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/RewriteManifestProcedure.java b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/RewriteManifestProcedure.java new file mode 100644 index 000000000000..bb2374f1b264 --- /dev/null +++ b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/RewriteManifestProcedure.java @@ -0,0 +1,479 @@ +/* + * 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.spark.procedure; + +import org.apache.paimon.Snapshot; +import org.apache.paimon.fs.Path; +import org.apache.paimon.manifest.FileKind; +import org.apache.paimon.manifest.ManifestEntry; +import org.apache.paimon.manifest.ManifestEntrySerializer; +import org.apache.paimon.manifest.ManifestEntrySortKey; +import org.apache.paimon.manifest.ManifestFile; +import org.apache.paimon.manifest.ManifestFile.ManifestEntryWriter; +import org.apache.paimon.manifest.ManifestFileMeta; +import org.apache.paimon.manifest.ManifestFileMetaSerializer; +import org.apache.paimon.manifest.ManifestList; +import org.apache.paimon.partition.PartitionPredicate; +import org.apache.paimon.predicate.Predicate; +import org.apache.paimon.predicate.PredicateBuilder; +import org.apache.paimon.spark.catalyst.analysis.expressions.ExpressionUtils; +import org.apache.paimon.stats.SimpleStats; +import org.apache.paimon.table.FileStoreTable; +import org.apache.paimon.table.Table; +import org.apache.paimon.table.sink.BatchTableCommit; +import org.apache.paimon.types.DataType; +import org.apache.paimon.types.RowType; +import org.apache.paimon.utils.FileStorePathFactory; +import org.apache.paimon.utils.ProcedureUtils; +import org.apache.paimon.utils.StringUtils; + +import org.apache.spark.api.java.JavaPairRDD; +import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.api.java.function.FlatMapFunction; +import org.apache.spark.api.java.function.PairFlatMapFunction; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.Expression; +import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.stream.Collectors; + +import scala.Tuple2; + +import static org.apache.paimon.utils.Preconditions.checkArgument; +import static org.apache.spark.sql.types.DataTypes.StringType; + +/** + * Rewrite manifest procedure. It reads all manifest entries, sorts them globally by {@code + * partition -> bucket -> level -> fileName} (canceling ADD/DELETE pairs along the way) and writes + * them back as new manifest files so that {@link ManifestFileMeta} statistics become more compact + * for scan pruning. The sort runs distributed via a Spark {@code sortByKey} shuffle. + * + *

An optional {@code where} clause restricts the rewrite to manifests whose partition stats may + * match the predicate; the remaining manifests are left untouched. + * + *


+ *  CALL sys.rewrite_manifest(table => 'tableId')
+ *  CALL sys.rewrite_manifest(table => 'tableId', where => 'dt = "2024-01-01"')
+ * 
+ */ +public class RewriteManifestProcedure extends BaseProcedure { + + private static final ProcedureParameter[] PARAMETERS = + new ProcedureParameter[] { + ProcedureParameter.required("table", StringType), + ProcedureParameter.optional("where", StringType), + ProcedureParameter.optional("options", StringType) + }; + + private static final StructType OUTPUT_TYPE = + new StructType( + new StructField[] { + new StructField( + "rewritten_manifests_count", + DataTypes.IntegerType, + true, + Metadata.empty()), + new StructField( + "added_manifests_count", + DataTypes.IntegerType, + true, + Metadata.empty()) + }); + + protected RewriteManifestProcedure(TableCatalog tableCatalog) { + super(tableCatalog); + } + + @Override + public ProcedureParameter[] parameters() { + return PARAMETERS; + } + + @Override + public StructType outputType() { + return OUTPUT_TYPE; + } + + @Override + public InternalRow[] call(InternalRow args) { + Identifier tableIdent = toIdentifier(args.getString(0), PARAMETERS[0].name()); + String where = args.isNullAt(1) ? null : args.getString(1); + String options = args.isNullAt(2) ? null : args.getString(2); + + Table table = loadSparkTable(tableIdent).getTable(); + HashMap dynamicOptions = new HashMap<>(); + ProcedureUtils.putAllOptions(dynamicOptions, options); + FileStoreTable fileStoreTable = (FileStoreTable) table.copy(dynamicOptions); + + // 1. read the latest snapshot and its data manifests + Snapshot latestSnapshot = fileStoreTable.store().snapshotManager().latestSnapshot(); + if (latestSnapshot == null) { + return new InternalRow[] {newInternalRow(0, 0)}; + } + ManifestList manifestList = fileStoreTable.store().manifestListFactory().create(); + List currentManifests = manifestList.readDataManifests(latestSnapshot); + if (currentManifests.isEmpty()) { + return new InternalRow[] {newInternalRow(0, 0)}; + } + + // 2. filter manifests by the optional where clause (partition-stats pruning) + List manifestsToRewrite = currentManifests; + PartitionPredicate partitionPredicate = resolvePartitionPredicate(tableIdent, table, where); + if (partitionPredicate != null) { + manifestsToRewrite = filterManifests(manifestsToRewrite, partitionPredicate); + if (manifestsToRewrite.isEmpty()) { + return new InternalRow[] {newInternalRow(0, 0)}; + } + } + + // 3. globally sort manifest entries and write them back as new manifest files + List newManifests = rewriteManifests(fileStoreTable, manifestsToRewrite); + + // 4. commit the rewritten manifests (optimistic concurrency with retry) + try (BatchTableCommit commit = fileStoreTable.newBatchWriteBuilder().newCommit()) { + commit.replaceManifests(manifestsToRewrite, newManifests); + } catch (Exception e) { + throw new RuntimeException(e); + } + + // rewritten_manifests_count: number of manifests that were rewritten (input) + // added_manifests_count: number of new manifests produced by the rewrite (output) + int rewrittenCount = manifestsToRewrite.size(); + int addedCount = newManifests.size(); + return new InternalRow[] {newInternalRow(rewrittenCount, addedCount)}; + } + + /** + * Parse the {@code where} SQL string into a {@link PartitionPredicate}, validating that it only + * references partition columns. Returns {@code null} when {@code where} is blank. + */ + private PartitionPredicate resolvePartitionPredicate( + Identifier tableIdent, Table table, String where) { + if (StringUtils.isNullOrWhitespaceOnly(where)) { + return null; + } + FileStoreTable fileStoreTable = (FileStoreTable) table; + DataSourceV2Relation relation = createRelation(tableIdent); + Expression condition = ExpressionUtils.resolveFilter(spark(), relation, where); + checkArgument( + ExpressionUtils.isValidPredicate( + spark(), condition, fileStoreTable.partitionKeys().toArray(new String[0])), + "Only partition predicate is supported, your predicate is %s, but partition keys are %s", + condition, + fileStoreTable.partitionKeys()); + Predicate predicate = + ExpressionUtils.convertConditionToPaimonPredicate( + condition, + ((LogicalPlan) relation).output(), + table.rowType(), + false) + .getOrElse(null); + + // the predicate references fields by their index in the full row type; map them to the + // partition type index so PartitionPredicate can test against partitionStats / BinaryRow + // partitions (which only contain partition columns) + List partitionKeys = fileStoreTable.partitionKeys(); + int[] fieldIdxToPartitionIdx = + fileStoreTable.schema().fields().stream() + .mapToInt(f -> partitionKeys.indexOf(f.name())) + .toArray(); + Predicate partitionPredicate = + PredicateBuilder.transformFieldMapping(predicate, fieldIdxToPartitionIdx) + .orElse(null); + + RowType partitionType = fileStoreTable.store().partitionType(); + return PartitionPredicate.fromPredicate(partitionType, partitionPredicate); + } + + /** Keep only manifests whose partition stats may match the predicate. */ + private List filterManifests( + List manifests, PartitionPredicate predicate) { + return manifests.stream() + .filter( + m -> { + SimpleStats stats = m.partitionStats(); + return predicate.test( + m.numAddedFiles() + m.numDeletedFiles(), + stats.minValues(), + stats.maxValues(), + stats.nullCounts()); + }) + .collect(Collectors.toList()); + } + + private List rewriteManifests( + FileStoreTable table, List currentManifests) { + List partitionFieldTypes = table.store().partitionType().getFieldTypes(); + ManifestFileMetaSerializer metaSerializer = new ManifestFileMetaSerializer(); + + // serialize ManifestFileMeta to byte[] so they can travel through the RDD + List serializedMetas = new ArrayList<>(currentManifests.size()); + for (ManifestFileMeta meta : currentManifests) { + try { + serializedMetas.add(metaSerializer.serializeToBytes(meta)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + int numPartitions = computeNumPartitions(table, currentManifests); + + JavaSparkContext jsc = new JavaSparkContext(spark().sparkContext()); + + // The table is captured by the closure (not broadcast) so that Spark serializes it with + // Java serialization (ClosureCleaner always uses JavaSerializer). This is required because + // the table's FileIO (e.g. HadoopFileIO) holds a SerializableConfiguration whose transient + // Configuration is restored only via Java writeObject/readObject — Kryo (used by broadcast + // when spark.serializer=KryoSerializer) would skip those callbacks and leave a null conf. + FileStoreTable tableRef = table; + + // Step 1: read all entries (ADD and DELETE) and pair them with their sort key + JavaPairRDD entryRDD = + jsc.parallelize(serializedMetas, Math.min(serializedMetas.size(), numPartitions)) + .flatMapToPair( + (PairFlatMapFunction) + manifestBytes -> { + ManifestFileMetaSerializer ser = + new ManifestFileMetaSerializer(); + ManifestFileMeta manifest = + ser.deserializeFromBytes(manifestBytes); + ManifestFile manifestFile = + tableRef.store().manifestFileFactory().create(); + ManifestEntrySerializer entrySer = + new ManifestEntrySerializer(); + + List> pairs = + new ArrayList<>(); + for (ManifestEntry entry : + manifestFile.read( + manifest.fileName(), + manifest.fileSize())) { + ManifestEntrySortKey key = + new ManifestEntrySortKey( + entry.partition(), + entry.bucket(), + entry.file().level(), + entry.fileName(), + partitionFieldTypes); + pairs.add( + new Tuple2<>( + key, + entrySer.serializeToBytes(entry))); + } + return pairs.iterator(); + }); + + // Step 2: global sort. The sort key is (partition, bucket, level, fileName), so + // the ADD and DELETE of the same file are adjacent. + JavaPairRDD sortedRDD = + entryRDD.sortByKey(true, numPartitions); + + // Step 3: within each partition, stream the sorted entries, cancel ADD/DELETE pairs per + // manifest entry, and write surviving entries to a single manifest file. Because the sort + // key + // is (partition, bucket, level, fileName), entries of the same file are consecutive; the + // per-key buffer holds at most one ADD and one DELETE, so memory is negligible. Each task + // produces at most one manifest, sized roughly to the manifest target file size. + List serializedResult = + sortedRDD + .mapPartitions( + (FlatMapFunction< + Iterator>, + byte[]>) + iter -> { + FileStoreTable t = tableRef; + FileStorePathFactory pathFactory = + t.store().pathFactory(); + Path manifestPath = pathFactory.newManifestFile(); + ManifestFile manifestFile = + t.store().manifestFileFactory().create(); + ManifestEntryWriter writer = + manifestFile.createManifestEntryWriter( + manifestPath); + ManifestEntrySerializer entrySer = + new ManifestEntrySerializer(); + + // per-current-key buffer: at most one ADD and one + // DELETE for the same Identifier + ManifestEntrySortKey currentKey = null; + ManifestEntry bufferedAdd = null; + ManifestEntry bufferedDelete = null; + try { + while (iter.hasNext()) { + Tuple2 pair = + iter.next(); + ManifestEntrySortKey key = pair._1; + ManifestEntry entry = + entrySer.deserializeFromBytes(pair._2); + + if (currentKey != null + && currentKey.compareTo(key) != 0) { + // key changed: flush the previous group + ManifestEntry survived = + mergeGroup( + bufferedAdd, + bufferedDelete); + if (survived != null) { + writer.write(survived); + } + bufferedAdd = null; + bufferedDelete = null; + } + + currentKey = key; + if (entry.kind() == FileKind.ADD) { + if (bufferedAdd != null) { + throw new IllegalStateException( + "Duplicate ADD entry for " + + entry.identifier()); + } + bufferedAdd = entry; + } else { + if (bufferedDelete != null) { + throw new IllegalStateException( + "Duplicate DELETE entry for " + + entry.identifier()); + } + bufferedDelete = entry; + } + } + + // flush the last group + if (currentKey != null) { + ManifestEntry survived = + mergeGroup(bufferedAdd, bufferedDelete); + if (survived != null) { + writer.write(survived); + } + } + } finally { + writer.close(); + } + + if (writer.recordCount() == 0) { + // nothing survived — delete the empty file and + // emit nothing + manifestFile.delete(writer.path().getName()); + return Collections.emptyList().iterator(); + } + ManifestFileMeta newMeta = writer.result(); + ManifestFileMetaSerializer ser = + new ManifestFileMetaSerializer(); + return Collections.singletonList( + ser.serializeToBytes(newMeta)) + .iterator(); + }) + .collect(); + + List newManifests = new ArrayList<>(serializedResult.size()); + for (byte[] bytes : serializedResult) { + try { + newManifests.add(metaSerializer.deserializeFromBytes(bytes)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + return newManifests; + } + + /** + * Merge the ADD and DELETE entries of the same file (same Identifier) in an order-independent + * way. Returns the surviving ADD if only an ADD is present, {@code null} if the ADD and DELETE + * cancel each other out, or the DELETE if only a DELETE is present (kept so it can match an ADD + * in a previous manifest). + */ + private static ManifestEntry mergeGroup(ManifestEntry addEntry, ManifestEntry deleteEntry) { + if (addEntry != null && deleteEntry != null) { + // ADD + DELETE cancel out + return null; + } + if (addEntry != null) { + return addEntry; + } + return deleteEntry; + } + + /** + * Estimate the number of output manifests (and thus the sort parallelism) from the input + * manifests. The surviving entry count is {@code added - deleted} (ADD/DELETE pairs cancel), + * the average entry size is {@code totalSize / (added + deleted)}, and the estimated output + * size is {@code avgEntrySize * survivingEntries}. The parallelism is the floor of that over + * the manifest target file size, so each task produces roughly one manifest of the target size. + */ + private int computeNumPartitions(FileStoreTable table, List manifests) { + long targetSizeBytes = table.coreOptions().manifestTargetSize().getBytes(); + long totalSizeBytes = 0L; + long addedEntries = 0L; + long deletedEntries = 0L; + for (ManifestFileMeta manifest : manifests) { + totalSizeBytes += manifest.fileSize(); + addedEntries += manifest.numAddedFiles(); + deletedEntries += manifest.numDeletedFiles(); + } + + if (totalSizeBytes <= 0) { + throw new IllegalStateException( + "Cannot compute parallelism: total manifest size is " + totalSizeBytes); + } + long totalEntries = addedEntries + deletedEntries; + if (totalEntries <= 0) { + throw new IllegalStateException( + "Cannot compute parallelism: total manifest entries is " + totalEntries); + } + long survivingEntries = addedEntries - deletedEntries; + if (survivingEntries < 0) { + throw new IllegalStateException( + "Cannot compute parallelism: surviving entries (added - deleted) is " + + survivingEntries); + } + double avgEntrySizeBytes = (double) totalSizeBytes / totalEntries; + long estimatedOutputSizeBytes = (long) (avgEntrySizeBytes * survivingEntries); + // floor division: prefer fewer, slightly-over-target manifests over more, under-target + // ones. For example, 35M estimated with 8M target -> 4 manifests (~8.75M each), not 5 + // (~7M each). At least one task is always produced. + return (int) Math.max(1, estimatedOutputSizeBytes / targetSizeBytes); + } + + @Override + public String description() { + return "This procedure rewrites and globally sorts manifest entries."; + } + + public static ProcedureBuilder builder() { + return new Builder() { + @Override + public RewriteManifestProcedure doBuild() { + return new RewriteManifestProcedure(tableCatalog()); + } + }; + } +} diff --git a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/RewriteManifestProcedureTest.scala b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/RewriteManifestProcedureTest.scala new file mode 100644 index 000000000000..43c066d1634c --- /dev/null +++ b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/RewriteManifestProcedureTest.scala @@ -0,0 +1,364 @@ +/* + * 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.spark.procedure + +import org.apache.paimon.spark.PaimonSparkTestBase + +import org.apache.spark.sql.streaming.StreamTest +import org.assertj.core.api.Assertions + +import scala.jdk.CollectionConverters._ + +/** Test rewrite manifest procedure. See [[RewriteManifestProcedure]]. */ +class RewriteManifestProcedureTest extends PaimonSparkTestBase with StreamTest { + + test("Paimon Procedure: rewrite manifest cleans delete entries and keeps data intact") { + // A small manifest target file size forces several manifest files, so that the global sort + // actually reshuffles entries across files. + spark.sql(s""" + |CREATE TABLE T (id INT, value STRING, dt STRING) + |TBLPROPERTIES ( + | 'bucket' = '2', + | 'bucket-key' = 'id', + | 'write-only' = 'true', + | 'file.format' = 'avro', + | 'manifest.target-file-size' = '1KB' + |) + |PARTITIONED BY (dt) + |""".stripMargin) + + // Insert partitions in non-sorted order across multiple commits so that manifest entries are + // interleaved and out of partition order. + spark.sql(s"INSERT INTO T VALUES (1, 'a', '2024-01-03')") + spark.sql(s"INSERT INTO T VALUES (2, 'b', '2024-01-01')") + spark.sql(s"INSERT INTO T VALUES (3, 'c', '2024-01-02')") + spark.sql(s"INSERT INTO T VALUES (4, 'd', '2024-01-01')") + spark.sql(s"INSERT INTO T VALUES (5, 'e', '2024-01-03')") + + // Partition-level overwrites produce ADD/DELETE pairs that rewrite_manifest should cancel, + // without dropping the other partitions. + spark.sql(s"INSERT OVERWRITE T PARTITION (dt = '2024-01-03') VALUES (1, 'a2')") + spark.sql(s"INSERT OVERWRITE T PARTITION (dt = '2024-01-01') VALUES (2, 'b2'), (4, 'd2')") + + Thread.sleep(10000) + + val expectedCount = spark.sql("SELECT count(*) FROM T").collectAsList().get(0).getLong(0) + val expectedIdSum = spark.sql("SELECT sum(id) FROM T").collectAsList().get(0).getLong(0) + + // before rewrite there should be some delete entries + val beforeDeleted = + spark + .sql("SELECT sum(num_deleted_files) FROM `T$manifests`") + .collectAsList() + .get(0) + .getLong(0) + Assertions.assertThat(beforeDeleted).isGreaterThan(0L) + + val beforeManifests = + spark.sql("SELECT count(*) FROM `T$manifests`").collectAsList().get(0).getLong(0) + + val result = + spark.sql("CALL sys.rewrite_manifest(table => 'T')").collectAsList().get(0) + + // rewritten_manifests_count == before (all manifests rewritten without where) + // added_manifests_count > 0 (new manifests produced) + val rewrittenCount = result.getInt(0) + val addedCount = result.getInt(1) + Assertions.assertThat(rewrittenCount).isEqualTo(beforeManifests.toInt) + Assertions.assertThat(addedCount).isGreaterThan(0) + + // after rewrite all delete entries must be cleaned + val afterDeleted = + spark + .sql("SELECT sum(num_deleted_files) FROM `T$manifests`") + .collectAsList() + .get(0) + .getLong(0) + Assertions.assertThat(afterDeleted).isEqualTo(0L) + + // data must be intact + Assertions + .assertThat(spark.sql("SELECT count(*) FROM T").collectAsList().get(0).getLong(0)) + .isEqualTo(expectedCount) + Assertions + .assertThat(spark.sql("SELECT sum(id) FROM T").collectAsList().get(0).getLong(0)) + .isEqualTo(expectedIdSum) + } + + test("Paimon Procedure: rewrite manifest orders manifest partitions globally") { + spark.sql(s""" + |CREATE TABLE T2 (id INT, dt STRING) + |TBLPROPERTIES ( + | 'bucket' = '-1', + | 'write-only' = 'true', + | 'manifest.target-file-size' = '1KB', + | 'file.format' = 'avro' + |) + |PARTITIONED BY (dt) + |""".stripMargin) + + // insert partitions in reverse order across commits + spark.sql(s"INSERT INTO T2 VALUES (1, '2024-01-03')") + spark.sql(s"INSERT INTO T2 VALUES (2, '2024-01-02')") + spark.sql(s"INSERT INTO T2 VALUES (3, '2024-01-01')") + spark.sql(s"INSERT INTO T2 VALUES (4, '2024-01-03')") + spark.sql(s"INSERT INTO T2 VALUES (5, '2024-01-01')") + + Thread.sleep(10000) + + val expectedCount = spark.sql("SELECT count(*) FROM T2").collectAsList().get(0).getLong(0) + + spark.sql("CALL sys.rewrite_manifest(table => 'T2')") + + // After global sort, manifest partition ranges must not overlap: sorting manifests by their + // min partition must yield a sequence where each manifest's min is >= the previous manifest's + // max. This is robust regardless of the row order Spark returns for the system table. + val ranges = spark + .sql("SELECT min_partition_stats, max_partition_stats FROM `T2$manifests`") + .collectAsList() + .asScala + .filter(r => !r.isNullAt(0)) + .map(r => (r.getString(0), r.getString(1))) + .sortBy(_._1) + + for (i <- 1 until ranges.length) { + Assertions.assertThat(ranges(i)._1.compareTo(ranges(i - 1)._2) >= 0).isTrue + } + + // data intact + Assertions + .assertThat(spark.sql("SELECT count(*) FROM T2").collectAsList().get(0).getLong(0)) + .isEqualTo(expectedCount) + } + + test("Paimon Procedure: rewrite manifest with where only rewrites matching manifests") { + spark.sql(s""" + |CREATE TABLE T3 (id INT, dt STRING) + |TBLPROPERTIES ( + | 'bucket' = '-1', + | 'write-only' = 'true', + | 'manifest.target-file-size' = '1KB' + |) + |PARTITIONED BY (dt) + |""".stripMargin) + + // insert into multiple partitions across commits + spark.sql(s"INSERT INTO T3 VALUES (1, '2024-01-01')") + spark.sql(s"INSERT INTO T3 VALUES (2, '2024-01-02')") + spark.sql(s"INSERT INTO T3 VALUES (3, '2024-01-03')") + spark.sql(s"INSERT INTO T3 VALUES (4, '2024-01-01')") + + Thread.sleep(10000) + + val expectedCount = spark.sql("SELECT count(*) FROM T3").collectAsList().get(0).getLong(0) + + // record manifest file names before rewrite + val allManifestsBefore = + spark.sql("SELECT file_name FROM `T3$manifests`").collectAsList().asScala.map(_.getString(0)) + + // rewrite only manifests that may match dt = '2024-01-01' + spark.sql("CALL sys.rewrite_manifest(table => 'T3', where => 'dt = \"2024-01-01\"')") + + val allManifestsAfter = + spark.sql("SELECT file_name FROM `T3$manifests`").collectAsList().asScala.map(_.getString(0)) + + // some manifests should have been rewritten (new file names appear) + val newManifests = allManifestsAfter.filter(!allManifestsBefore.contains(_)) + Assertions.assertThat(newManifests.nonEmpty).isTrue + + // but not all manifests are rewritten — at least one original manifest survives + val survivingOriginals = allManifestsBefore.filter(allManifestsAfter.contains(_)) + Assertions.assertThat(survivingOriginals.nonEmpty).isTrue + + // data intact + Assertions + .assertThat(spark.sql("SELECT count(*) FROM T3").collectAsList().get(0).getLong(0)) + .isEqualTo(expectedCount) + Assertions + .assertThat( + spark + .sql("SELECT count(*) FROM T3 WHERE dt = '2024-01-01'") + .collectAsList() + .get(0) + .getLong(0)) + .isEqualTo(2) + } + + test("Paimon Procedure: rewrite manifest with where matching nothing returns zero") { + spark.sql(s""" + |CREATE TABLE T4 (id INT, dt STRING) + |TBLPROPERTIES ( + | 'bucket' = '-1', + | 'write-only' = 'true', + | 'manifest.target-file-size' = '1KB', + | 'file.format' = 'avro' + |) + |PARTITIONED BY (dt) + |""".stripMargin) + + spark.sql(s"INSERT INTO T4 VALUES (1, '2024-01-01')") + spark.sql(s"INSERT INTO T4 VALUES (2, '2024-01-02')") + Thread.sleep(10000) + + val manifestsBefore = + spark.sql("SELECT file_name FROM `T4$manifests`").collectAsList().asScala.map(_.getString(0)) + + // where matches no partition — nothing should be rewritten + val result = + spark + .sql("CALL sys.rewrite_manifest(table => 'T4', where => 'dt = \"1999-01-01\"')") + .collectAsList() + .get(0) + Assertions.assertThat(result.getInt(0)).isEqualTo(0) + Assertions.assertThat(result.getInt(1)).isEqualTo(0) + + // all original manifests survive untouched + val manifestsAfter = + spark.sql("SELECT file_name FROM `T4$manifests`").collectAsList().asScala.map(_.getString(0)) + Assertions.assertThat(manifestsAfter).isEqualTo(manifestsBefore) + + // data intact + Assertions + .assertThat(spark.sql("SELECT count(*) FROM T4").collectAsList().get(0).getLong(0)) + .isEqualTo(2) + } + + test("Paimon Procedure: rewrite manifest with range where rewrites matching partitions") { + spark.sql(s""" + |CREATE TABLE T5 (id INT, dt STRING) + |TBLPROPERTIES ( + | 'bucket' = '-1', + | 'write-only' = 'true', + | 'manifest.target-file-size' = '1KB', + | 'file.format' = 'avro' + |) + |PARTITIONED BY (dt) + |""".stripMargin) + + spark.sql(s"INSERT INTO T5 VALUES (1, '2024-01-01')") + spark.sql(s"INSERT INTO T5 VALUES (2, '2024-01-02')") + spark.sql(s"INSERT INTO T5 VALUES (3, '2024-01-03')") + spark.sql(s"INSERT INTO T5 VALUES (4, '2024-01-04')") + Thread.sleep(10000) + + val expectedCount = spark.sql("SELECT count(*) FROM T5").collectAsList().get(0).getLong(0) + + // range where: only Jan 1-2 + spark.sql( + "CALL sys.rewrite_manifest(table => 'T5', where => 'dt >= \"2024-01-01\" AND dt <= \"2024-01-02\"')") + + // data intact across all partitions + Assertions + .assertThat(spark.sql("SELECT count(*) FROM T5").collectAsList().get(0).getLong(0)) + .isEqualTo(expectedCount) + Assertions + .assertThat( + spark + .sql("SELECT count(*) FROM T5 WHERE dt >= '2024-01-01' AND dt <= '2024-01-02'") + .collectAsList() + .get(0) + .getLong(0)) + .isEqualTo(2) + } + + test("Paimon Procedure: rewrite manifest on unpartitioned table") { + spark.sql(s""" + |CREATE TABLE T6 (id INT, value STRING) + |TBLPROPERTIES ( + | 'bucket' = '-1', + | 'write-only' = 'true', + | 'manifest.target-file-size' = '1KB', + | 'file.format' = 'avro' + |) + |""".stripMargin) + + spark.sql(s"INSERT INTO T6 VALUES (1, 'a')") + spark.sql(s"INSERT INTO T6 VALUES (2, 'b')") + Thread.sleep(10000) + + val expectedCount = spark.sql("SELECT count(*) FROM T6").collectAsList().get(0).getLong(0) + + spark.sql("CALL sys.rewrite_manifest(table => 'T6')") + + // data intact + Assertions + .assertThat(spark.sql("SELECT count(*) FROM T6").collectAsList().get(0).getLong(0)) + .isEqualTo(expectedCount) + } + + test("Paimon Procedure: rewrite manifest with non-partition where throws") { + spark.sql(s""" + |CREATE TABLE T7 (id INT, dt STRING) + |TBLPROPERTIES ( + | 'bucket' = '-1', + | 'write-only' = 'true', + | 'manifest.target-file-size' = '1KB', + | 'file.format' = 'avro' + |) + |PARTITIONED BY (dt) + |""".stripMargin) + + spark.sql(s"INSERT INTO T7 VALUES (1, '2024-01-01')") + Thread.sleep(10000) + + // where on a non-partition column (id) must fail + Assertions + .assertThatThrownBy( + () => spark.sql("CALL sys.rewrite_manifest(table => 'T7', where => 'id = 1')")) + .hasMessageContaining("Only partition predicate is supported") + } + + test("Paimon Procedure: rewritten manifest sizes are within the target bound") { + // Use a small target file size and enough data so that multiple manifests are produced. + // Each task writes a single non-rolling manifest, so every output manifest should be at most + // a modest multiple of the target size (allowing headroom for a single partition that + // slightly exceeds the range estimate). + val targetSize = 1024L + spark.sql(s""" + |CREATE TABLE T8 (id INT, value STRING, dt STRING) + |TBLPROPERTIES ( + | 'bucket' = '4', + | 'bucket-key' = 'id', + | 'write-only' = 'true', + | 'file.format' = 'avro', + | 'manifest.target-file-size' = '${targetSize}B' + |) + |PARTITIONED BY (dt) + |""".stripMargin) + + // Insert enough rows across enough partitions to produce multiple output manifests. + for (dt <- 0 until 20) { + val values = (0 until 20).map(i => s"(${dt * 20 + i}, '${"x" * 50}', '2024-01-${dt + 1}')") + spark.sql(s"INSERT INTO T8 VALUES ${values.mkString(", ")}") + } + Thread.sleep(10000) + + spark.sql("CALL sys.rewrite_manifest(table => 'T8')") + + val sizes = + spark.sql("SELECT file_size FROM `T8$manifests`").collectAsList().asScala.map(_.getLong(0)) + Assertions.assertThat(sizes.nonEmpty).isTrue + // Every rewritten manifest should be <= 3x target size (generous bound for the last manifest + // of a task and range-partitioner skew). The key invariant: no manifest should be wildly + // oversized (e.g. 10x), which would indicate the parallelism estimate is wrong. + for (size <- sizes) { + Assertions.assertThat(size).isLessThanOrEqualTo(targetSize * 3) + } + } +}