JingsongLi commented on code in PR #9195:
URL: https://github.com/apache/paimon/pull/9195#discussion_r3818211025


##########
paimon-core/src/main/java/org/apache/paimon/table/sink/BatchTableCommit.java:
##########
@@ -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(

Review Comment:
   [P1] Keep the new operation out of the abstract public API
   
   `BatchTableCommit` is annotated `@Public`; adding a new abstract method 
makes every external implementation source-incompatible, and binaries compiled 
against the old interface can fail with `AbstractMethodError` if this operation 
reaches them. This procedure only needs Paimon’s internal commit 
implementation. Please expose it through an internal interface/type, or at 
minimum make this a default method that throws `UnsupportedOperationException` 
so existing public implementations remain compatible.



##########
paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/RewriteManifestProcedure.java:
##########
@@ -0,0 +1,478 @@
+/*
+ * 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.ManifestAvroWriter;
+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.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.
+ *
+ * <p>An optional {@code where} clause restricts the rewrite to manifests 
whose partition stats may
+ * match the predicate; the remaining manifests are left untouched.
+ *
+ * <pre><code>
+ *  CALL sys.rewrite_manifest(table => 'tableId')
+ *  CALL sys.rewrite_manifest(table => 'tableId', where => 'dt = "2024-01-01"')
+ * </code></pre>
+ */
+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<String, String> 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<ManifestFileMeta> 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<ManifestFileMeta> 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<ManifestFileMeta> 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<String> 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<ManifestFileMeta> filterManifests(
+            List<ManifestFileMeta> 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<ManifestFileMeta> rewriteManifests(
+            FileStoreTable table, List<ManifestFileMeta> currentManifests) {
+        List<DataType> partitionFieldTypes = 
table.store().partitionType().getFieldTypes();
+        ManifestFileMetaSerializer metaSerializer = new 
ManifestFileMetaSerializer();
+
+        // serialize ManifestFileMeta to byte[] so they can travel through the 
RDD
+        List<byte[]> 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<ManifestEntrySortKey, byte[]> entryRDD =
+                jsc.parallelize(serializedMetas, 
Math.min(serializedMetas.size(), numPartitions))
+                        .flatMapToPair(
+                                (PairFlatMapFunction<byte[], 
ManifestEntrySortKey, byte[]>)
+                                        manifestBytes -> {
+                                            ManifestFileMetaSerializer ser =
+                                                    new 
ManifestFileMetaSerializer();
+                                            ManifestFileMeta manifest =
+                                                    
ser.deserializeFromBytes(manifestBytes);
+                                            ManifestFile manifestFile =
+                                                    
tableRef.store().manifestFileFactory().create();
+                                            ManifestEntrySerializer entrySer =
+                                                    new 
ManifestEntrySerializer();
+
+                                            List<Tuple2<ManifestEntrySortKey, 
byte[]>> 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<ManifestEntrySortKey, byte[]> 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<byte[]> serializedResult =
+                sortedRDD
+                        .mapPartitions(
+                                (FlatMapFunction<
+                                                
Iterator<Tuple2<ManifestEntrySortKey, byte[]>>,
+                                                byte[]>)
+                                        iter -> {
+                                            FileStoreTable t = tableRef;
+                                            FileStorePathFactory pathFactory =
+                                                    t.store().pathFactory();
+                                            Path manifestPath = 
pathFactory.newManifestFile();

Review Comment:
   [P2] Make task-attempt manifest output abortable
   
   Each Spark task writes its final manifest directly into the table metadata 
directory. If a later partition fails, `collect()` throws before the driver 
receives `newManifests`, so all manifests already completed by successful 
partitions are leaked. A retried/speculative attempt can likewise create a 
second file while only one attempt’s metadata is returned. This can leak a full 
rewrite’s worth of manifests on every failed run. Please use task-attempt 
temporary output plus an attempt commit/abort protocol (and clean all produced 
paths when the stage/commit fails), and add a test that injects a failure after 
another partition has finished writing.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to