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 c96f22b792 [spark] Support compact_chain_table procedure (#7313)
c96f22b792 is described below

commit c96f22b79210644f4f891996802f6dc0e8ead75d
Author: Juntao Zhang <[email protected]>
AuthorDate: Tue Jun 23 19:08:06 2026 +0800

    [spark] Support compact_chain_table procedure (#7313)
---
 docs/docs/primary-key-table/chain-table.md         |  63 +++-
 docs/docs/spark/procedures.md                      |  12 +
 .../apache/paimon/table/ChainGroupReadTable.java   |  55 +--
 .../org/apache/paimon/spark/SparkProcedures.java   |   2 +
 .../procedure/CompactChainTableProcedure.java      | 220 ++++++++++++
 .../paimon/spark/utils/SparkProcedureUtils.java    |  31 ++
 .../procedure/CompactChainTableProcedureTest.scala | 394 +++++++++++++++++++++
 7 files changed, 738 insertions(+), 39 deletions(-)

diff --git a/docs/docs/primary-key-table/chain-table.md 
b/docs/docs/primary-key-table/chain-table.md
index a861fb12e6..0cb2984b4c 100644
--- a/docs/docs/primary-key-table/chain-table.md
+++ b/docs/docs/primary-key-table/chain-table.md
@@ -130,7 +130,6 @@ Notice that:
 - Chain table is only supported for primary key table, which means you should 
define `bucket` and `bucket-key` for the table.
 - Chain table should ensure that the schema of each branch is consistent.
 - Both Spark and Flink batch read/write are supported. Flink streaming 
read/write is not supported.
-- Chain compact is not supported for now, and it will be supported later.
 - Deletion vector is not supported for chain table.
 
 After creating a chain table, you can read and write data in the following 
ways.
@@ -153,12 +152,12 @@ select t1, t2, t3 from default.t where date = '20250811'
 ```
 you will get the following result:
 ```text
-+---+----+-----+ 
-| t1|  t2|   t3| 
-+---+----+-----+ 
-| 1 |   1|   1 |           
-| 2 |   1|   1 |               
-+---+----+-----+ 
++---+----+-----+
+| t1|  t2|   t3|
++---+----+-----+
+| 1 |   1|   1 |
+| 2 |   1|   1 |
++---+----+-----+
 ```
 
 - Incremental Query: Read the incremental partition from t$branch_delta
@@ -167,11 +166,11 @@ select t1, t2, t3 from `default`.`t$branch_delta` where 
date = '20250811'
 ```
 you will get the following result:
 ```text
-+---+----+-----+ 
-| t1|  t2|   t3| 
-+---+----+-----+      
-| 2 |   1|   1 |               
-+---+----+-----+ 
++---+----+-----+
+| t1|  t2|   t3|
++---+----+-----+
+| 2 |   1|   1 |
++---+----+-----+
 ```
 
 - Hybrid Query: Read both full and incremental data simultaneously.
@@ -182,13 +181,39 @@ select t1, t2, t3 from `default`.`t$branch_delta` where 
date = '20250811'
 ```
 you will get the following result:
 ```text
-+---+----+-----+ 
-| t1|  t2|   t3| 
-+---+----+-----+ 
-| 1 |   1|   1 |           
-| 2 |   1|   1 |  
-| 2 |   1|   1 |               
-+---+----+-----+ 
++---+----+-----+
+| t1|  t2|   t3|
++---+----+-----+
+| 1 |   1|   1 |
+| 2 |   1|   1 |
+| 2 |   1|   1 |
++---+----+-----+
+```
+
+- Chain Table Compaction: Merge data from snapshot and delta branches into the 
snapshot branch.
+  This is useful for periodically compacting incremental data into full 
snapshots.
+  You can use the `compact_chain_table` procedure to merge a specific 
partition:
+
+```sql
+CALL sys.compact_chain_table(table => 'default.t', partition => 
'date="20250811"');
+```
+
+After compaction, the data in the snapshot branch will contain the merged 
result from both snapshot
+and delta branches, and subsequent queries will benefit from direct snapshot 
access without
+merge-on-read overhead.
+
+```sql
+select t1, t2, t3 from `default`.`t$branch_snapshot` where date = '20250811';
+```
+
+you will get the following result:
+```text
++---+----+-----+
+| t1|  t2|   t3|
++---+----+-----+
+| 1 |   1|   1 |
+| 2 |   1|   1 |
++---+----+-----+
 ```
 
 ## Group Partition
diff --git a/docs/docs/spark/procedures.md b/docs/docs/spark/procedures.md
index b264c49fc2..ed55504d07 100644
--- a/docs/docs/spark/procedures.md
+++ b/docs/docs/spark/procedures.md
@@ -79,6 +79,18 @@ This section introduce all available spark procedures about 
paimon.
          CALL sys.compact_database(including_databases => 'db1', options => 
'target-file-size=128m')
       </td>
     </tr>
+    <tr>
+      <td>compact_chain_table</td>
+      <td>
+         To compact chain table by merging snapshot and delta branches into 
the snapshot branch. Arguments:
+            <li>table: The target chain table identifier. Cannot be empty.</li>
+            <li>partition: Partition specification format (e.g., 
'dt="20250810",hour="22"'). Cannot be empty.</li>
+            <li>overwrite: Whether to overwrite if the partition already 
exists in the snapshot branch. Default is false. Optional.</li>
+      </td>
+      <td>
+         CALL sys.compact_chain_table(table => 'default.T', partition => 
'dt="20250810",hour="22"')<br/><br/>
+      </td>
+    </tr>
     <tr>
       <td>expire_snapshots</td>
       <td>
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/ChainGroupReadTable.java 
b/paimon-core/src/main/java/org/apache/paimon/table/ChainGroupReadTable.java
index 75ba5f4097..95a591ebb4 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/ChainGroupReadTable.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/ChainGroupReadTable.java
@@ -128,6 +128,7 @@ public class ChainGroupReadTable extends 
FallbackReadFileStoreTable {
         private final ChainPartitionProjector partitionProjector;
         private Predicate dataPredicate;
         private Filter<Integer> bucketFilter;
+        protected boolean preloadTargetSnapshot = true;
 
         public ChainTableBatchScan(
                 TableSchema tableSchema, ChainGroupReadTable 
chainGroupReadTable) {
@@ -210,6 +211,11 @@ public class ChainGroupReadTable extends 
FallbackReadFileStoreTable {
             return this;
         }
 
+        public FallbackReadScan skipPreloadTargetSnapshot() {
+            this.preloadTargetSnapshot = false;
+            return this;
+        }
+
         /**
          * Builds a plan for chain tables.
          *
@@ -237,26 +243,7 @@ public class ChainGroupReadTable extends 
FallbackReadFileStoreTable {
         public Plan plan() {
             List<Split> splits = new ArrayList<>();
             PredicateBuilder builder = new 
PredicateBuilder(tableSchema.logicalPartitionType());
-            for (Split split : mainScan.plan().splits()) {
-                DataSplit dataSplit = (DataSplit) split;
-                HashMap<String, String> fileBucketPathMapping = new 
HashMap<>();
-                HashMap<String, String> fileBranchMapping = new HashMap<>();
-                for (DataFileMeta file : dataSplit.dataFiles()) {
-                    fileBucketPathMapping.put(file.fileName(), ((DataSplit) 
split).bucketPath());
-                    fileBranchMapping.put(file.fileName(), 
options.scanFallbackSnapshotBranch());
-                }
-                splits.add(
-                        new ChainSplit(
-                                dataSplit.partition(),
-                                dataSplit.dataFiles(),
-                                fileBranchMapping,
-                                fileBucketPathMapping));
-            }
-
-            Set<BinaryRow> snapshotPartitions =
-                    new HashSet<>(
-                            newChainPartitionListingScan(true, 
getMainPartitionPredicate())
-                                    .listPartitions());
+            Set<BinaryRow> snapshotPartitions = 
preloadTargetSnapshotSplits(splits);
 
             DataTableScan deltaPartitionScan =
                     newChainPartitionListingScan(false, 
getFallbackPartitionPredicate());
@@ -453,6 +440,34 @@ public class ChainGroupReadTable extends 
FallbackReadFileStoreTable {
             return scan;
         }
 
+        private Set<BinaryRow> preloadTargetSnapshotSplits(List<Split> splits) 
{
+            Set<BinaryRow> snapshotPartitions = new HashSet<>();
+            if (!preloadTargetSnapshot) {
+                return snapshotPartitions;
+            }
+
+            for (Split split : mainScan.plan().splits()) {
+                DataSplit dataSplit = (DataSplit) split;
+                HashMap<String, String> fileBucketPathMapping = new 
HashMap<>();
+                HashMap<String, String> fileBranchMapping = new HashMap<>();
+                for (DataFileMeta file : dataSplit.dataFiles()) {
+                    fileBucketPathMapping.put(file.fileName(), ((DataSplit) 
split).bucketPath());
+                    fileBranchMapping.put(file.fileName(), 
options.scanFallbackSnapshotBranch());
+                }
+                splits.add(
+                        new ChainSplit(
+                                dataSplit.partition(),
+                                dataSplit.dataFiles(),
+                                fileBranchMapping,
+                                fileBucketPathMapping));
+            }
+
+            snapshotPartitions.addAll(
+                    newChainPartitionListingScan(true, 
getMainPartitionPredicate())
+                            .listPartitions());
+            return snapshotPartitions;
+        }
+
         private DataTableScan newFilteredScan(boolean snapshot) {
             DataTableScan scan =
                     snapshot
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 ad0f2a93b7..6a2203b9cb 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
@@ -21,6 +21,7 @@ package org.apache.paimon.spark;
 import org.apache.paimon.spark.procedure.AlterFunctionProcedure;
 import org.apache.paimon.spark.procedure.AlterViewDialectProcedure;
 import org.apache.paimon.spark.procedure.ClearConsumersProcedure;
+import org.apache.paimon.spark.procedure.CompactChainTableProcedure;
 import org.apache.paimon.spark.procedure.CompactDatabaseProcedure;
 import org.apache.paimon.spark.procedure.CompactManifestProcedure;
 import org.apache.paimon.spark.procedure.CompactProcedure;
@@ -103,6 +104,7 @@ public class SparkProcedures {
         procedureBuilders.put("rename_branch", RenameBranchProcedure::builder);
         procedureBuilders.put("compact", CompactProcedure::builder);
         procedureBuilders.put("compact_database", 
CompactDatabaseProcedure::builder);
+        procedureBuilders.put("compact_chain_table", 
CompactChainTableProcedure::builder);
         procedureBuilders.put("rescale", RescaleProcedure::builder);
         procedureBuilders.put("migrate_database", 
MigrateDatabaseProcedure::builder);
         procedureBuilders.put("migrate_table", MigrateTableProcedure::builder);
diff --git 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/CompactChainTableProcedure.java
 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/CompactChainTableProcedure.java
new file mode 100644
index 0000000000..ad992cf505
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/CompactChainTableProcedure.java
@@ -0,0 +1,220 @@
+/*
+ * 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.CoreOptions;
+import org.apache.paimon.partition.PartitionPredicate;
+import org.apache.paimon.spark.commands.PaimonSparkWriter;
+import org.apache.paimon.spark.util.ScanPlanHelper$;
+import org.apache.paimon.spark.utils.SparkProcedureUtils;
+import org.apache.paimon.table.ChainGroupReadTable;
+import org.apache.paimon.table.FallbackReadFileStoreTable;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.Split;
+import org.apache.paimon.utils.ParameterUtils;
+import org.apache.paimon.utils.StringUtils;
+
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.PaimonUtils;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.catalyst.InternalRow;
+import org.apache.spark.sql.connector.catalog.Identifier;
+import org.apache.spark.sql.connector.catalog.TableCatalog;
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation;
+import org.apache.spark.sql.functions;
+import org.apache.spark.sql.types.Metadata;
+import org.apache.spark.sql.types.StructField;
+import org.apache.spark.sql.types.StructType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+import static org.apache.spark.sql.types.DataTypes.BooleanType;
+import static org.apache.spark.sql.types.DataTypes.StringType;
+
+/**
+ * Procedure to compact chain table. Usage:
+ *
+ * <pre><code>
+ *  -- Compact chain table, overwrite default is false
+ *  CALL sys.compact_chain_table(table => 'db.table', partition => 
'dt="20250810",hour="22"', [overwrite => true])
+ * </code></pre>
+ */
+public class CompactChainTableProcedure extends BaseProcedure {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(CompactChainTableProcedure.class);
+
+    private static final ProcedureParameter[] PARAMETERS =
+            new ProcedureParameter[] {
+                ProcedureParameter.required("table", StringType),
+                ProcedureParameter.required("partition", StringType),
+                ProcedureParameter.optional("overwrite", BooleanType)
+            };
+
+    private static final StructType OUTPUT_TYPE =
+            new StructType(
+                    new StructField[] {
+                        new StructField("result", BooleanType, false, 
Metadata.empty())
+                    });
+
+    protected CompactChainTableProcedure(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 partitionStr = args.getString(1);
+        boolean overwrite = !args.isNullAt(2) && args.getBoolean(2);
+        checkArgument(StringUtils.isNotEmpty(partitionStr), "Partition string 
cannot be empty");
+        checkArgument(
+                partitionStr.split(";").length == 1,
+                "compact_chain_table only supports a single partition, but 
multiple partitions were provided: %s",
+                partitionStr);
+
+        return modifyPaimonTable(
+                tableIdent,
+                t -> {
+                    checkArgument(
+                            new CoreOptions(t.options()).isChainTable(),
+                            "compact_chain_table only supports chain table");
+                    checkArgument(
+                            t instanceof FallbackReadFileStoreTable,
+                            "Table %s is not a chain table",
+                            tableIdent);
+                    FallbackReadFileStoreTable table = 
(FallbackReadFileStoreTable) t;
+                    checkArgument(
+                            table.other() instanceof ChainGroupReadTable,
+                            "Table %s is not a chain table",
+                            tableIdent);
+                    DataSourceV2Relation relation = createRelation(tableIdent);
+                    boolean success =
+                            execute(
+                                    (ChainGroupReadTable) table.other(),
+                                    relation,
+                                    partitionStr,
+                                    overwrite);
+                    return new InternalRow[] {newInternalRow(success)};
+                });
+    }
+
+    private boolean execute(
+            ChainGroupReadTable table,
+            DataSourceV2Relation relation,
+            String partitionStr,
+            boolean overwrite) {
+        String partition = SparkProcedureUtils.toWhere(partitionStr);
+        FileStoreTable snapshotTable = table.wrapped();
+
+        ChainGroupReadTable.ChainTableBatchScan scan =
+                (ChainGroupReadTable.ChainTableBatchScan) table.newScan();
+        PartitionPredicate partitionPredicate =
+                SparkProcedureUtils.convertToPartitionPredicate(
+                        partition, table.schema().logicalPartitionType(), 
spark(), relation);
+
+        // Check if target partition already exists in snapshot branch
+        boolean partitionExists = checkPartitionExists(snapshotTable, 
partition, relation);
+        if (partitionExists) {
+            if (overwrite) {
+                
scan.skipPreloadTargetSnapshot().withPartitionFilter(partitionPredicate);
+                LOG.info("Found existing partition {}, will overwrite it.", 
partition);
+            } else {
+                LOG.info(
+                        "Partition {} already exists in snapshot branch, 
skipping compaction.",
+                        partitionStr);
+                return false;
+            }
+        } else {
+            scan.withPartitionFilter(partitionPredicate);
+        }
+
+        List<Split> splits = scan.plan().splits();
+        if (splits.isEmpty()) {
+            LOG.warn(
+                    "Table {} partition {} has no data to compact, skipping.", 
table, partitionStr);
+            return false;
+        }
+
+        Dataset<Row> datasetForWrite =
+                PaimonUtils.createDataset(
+                        spark(),
+                        ScanPlanHelper$.MODULE$.createNewScanPlan(
+                                splits.toArray(new Split[0]), relation));
+
+        PaimonSparkWriter writer = PaimonSparkWriter.apply(snapshotTable);
+        Map<String, String> targetPartition =
+                ParameterUtils.parseCommaSeparatedKeyValues(partitionStr);
+        for (Map.Entry<String, String> entry : targetPartition.entrySet()) {
+            datasetForWrite =
+                    datasetForWrite.withColumn(entry.getKey(), 
functions.expr(entry.getValue()));
+        }
+        if (partitionExists) {
+            Map<String, String> staticPartition =
+                    SparkProcedureUtils.parseStaticPartition(spark(), 
targetPartition);
+            writer.writeBuilder().withOverwrite(staticPartition);
+        }
+        writer.commit(writer.write(datasetForWrite));
+        LOG.info("Successfully compacted partition {} to snapshot branch.", 
partitionStr);
+        return true;
+    }
+
+    private boolean checkPartitionExists(
+            FileStoreTable snapshotTable, String partition, 
DataSourceV2Relation relation) {
+        PartitionPredicate snapshotPartitionPredicate =
+                SparkProcedureUtils.convertToPartitionPredicate(
+                        partition,
+                        snapshotTable.schema().logicalPartitionType(),
+                        spark(),
+                        relation);
+
+        return !snapshotTable
+                .newScan()
+                .withPartitionFilter(snapshotPartitionPredicate)
+                .plan()
+                .splits()
+                .isEmpty();
+    }
+
+    public static ProcedureBuilder builder() {
+        return new BaseProcedure.Builder<CompactChainTableProcedure>() {
+            @Override
+            public CompactChainTableProcedure doBuild() {
+                return new CompactChainTableProcedure(tableCatalog());
+            }
+        };
+    }
+
+    @Override
+    public String description() {
+        return "Compact chain table by merging snapshot + delta into target 
snapshot.";
+    }
+}
diff --git 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/utils/SparkProcedureUtils.java
 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/utils/SparkProcedureUtils.java
index e4bc86b7a5..59f93f92fb 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/utils/SparkProcedureUtils.java
+++ 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/utils/SparkProcedureUtils.java
@@ -27,6 +27,7 @@ import org.apache.paimon.utils.StringUtils;
 
 import org.apache.spark.sql.SparkSession;
 import org.apache.spark.sql.catalyst.expressions.Expression;
+import org.apache.spark.sql.catalyst.expressions.Literal;
 import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan;
 import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation;
 import org.slf4j.Logger;
@@ -34,6 +35,7 @@ import org.slf4j.LoggerFactory;
 
 import javax.annotation.Nullable;
 
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -107,4 +109,33 @@ public class SparkProcedureUtils {
                 .reduce((a, b) -> a + " OR " + b)
                 .orElse(null);
     }
+
+    /**
+     * Parse partition spec values by evaluating them as Spark SQL literal 
expressions. This strips
+     * quotes from string literals and validates that values are non-null 
literals.
+     *
+     * @param spark the Spark session
+     * @param partitionSpec the partition spec with raw values (e.g., {"date": 
"\"20260225\""})
+     * @return the static partition map with unquoted literal values (e.g., 
{"date": "20260225"})
+     */
+    public static Map<String, String> parseStaticPartition(
+            SparkSession spark, Map<String, String> partitionSpec) {
+        Map<String, String> staticPartition = new HashMap<>();
+        for (Map.Entry<String, String> entry : partitionSpec.entrySet()) {
+            Expression expr;
+            try {
+                expr = 
spark.sessionState().sqlParser().parseExpression(entry.getValue());
+            } catch (Exception e) {
+                throw new RuntimeException(e);
+            }
+            checkArgument(
+                    expr instanceof Literal,
+                    "Partition value must be a literal expression, but got: 
%s",
+                    entry.getValue());
+            Object value = ((Literal) expr).value();
+            checkArgument(value != null, "Partition value cannot be null");
+            staticPartition.put(entry.getKey(), value.toString());
+        }
+        return staticPartition;
+    }
 }
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/CompactChainTableProcedureTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/CompactChainTableProcedureTest.scala
new file mode 100644
index 0000000000..b7ffc9be5f
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/CompactChainTableProcedureTest.scala
@@ -0,0 +1,394 @@
+/*
+ * 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.Row
+import org.apache.spark.sql.streaming.StreamTest
+
+/** Test compact_chain_table procedure. See [[CompactChainTableProcedure]]. */
+class CompactChainTableProcedureTest extends PaimonSparkTestBase {
+
+  test("Paimon Procedure: compact_chain_table - basic test") {
+    withTable("chain_compact_t1") {
+      // Create chain table
+      spark.sql("""
+                  |CREATE TABLE IF NOT EXISTS chain_compact_t1 (
+                  |    `t1` BIGINT COMMENT 't1',
+                  |    `t2` BIGINT COMMENT 't2',
+                  |    `t3` STRING COMMENT 't3'
+                  |  ) PARTITIONED BY (`date` STRING COMMENT 'date')
+                  |TBLPROPERTIES (
+                  |     'chain-table.enabled' = 'true',
+                  |     'primary-key' = 'date,t1',
+                  |     'sequence.field' = 't2',
+                  |     'bucket-key' = 't1',
+                  |     'bucket' = '1',
+                  |     'partition.timestamp-pattern' = '$date',
+                  |     'partition.timestamp-formatter' = 'yyyyMMdd'
+                  |)
+                  |""".stripMargin)
+
+      // Create branches
+      setupChainTableBranches("chain_compact_t1")
+
+      // Compact empty partition
+      checkAnswer(
+        spark.sql(
+          "CALL sys.compact_chain_table(table => '`chain_compact_t1`', 
partition => 'date=\"20260224\"')"),
+        Row(false) :: Nil)
+
+      // Insert snapshot data
+      spark.sql(
+        "insert into `chain_compact_t1$branch_snapshot` partition (date = 
'20260222') values (0, 1, '0')")
+      spark.sql(
+        "insert into `chain_compact_t1$branch_snapshot` partition (date = 
'20260223') values (1, 1, '1')")
+
+      // Insert delta data
+      spark.sql(
+        "insert into `chain_compact_t1$branch_delta` partition (date = 
'20260224') values (2, 2, '2')")
+
+      // Before compaction: verify chain read shows snapshot + delta
+      checkAnswer(
+        sql("SELECT * FROM `chain_compact_t1` where date = '20260224'"),
+        Seq(Row(1, 1, "1", "20260224"), Row(2, 2, "2", "20260224"))
+      )
+
+      checkAnswer(
+        spark.sql(
+          "CALL sys.compact_chain_table(table => '`chain_compact_t1`', 
partition => 'date=\"20260224\"')"),
+        Row(true) :: Nil)
+
+      // After compaction: verify snapshot branch has merged data
+      checkAnswer(
+        sql("SELECT * FROM `chain_compact_t1` where date = '20260224'"),
+        Seq(Row(1, 1, "1", "20260224"), Row(2, 2, "2", "20260224"))
+      )
+      checkAnswer(
+        sql("SELECT * FROM `chain_compact_t1$branch_snapshot` where date = 
'20260223'"),
+        Seq(Row(1, 1, "1", "20260223"))
+      )
+      checkAnswer(
+        sql("SELECT * FROM `chain_compact_t1$branch_snapshot` where date = 
'20260224'"),
+        Seq(Row(1, 1, "1", "20260224"), Row(2, 2, "2", "20260224"))
+      )
+    }
+  }
+
+  test("Paimon Procedure: compact_chain_table - overwrite test") {
+    withTable("chain_compact_t2") {
+      // Create chain table
+      spark.sql("""
+                  |CREATE TABLE IF NOT EXISTS chain_compact_t2 (
+                  |    `t1` BIGINT COMMENT 't1',
+                  |    `t2` BIGINT COMMENT 't2',
+                  |    `t3` STRING COMMENT 't3'
+                  |  ) PARTITIONED BY (`date` STRING COMMENT 'date')
+                  |TBLPROPERTIES (
+                  |     'dynamic-partition-overwrite' = 'false',
+                  |     'chain-table.enabled' = 'true',
+                  |     'primary-key' = 'date,t1',
+                  |     'sequence.field' = 't2',
+                  |     'bucket-key' = 't1',
+                  |     'bucket' = '1',
+                  |     'partition.timestamp-pattern' = '$date',
+                  |     'partition.timestamp-formatter' = 'yyyyMMdd'
+                  |  )
+                  |""".stripMargin)
+
+      // Create branches
+      setupChainTableBranches("chain_compact_t2")
+
+      spark.sql("insert into `chain_compact_t2` partition (date = '20260222') 
values (1, 1, '1')")
+
+      // Insert snapshot data
+      spark.sql(
+        "insert into `chain_compact_t2$branch_snapshot` partition (date = 
'20260223') values (2, 1, '2')")
+      spark.sql(
+        "insert into `chain_compact_t2$branch_snapshot` partition (date = 
'20260224') values (3, 1, '3')")
+      spark.sql(
+        "insert into `chain_compact_t2$branch_snapshot` partition (date = 
'20260225') values (3, 2, '3-1')")
+
+      // Insert delta data
+      spark.sql(
+        "insert into `chain_compact_t2$branch_delta` partition (date = 
'20260225') values (4, 2, '4')")
+
+      // First call should fail because partition exists
+      checkAnswer(
+        spark.sql(
+          "CALL sys.compact_chain_table(table => '`chain_compact_t2`', 
partition => 'date=\"20260225\"')"),
+        Row(false) :: Nil)
+
+      checkAnswer(
+        sql("SELECT * FROM `chain_compact_t2` where date = '20260225'"),
+        Seq(Row(3, 2, "3-1", "20260225"))
+      )
+
+      checkAnswer(
+        spark.sql(
+          "CALL sys.compact_chain_table(table => '`chain_compact_t2`', 
partition => 'date=\"20260225\"', overwrite => true)"),
+        Row(true) :: Nil)
+
+      // Verify snapshot branch now has the data
+      checkAnswer(
+        sql("SELECT * FROM `chain_compact_t2$branch_snapshot` where date = 
'20260225'"),
+        Seq(Row(3, 1, "3", "20260225"), Row(4, 2, "4", "20260225"))
+      )
+      checkAnswer(
+        sql("SELECT * FROM `chain_compact_t2$branch_snapshot` where date != 
'20260225'"),
+        Seq(Row(2, 1, "2", "20260223"), Row(3, 1, "3", "20260224"))
+      )
+
+      // Check snapshots commit_kind
+      checkAnswer(
+        sql("select snapshot_id, commit_kind from 
`chain_compact_t2$branch_snapshot$snapshots`"),
+        Seq(Row(1, "APPEND"), Row(2, "APPEND"), Row(3, "APPEND"), Row(4, 
"OVERWRITE"))
+      )
+    }
+  }
+
+  test("Paimon Procedure: compact_chain_table - test multiple partitions") {
+    withTable("chain_compact_t3") {
+      spark.sql("""
+                  |CREATE TABLE IF NOT EXISTS chain_compact_t3 (
+                  |    `t1` BIGINT COMMENT 't1',
+                  |    `t2` BIGINT COMMENT 't2',
+                  |    `t3` STRING COMMENT 't3'
+                  |  ) PARTITIONED BY (`dt` STRING COMMENT 'dt', `hour` STRING 
COMMENT 'hour')
+                  |TBLPROPERTIES (
+                  |     'chain-table.enabled' = 'true',
+                  |     'primary-key' = 'dt,hour,t1',
+                  |     'sequence.field' = 't2',
+                  |     'bucket-key' = 't1',
+                  |     'bucket' = '2',
+                  |     'partition.timestamp-pattern' = '$dt $hour:00:00',
+                  |     'partition.timestamp-formatter' = 'yyyyMMdd HH:mm:ss'
+                  |  )
+                  |""".stripMargin)
+
+      // Create branches
+      setupChainTableBranches("chain_compact_t3")
+
+      // Write snapshot branch data
+      spark.sql(
+        "insert into `chain_compact_t3$branch_snapshot` partition (dt = 
'20250810', hour = '20') values (0, 1, '0')")
+      spark.sql(
+        "insert into `chain_compact_t3$branch_snapshot` partition (dt = 
'20250810', hour = '22') values (1, 1, '1'),(2, 1, '1')")
+
+      // Write delta branch data
+      spark.sql(
+        "insert into `chain_compact_t3$branch_delta` partition (dt = 
'20250810', hour = '21') values (1, 1, '1'),(2, 1, '1')")
+      spark.sql(
+        "insert into `chain_compact_t3$branch_delta` partition (dt = 
'20250810', hour = '22') values (1, 2, '1-1' ),(3, 1, '1' )")
+      spark.sql(
+        "insert into `chain_compact_t3$branch_delta` partition (dt = 
'20250810', hour = '23') values (2, 2, '1-1' ),(4, 1, '1' )")
+
+      // Compact partition 20250810/hour=23
+      checkAnswer(
+        spark.sql(
+          "CALL sys.compact_chain_table(table => '`chain_compact_t3`', 
partition => 'dt=\"20250810\",hour=\"23\"')"),
+        Row(true) :: Nil
+      )
+
+      // Verify chain read still works correctly
+      checkAnswer(
+        sql("SELECT * FROM `chain_compact_t3` where dt = '20250810' and hour = 
'23'"),
+        Seq(
+          Row(1, 1, "1", "20250810", "23"),
+          Row(2, 2, "1-1", "20250810", "23"),
+          Row(4, 1, "1", "20250810", "23")
+        )
+      )
+
+      spark.sql(
+        "insert into `chain_compact_t3$branch_snapshot` partition (dt = 
'20250811', hour = '00') values (1, 2, '1-1'),(2, 2, '1-1'),(3, 2, '1-1'), (4, 
2, '1-1')")
+      spark.sql(
+        "insert into `chain_compact_t3$branch_snapshot` partition (dt = 
'20250811', hour = '02') values (1, 2, '1-1'),(2, 2, '1-1'),(3, 2, '1-1'), (4, 
2, '1-1'), (5, 1, '1' ), (6, 1, '1')")
+      spark.sql(
+        "insert into `chain_compact_t3$branch_delta` partition (dt = 
'20250811', hour = '00') values (3, 2, '1-1' ),(4, 2, '1-1')")
+      spark.sql(
+        "insert into `chain_compact_t3$branch_delta` partition (dt = 
'20250811', hour = '01') values (5, 1, '1' ),(6, 1, '1' )")
+      spark.sql(
+        "insert into `chain_compact_t3$branch_delta` partition (dt = 
'20250811', hour = '02') values (5, 2, '1-1' ),(6, 2, '1-1' )")
+
+      // Compact partition 20250811/hour=02 without overwrite
+      checkAnswer(
+        spark.sql(
+          "CALL sys.compact_chain_table(table => '`chain_compact_t3`', 
partition => 'dt=\"20250811\",hour=\"02\"')"),
+        Row(false) :: Nil
+      )
+
+      // Verify data is unchanged after skip
+      checkAnswer(
+        sql("SELECT * FROM `chain_compact_t3` where dt = '20250811' and hour = 
'02'"),
+        Seq(
+          Row(1, 2, "1-1", "20250811", "02"),
+          Row(2, 2, "1-1", "20250811", "02"),
+          Row(3, 2, "1-1", "20250811", "02"),
+          Row(4, 2, "1-1", "20250811", "02"),
+          Row(5, 1, "1", "20250811", "02"),
+          Row(6, 1, "1", "20250811", "02")
+        )
+      )
+
+      // Compact with overwrite to test overwrite path
+      checkAnswer(
+        spark.sql(
+          "CALL sys.compact_chain_table(table => '`chain_compact_t3`', 
partition => 'dt=\"20250811\",hour=\"02\"', overwrite => true)"),
+        Row(true) :: Nil
+      )
+      checkAnswer(
+        sql("select snapshot_id,commit_kind from 
`chain_compact_t3$branch_snapshot$snapshots`"),
+        Seq(
+          Row(1, "APPEND"),
+          Row(2, "APPEND"),
+          Row(3, "APPEND"),
+          Row(4, "APPEND"),
+          Row(5, "APPEND"),
+          Row(6, "OVERWRITE")
+        )
+      )
+
+      // Check all snapshot partition data
+      checkAnswer(
+        sql("SELECT * FROM `chain_compact_t3` where dt = '20250811' and hour = 
'02'"),
+        Seq(
+          Row(1, 2, "1-1", "20250811", "02"),
+          Row(2, 2, "1-1", "20250811", "02"),
+          Row(3, 2, "1-1", "20250811", "02"),
+          Row(4, 2, "1-1", "20250811", "02"),
+          Row(5, 2, "1-1", "20250811", "02"),
+          Row(6, 2, "1-1", "20250811", "02")
+        )
+      )
+      checkAnswer(
+        sql("SELECT * FROM `chain_compact_t3` where dt = '20250811' and hour = 
'00'"),
+        Seq(
+          Row(1, 2, "1-1", "20250811", "00"),
+          Row(2, 2, "1-1", "20250811", "00"),
+          Row(3, 2, "1-1", "20250811", "00"),
+          Row(4, 2, "1-1", "20250811", "00")
+        )
+      )
+      checkAnswer(
+        sql(
+          "SELECT * FROM `chain_compact_t3$branch_snapshot` where dt = 
'20250810' and hour = '23'"),
+        Seq(
+          Row(1, 1, "1", "20250810", "23"),
+          Row(2, 2, "1-1", "20250810", "23"),
+          Row(4, 1, "1", "20250810", "23")
+        )
+      )
+      checkAnswer(
+        sql(
+          "SELECT * FROM `chain_compact_t3$branch_snapshot` where dt = 
'20250810' and hour = '22'"),
+        Seq(Row(1, 1, "1", "20250810", "22"), Row(2, 1, "1", "20250810", "22"))
+      )
+    }
+  }
+
+  test("Paimon Procedure: compact_chain_table - test multiple partitions with 
group") {
+    withTable("chain_compact_t4") {
+      spark.sql(
+        """
+          |CREATE TABLE IF NOT EXISTS chain_compact_t4 (
+          |    `t1` BIGINT COMMENT 't1',
+          |    `t2` BIGINT COMMENT 't2',
+          |    `t3` STRING COMMENT 't3'
+          |  ) PARTITIONED BY (`region` STRING COMMENT 'region', `dt` STRING 
COMMENT 'dt', `hour` STRING COMMENT 'hour')
+          |TBLPROPERTIES (
+          |     'chain-table.enabled' = 'true',
+          |     'primary-key' = 'region,dt,hour,t1',
+          |     'sequence.field' = 't2',
+          |     'bucket-key' = 't1',
+          |     'bucket' = '1',
+          |     'partition.timestamp-pattern' = '$dt $hour:00:00',
+          |     'partition.timestamp-formatter' = 'yyyyMMdd HH:mm:ss',
+          |     'merge-engine' = 'deduplicate',
+          |     'chain-table.chain-partition-keys' = 'dt,hour'
+          |  )
+          |""".stripMargin)
+
+      // Create branches
+      setupChainTableBranches("chain_compact_t4")
+
+      // Write snapshot branch data
+      spark.sql(
+        "insert into `chain_compact_t4$branch_snapshot` partition 
(region='CN', dt = '20250810', hour = '20') values (1, 1, '1')")
+      spark.sql(
+        "insert into `chain_compact_t4$branch_snapshot` partition 
(region='CN', dt = '20250810', hour = '21') values (2, 1, '1')")
+      spark.sql(
+        "insert into `chain_compact_t4$branch_snapshot` partition 
(region='CN', dt = '20250810', hour = '22') values (3, 1, '1')")
+      spark.sql(
+        "insert into `chain_compact_t4$branch_snapshot` partition 
(region='UK', dt = '20250810', hour = '21') values (21, 1, '1')")
+
+      // Write delta branch data
+      spark.sql(
+        "insert into `chain_compact_t4$branch_delta` partition (region='CN', 
dt = '20250810', hour = '22') values (4, 1, '1')")
+      spark.sql(
+        "insert into `chain_compact_t4$branch_delta` partition (region='US', 
dt = '20250810', hour = '22') values (11, 1, '1')")
+      spark.sql(
+        "insert into `chain_compact_t4$branch_delta` partition (region='UK', 
dt = '20250810', hour = '22') values (22, 1, '1' )")
+
+      checkAnswer(
+        sql(
+          "SELECT * FROM `chain_compact_t4$branch_snapshot` where dt = 
'20250810' and hour = '22'"),
+        Seq(
+          Row(3, 1, "1", "CN", "20250810", "22")
+        )
+      )
+      checkAnswer(
+        spark.sql(
+          "CALL sys.compact_chain_table(table => '`chain_compact_t4`', 
partition => 'dt=\"20250810\", hour=\"22\"', overwrite => true)"),
+        Row(true) :: Nil
+      )
+      checkAnswer(
+        sql(
+          "SELECT * FROM `chain_compact_t4$branch_snapshot` where dt = 
'20250810' and hour = '22' order by region"),
+        Seq(
+          Row(2, 1, "1", "CN", "20250810", "22"),
+          Row(4, 1, "1", "CN", "20250810", "22"),
+          Row(21, 1, "1", "UK", "20250810", "22"),
+          Row(22, 1, "1", "UK", "20250810", "22"),
+          Row(11, 1, "1", "US", "20250810", "22")
+        )
+      )
+    }
+  }
+
+  def setupChainTableBranches(tableName: String): Unit = {
+    spark.sql(s"CALL sys.create_branch('$tableName', 'snapshot');")
+    spark.sql(s"CALL sys.create_branch('$tableName', 'delta');")
+
+    // Set branch properties
+    spark.sql(
+      s"ALTER TABLE $tableName SET tblproperties (" +
+        "'scan.fallback-snapshot-branch' = 'snapshot', " +
+        "'scan.fallback-delta-branch' = 'delta')")
+    spark.sql(
+      s"ALTER TABLE `$tableName$$branch_snapshot` SET tblproperties (" +
+        "'scan.fallback-snapshot-branch' = 'snapshot'," +
+        "'scan.fallback-delta-branch' = 'delta')")
+    spark.sql(
+      s"ALTER TABLE `$tableName$$branch_delta` SET tblproperties (" +
+        "'scan.fallback-snapshot-branch' = 'snapshot'," +
+        "'scan.fallback-delta-branch' = 'delta')")
+  }
+}


Reply via email to