This is an automated email from the ASF dual-hosted git repository.

czy006 pushed a commit to branch jira/JSPT-2568
in repository https://gitbox.apache.org/repos/asf/amoro.git

commit f922735694b161fbc7f756a282bdfbfdd755aa9e
Author: ConradJam <[email protected]>
AuthorDate: Tue Jun 16 23:32:24 2026 +0800

    [JSPT-2540][feat] 小文件优化:SNAPSHOT清理脚本升级 & 优化
---
 .../paimon/TestPaimonExpireSnapshotProcess.java    |   3 +-
 amoro-producer/README.md                           | 187 ++++++++
 amoro-producer/pom.xml                             | 135 ++++++
 .../amoro/producer/PaimonExpireSnapshots.scala     | 491 +++++++++++++++++++++
 pom.xml                                            |   1 +
 5 files changed, 816 insertions(+), 1 deletion(-)

diff --git 
a/amoro-ams/src/test/java/org/apache/amoro/server/process/paimon/TestPaimonExpireSnapshotProcess.java
 
b/amoro-ams/src/test/java/org/apache/amoro/server/process/paimon/TestPaimonExpireSnapshotProcess.java
index fc02a5d6b..e021f45f8 100644
--- 
a/amoro-ams/src/test/java/org/apache/amoro/server/process/paimon/TestPaimonExpireSnapshotProcess.java
+++ 
b/amoro-ams/src/test/java/org/apache/amoro/server/process/paimon/TestPaimonExpireSnapshotProcess.java
@@ -51,7 +51,8 @@ public class TestPaimonExpireSnapshotProcess {
     Assert.assertEquals("sl_real_time_merger", params.get("curUser"));
     Assert.assertEquals("sl_real_time_merger", params.get("logUser"));
     Assert.assertEquals("AMORO", params.get("sourceTag"));
-    
Assert.assertEquals("{\"sparkVersion\":\"354\",\"paimon.version\":\"1.3\"}", 
params.get("conf"));
+    Assert.assertEquals(
+        "{\"sparkVersion\":\"354\",\"paimon.version\":\"1.3\"}", 
params.get("conf"));
   }
 
   @Test
diff --git a/amoro-producer/README.md b/amoro-producer/README.md
new file mode 100644
index 000000000..46e49bd99
--- /dev/null
+++ b/amoro-producer/README.md
@@ -0,0 +1,187 @@
+<!--
+ - 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.
+ -->
+
+# Amoro Producer Snapshot 清理任务
+
+`amoro-producer` 提供可通过 `spark-submit` 提交的 Paimon Snapshot 清理任务。入口类为
+`org.apache.amoro.producer.PaimonExpireSnapshots`,任务会通过 Spark SQL 执行
+`CALL sys.expire_snapshots`。
+
+## 打包
+
+```bash
+./mvnw -pl amoro-producer package -DskipTests
+```
+
+打包产物:
+
+```text
+amoro-producer/target/amoro-producer-0.9-SNAPSHOT-jar-with-dependencies.jar
+```
+
+## 参数说明
+
+| 参数 | 是否必填 | 默认值 | 说明 |
+|------|----------|--------|------|
+| `--databaseName` | 否 | 无 | 数据库名。仅传该参数时,任务会扫描并清理该库下全部非临时表。 |
+| `--tableName` | 否 | 无 | 表名。与 `--databaseName` 同时传入时,只清理指定表;单独传入时必须使用 
`db.table` 格式。 |
+| `--retainMax` | 否 | `20` | 传给 `expire_snapshots` 的 `retain_max`。 |
+| `--retainMin` | 否 | `10` | 传给 `expire_snapshots` 的 `retain_min`。 |
+| `--maxDeletes` | 否 | `550` | 单轮最多删除的 snapshot 数量。若返回删除数大于等于该值,则继续下一轮清理。 |
+| `--retryTimes` | 否 | `3` | 单轮 `expire_snapshots` SQL 失败后的最大重试次数。 |
+| `--continueOnTableFailure` | 否 | `true` | 单表清理失败后是否继续处理后续表。 |
+| `--procedureOptions` | 否 | 
`file-operation.thread-num=32,snapshot.expire.execution-mode=sync` | 传给 
`expire_snapshots` 的 `options` 字符串。传空字符串时不拼接 `options` 参数。 |
+| `--help` / `-h` | 否 | 无 | 打印帮助信息。 |
+
+参数组合规则:
+
+| 参数组合 | 行为 |
+|----------|------|
+| `--databaseName db` | 清理 `db` 下所有非临时表。 |
+| `--databaseName db --tableName t1` | 只清理 `db.t1`。 |
+| `--tableName db.t1` | 只清理 `db.t1`。 |
+| `--tableName t1` | 非法参数,任务打印 usage 并以退出码 `2` 结束。 |
+| `--databaseName db --tableName db.t1` | 非法参数,任务打印 usage 并以退出码 `2` 结束。 |
+
+## Spark Submit Demo
+
+以下示例基于 YARN cluster 模式提交,主类替换为
+`org.apache.amoro.producer.PaimonExpireSnapshots`,应用 JAR 替换为
+`amoro-producer-0.9-SNAPSHOT-jar-with-dependencies.jar`。
+
+### 按数据库清理
+
+```bash
+./spark-submit \
+    --master yarn \
+    --deploy-mode cluster \
+    --driver-memory 8g \
+    --driver-cores 4 \
+    --executor-memory 16g \
+    --executor-cores 4 \
+    --conf spark.dynamicAllocation.enabled=true \
+    --conf spark.dynamicAllocation.minExecutors=1 \
+    --conf spark.dynamicAllocation.maxExecutors=2 \
+    --conf spark.dynamicAllocation.initialExecutors=40 \
+    --conf spark.dynamicAllocation.shuffleTracking.enabled=true \
+    --conf spark.driver.memoryOverhead=2g \
+    --conf spark.executor.memoryOverhead=2g \
+    --conf spark.driver.bindAddress=0.0.0.0 \
+    --conf spark.sql.shuffle.partitions=400 \
+    --conf spark.sql.cbo.enabled=true \
+    --conf spark.sql.files.maxPartitionBytes=512m \
+    --conf spark.default.parallelism=200 \
+    --conf spark.locality.wait=0s \
+    --conf spark.sql.crossJoin.enabled=true \
+    --conf spark.sql.adaptive.localShuffleReader.enabled=true \
+    --conf spark.sql.fuse.unionAllOnJoin.enabled=true \
+    --conf spark.sql.optimizer.runtime.bloomFilter.enabled=true \
+    --conf spark.sql.optimizer.enableMergeScalarAggsInInnerJoin=true \
+    --conf spark.sql.optimizer.pushdownAggregateBelowJoin=true \
+    --conf spark.sql.optimizer.inferDistinctFromIntersect=true \
+    --conf spark.sql.optimizer.groupSplitsByLocation=false \
+    --conf spark.sql.adaptive.amend.join.selection.enabled=true \
+    --conf spark.sql.mergeScalaSubquery.pullupAggFilter=true \
+    --conf spark.sql.execution.optimizeExpand=true \
+    --conf spark.sql.execution.optimizeExpand.ratio=5 \
+    --conf spark.sql.legacy.ctePrecedencePolicy=LEGACY \
+    --conf spark.sql.auto.reused.cte.enabled=true \
+    --conf spark.sql.auto.clear.cte.cache.enabled=true \
+    --conf spark.locality.wait.node=0s \
+    --conf spark.sql.sources.ignoreDataLocality=true \
+    --conf spark.driver.host=10.89.56.103 \
+    --conf spark.hadoop.net.topology.script.file.name=/dev/null \
+    --keytab /data/work/hadoop-shopline/etc/hadoop/sljdp.keytab \
+    --principal [email protected] \
+    --class org.apache.amoro.producer.PaimonExpireSnapshots \
+    
/data/work/frame/amoro-0.9-SNAPSHOT/plugin/producer/amoro-producer-0.9-SNAPSHOT-jar-with-dependencies.jar
 \
+    --databaseName sl_oki_test \
+    --retainMax 20 \
+    --retainMin 10 \
+    --maxDeletes 550 \
+    --retryTimes 3 \
+    --continueOnTableFailure true \
+    --procedureOptions 
file-operation.thread-num=32,snapshot.expire.execution-mode=sync
+```
+
+### 按单表清理
+
+```bash
+./spark-submit \
+    --master yarn \
+    --deploy-mode cluster \
+    --driver-memory 8g \
+    --driver-cores 4 \
+    --executor-memory 16g \
+    --executor-cores 4 \
+    --conf spark.dynamicAllocation.enabled=true \
+    --conf spark.dynamicAllocation.minExecutors=1 \
+    --conf spark.dynamicAllocation.maxExecutors=2 \
+    --conf spark.dynamicAllocation.initialExecutors=40 \
+    --conf spark.dynamicAllocation.shuffleTracking.enabled=true \
+    --conf spark.driver.memoryOverhead=2g \
+    --conf spark.executor.memoryOverhead=2g \
+    --conf spark.driver.bindAddress=0.0.0.0 \
+    --conf spark.sql.shuffle.partitions=400 \
+    --conf spark.sql.cbo.enabled=true \
+    --conf spark.sql.files.maxPartitionBytes=512m \
+    --conf spark.default.parallelism=200 \
+    --conf spark.locality.wait=0s \
+    --conf spark.sql.crossJoin.enabled=true \
+    --conf spark.sql.adaptive.localShuffleReader.enabled=true \
+    --conf spark.sql.fuse.unionAllOnJoin.enabled=true \
+    --conf spark.sql.optimizer.runtime.bloomFilter.enabled=true \
+    --conf spark.sql.optimizer.enableMergeScalarAggsInInnerJoin=true \
+    --conf spark.sql.optimizer.pushdownAggregateBelowJoin=true \
+    --conf spark.sql.optimizer.inferDistinctFromIntersect=true \
+    --conf spark.sql.optimizer.groupSplitsByLocation=false \
+    --conf spark.sql.adaptive.amend.join.selection.enabled=true \
+    --conf spark.sql.mergeScalaSubquery.pullupAggFilter=true \
+    --conf spark.sql.execution.optimizeExpand=true \
+    --conf spark.sql.execution.optimizeExpand.ratio=5 \
+    --conf spark.sql.legacy.ctePrecedencePolicy=LEGACY \
+    --conf spark.sql.auto.reused.cte.enabled=true \
+    --conf spark.sql.auto.clear.cte.cache.enabled=true \
+    --conf spark.locality.wait.node=0s \
+    --conf spark.sql.sources.ignoreDataLocality=true \
+    --conf spark.driver.host=10.89.56.103 \
+    --conf spark.hadoop.net.topology.script.file.name=/dev/null \
+    --keytab /data/work/hadoop-shopline/etc/hadoop/sljdp.keytab \
+    --principal [email protected] \
+    --class org.apache.amoro.producer.PaimonExpireSnapshots \
+    
/data/work/frame/amoro-0.9-SNAPSHOT/plugin/producer/amoro-producer-0.9-SNAPSHOT-jar-with-dependencies.jar
 \
+    --databaseName sl_oki_test \
+    --tableName t_order \
+    --retainMax 20 \
+    --retainMin 10 \
+    --maxDeletes 550 \
+    --retryTimes 3 \
+    --continueOnTableFailure true \
+    --procedureOptions 
file-operation.thread-num=32,snapshot.expire.execution-mode=sync
+```
+
+## 运行注意事项
+
+Spark 运行环境必须提前配置好 Paimon catalog,否则 `SHOW TABLES` 或
+`CALL sys.expire_snapshots` 会在运行期失败。
+
+如果运行环境已经通过 Spark 安装目录或平台层提供 Paimon 相关 JAR,可以直接提交
+`amoro-producer-0.9-SNAPSHOT.jar`;如果希望单 JAR 提交,使用
+`amoro-producer-0.9-SNAPSHOT-jar-with-dependencies.jar`。
+
+`spark.dynamicAllocation.initialExecutors` 建议不要大于
+`spark.dynamicAllocation.maxExecutors`。上面的示例保留了原始提交命令中的资源参数,实际提交前请按集群策略调整。
diff --git a/amoro-producer/pom.xml b/amoro-producer/pom.xml
new file mode 100644
index 000000000..e590ebffc
--- /dev/null
+++ b/amoro-producer/pom.xml
@@ -0,0 +1,135 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ 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.
+  -->
+<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/xsd/maven-4.0.0.xsd";>
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.apache.amoro</groupId>
+        <artifactId>amoro-parent</artifactId>
+        <version>0.9-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>amoro-producer</artifactId>
+    <packaging>jar</packaging>
+
+    <name>amoro-producer</name>
+    <url>http://maven.apache.org</url>
+
+    <properties>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.apache.spark</groupId>
+            <artifactId>spark-sql_${scala.binary.version}</artifactId>
+            <version>${spark.version}</version>
+            <scope>provided</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.paimon</groupId>
+            
<artifactId>paimon-spark-${spark.major.version}_${scala.binary.version}</artifactId>
+            <version>${paimon.version}</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.paimon</groupId>
+            <artifactId>paimon-bundle</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.paimon</groupId>
+            <artifactId>paimon-hive-connector-3.1</artifactId>
+            <exclusions>
+                <exclusion>
+                    <groupId>com.google.guava</groupId>
+                    <artifactId>guava</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.paimon</groupId>
+            <artifactId>paimon-s3</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.paimon</groupId>
+            <artifactId>paimon-oss</artifactId>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>net.alchim31.maven</groupId>
+                <artifactId>scala-maven-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <id>scala-compile-first</id>
+                        <goals>
+                            <goal>add-source</goal>
+                            <goal>compile</goal>
+                        </goals>
+                        <phase>process-resources</phase>
+                    </execution>
+                    <execution>
+                        <id>scala-test-compile</id>
+                        <goals>
+                            <goal>add-source</goal>
+                            <goal>testCompile</goal>
+                        </goals>
+                        <phase>process-test-resources</phase>
+                    </execution>
+                </executions>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-shade-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <id>shade-amoro-producer</id>
+                        <goals>
+                            <goal>shade</goal>
+                        </goals>
+                        <phase>package</phase>
+                        <configuration>
+                            
<shadedArtifactAttached>true</shadedArtifactAttached>
+                            
<shadedClassifierName>jar-with-dependencies</shadedClassifierName>
+                            
<createDependencyReducedPom>false</createDependencyReducedPom>
+                            <artifactSet>
+                                <excludes>
+                                    <exclude>org.slf4j:slf4j-api</exclude>
+                                    <exclude>org.apache.hadoop:*</exclude>
+                                    <exclude>org.apache.hive:*</exclude>
+                                </excludes>
+                            </artifactSet>
+                            <transformers>
+                                <transformer 
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
+                                    
<mainClass>org.apache.amoro.producer.PaimonExpireSnapshots</mainClass>
+                                </transformer>
+                            </transformers>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+</project>
diff --git 
a/amoro-producer/src/main/scala/org/apache/amoro/producer/PaimonExpireSnapshots.scala
 
b/amoro-producer/src/main/scala/org/apache/amoro/producer/PaimonExpireSnapshots.scala
new file mode 100644
index 000000000..66f51245c
--- /dev/null
+++ 
b/amoro-producer/src/main/scala/org/apache/amoro/producer/PaimonExpireSnapshots.scala
@@ -0,0 +1,491 @@
+/*
+ * 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.amoro.producer
+
+import java.util.Locale
+
+import scala.util.control.NonFatal
+
+import org.apache.spark.sql.{Row, SparkSession}
+
+object PaimonExpireSnapshots {
+
+  private val DefaultRetainMax = 20
+  private val DefaultRetainMin = 10
+  private val DefaultMaxDeletes = 550L
+  private val DefaultRetryTimes = 3
+  private val DefaultContinueOnTableFailure = true
+  private val DefaultProcedureOptions =
+    "file-operation.thread-num=32,snapshot.expire.execution-mode=sync"
+
+  final private case class AppConfig(
+      databaseName: Option[String],
+      tableName: Option[String],
+      retainMax: Int,
+      retainMin: Int,
+      maxDeletes: Long,
+      retryTimes: Int,
+      continueOnTableFailure: Boolean,
+      procedureOptions: String)
+
+  final private case class TargetTable(raw: String, database: String, table: 
String)
+
+  def main(args: Array[String]): Unit = {
+    val config =
+      try {
+        val parsed = parseArgs(args)
+        validateConfig(parsed)
+        parsed
+      } catch {
+        case e: IllegalArgumentException =>
+          printUsage()
+          println(s"参数错误: ${e.getMessage}")
+          sys.exit(2)
+      }
+
+    val spark = SparkSession.builder().getOrCreate()
+    try {
+      val targets = resolveTargetTables(spark, config)
+
+      val (targetsByMode, modeDesc) =
+        if (targets.size > 1 || config.tableName.isEmpty) {
+          val databaseName = config.databaseName.getOrElse("unknown")
+          (Some(databaseName), "整库清理")
+        } else {
+          (None, "单表清理")
+        }
+
+      if (targets.nonEmpty) {
+        if (targetsByMode.nonEmpty) {
+          println(s"${modeDesc}启动,数据库 ${targetsByMode.get} 共发现 ${targets.size} 
张表:")
+          targets.foreach(t => println(s"  - ${t.raw}"))
+        } else {
+          println(s"${modeDesc}启动,目标表: ${targets.head.raw}")
+        }
+      }
+
+      var successTables = 0
+      var failedTables = 0
+      val allStartNs = System.nanoTime()
+
+      targets.foreach { table =>
+        try {
+          expireOneTable(spark, config, table)
+          successTables += 1
+        } catch {
+          case NonFatal(t) =>
+            failedTables += 1
+            println(s"[表 ${table.raw} 清理失败] ${t.getClass.getSimpleName}: 
${t.getMessage}")
+            if (!config.continueOnTableFailure) {
+              throw t
+            }
+        }
+      }
+
+      val allCostMs = (System.nanoTime() - allStartNs) / 1e6
+      println(
+        f"[清理汇总] 模式=$modeDesc, successTables=$successTables, 
failedTables=$failedTables, " +
+          f"总耗时=${allCostMs}%.3f ms, 表数=${targets.size}")
+    } finally {
+      spark.stop()
+    }
+  }
+
+  private def resolveTargetTables(spark: SparkSession, config: AppConfig): 
Seq[TargetTable] = {
+    (config.databaseName, config.tableName) match {
+      case (Some(database), Some(table)) =>
+        Seq(makeTarget(
+          requireNonEmpty(database, "--databaseName"),
+          requireNonEmpty(table, "--tableName")))
+
+      case (Some(database), None) =>
+        listTables(spark, requireNonEmpty(database, "--databaseName"))
+
+      case (None, Some(table)) =>
+        val trimTable = requireNonEmpty(table, "--tableName")
+        val parts = trimTable.split("\\.", -1)
+        if (parts.length != 2 || parts.exists(_.trim.isEmpty)) {
+          throw new IllegalArgumentException(
+            "只传入 --tableName 时,必须使用 db.table 格式,例如 --tableName db.table")
+        }
+        if (parts.length == 2) {
+          Seq(makeTarget(parts(0).trim, parts(1).trim))
+        } else {
+          throw new IllegalArgumentException(
+            "只传入 --tableName 时,必须使用 db.table 格式,例如 --tableName db.table")
+        }
+
+      case (None, None) =>
+        throw new IllegalArgumentException("参数不足:请至少提供 --databaseName 或 
--tableName")
+    }
+  }
+
+  private def requireNonEmpty(value: String, argName: String): String = {
+    val trimmed = value.trim
+    if (trimmed.isEmpty) {
+      throw new IllegalArgumentException(s"$argName 的值不能为空")
+    }
+    trimmed
+  }
+
+  private def validateConfig(config: AppConfig): Unit = {
+    (config.databaseName, config.tableName) match {
+      case (Some(database), Some(table)) =>
+        requireNonEmpty(database, "--databaseName")
+        val trimTable = requireNonEmpty(table, "--tableName")
+        if (trimTable.contains(".")) {
+          throw new IllegalArgumentException(
+            "当 --databaseName 与 --tableName 同时提供时,--tableName 仅允许传入表名,不允许使用 
db.table")
+        }
+
+      case (Some(database), None) =>
+        requireNonEmpty(database, "--databaseName")
+
+      case (None, Some(table)) =>
+        val trimTable = requireNonEmpty(table, "--tableName")
+        val parts = trimTable.split("\\.", -1)
+        if (parts.length != 2 || parts.exists(_.trim.isEmpty)) {
+          throw new IllegalArgumentException(
+            "只传入 --tableName 时,必须使用 db.table 格式,例如 --tableName db.table")
+        }
+
+      case (None, None) =>
+        throw new IllegalArgumentException("参数不足:请至少提供 --databaseName 或 
--tableName")
+    }
+
+    if (config.retainMax < 0) {
+      throw new IllegalArgumentException("--retainMax 不能小于 0")
+    }
+    if (config.retainMin < 0) {
+      throw new IllegalArgumentException("--retainMin 不能小于 0")
+    }
+    if (config.maxDeletes < 0) {
+      throw new IllegalArgumentException("--maxDeletes 不能小于 0")
+    }
+    if (config.retryTimes <= 0) {
+      throw new IllegalArgumentException("--retryTimes 必须大于 0")
+    }
+  }
+
+  private def makeTarget(database: String, table: String): TargetTable = {
+    TargetTable(s"${database}.${table}", database, table)
+  }
+
+  private def listTables(spark: SparkSession, database: String): 
Seq[TargetTable] = {
+    val df = spark.sql(s"SHOW TABLES IN ${quoteIdent(database)}")
+    val rows = df.collect()
+    val tables = rows.flatMap { row =>
+      val tableNameOpt = fieldOpt(row, Set("tablename", "table_name", "table"))
+        .orElse(if (row.size > 1) {
+          Option(row.get(1))
+        } else {
+          None
+        })
+        .filter(_ != null)
+        .map(_.toString)
+
+      val isTemporary = fieldOpt(row, Set("istemporary", 
"is_temporary")).exists {
+        case b: java.lang.Boolean => b.booleanValue()
+        case s: String => s.equalsIgnoreCase("true") || s == "1"
+        case i: java.lang.Integer => i == 1
+        case l: java.lang.Long => l == 1L
+        case _ => false
+      }
+
+      tableNameOpt
+        .filter(name => name != null && name.trim.nonEmpty && !isTemporary)
+        .map(name => makeTarget(database, name))
+    }
+    tables.toSeq
+  }
+
+  private def expireOneTable(spark: SparkSession, config: AppConfig, table: 
TargetTable): Unit = {
+    var round = 1
+    var executedRounds = 0
+    var totalCostMs = 0.0
+    var maxCostMs = 0.0
+    var minCostMs = Double.PositiveInfinity
+    var shouldContinue = true
+
+    println(s"========== 表 ${table.raw} 开始清理 ==========")
+    while (shouldContinue) {
+      println(s"========== 表 ${table.raw} 开始第 ${round} 轮过期快照清理 ==========")
+      val roundStartNs = System.nanoTime()
+
+      val deletedCount = runOnceWithRetry(spark, config, table.raw)
+      val roundCostMs = (System.nanoTime() - roundStartNs) / 1e6
+      executedRounds += 1
+      totalCostMs += roundCostMs
+      maxCostMs = math.max(maxCostMs, roundCostMs)
+      minCostMs = math.min(minCostMs, roundCostMs)
+
+      val avgCostMs = totalCostMs / executedRounds
+      shouldContinue = deletedCount >= config.maxDeletes
+
+      println(
+        f"[表 ${table.raw} 第 ${round} 轮统计] " +
+          f"本轮耗时=${roundCostMs}%.3f ms, " +
+          f"平均耗时=${avgCostMs}%.3f ms, " +
+          f"最大耗时=${maxCostMs}%.3f ms, " +
+          f"最小耗时=${minCostMs}%.3f ms, " +
+          f"deletedCount=$deletedCount, continue=$shouldContinue")
+      round += 1
+    }
+
+    println(
+      f"[表 ${table.raw} 清理结束] " +
+        f"总轮次=$executedRounds, " +
+        f"平均耗时=${if (executedRounds == 0) 0.0 else totalCostMs / 
executedRounds}%.3f ms, " +
+        f"最大耗时=${if (executedRounds == 0) 0.0 else maxCostMs}%.3f ms, " +
+        f"最小耗时=${if (executedRounds == 0) 0.0 else minCostMs}%.3f ms")
+  }
+
+  private def runOnceWithRetry(
+      spark: SparkSession,
+      config: AppConfig,
+      tableName: String): Long = {
+    var attempt = 1
+    var lastError: Throwable = null
+
+    while (attempt <= config.retryTimes) {
+      try {
+        val df = spark.sql(buildExpireSql(tableName, config))
+        val rows = df.collect()
+
+        if (rows.isEmpty) {
+          throw new IllegalStateException(s"expire_snapshots 
返回空结果,table=$tableName")
+        }
+
+        val deletedCount = parseDeletedCount(rows(0))
+        println(
+          s"[表 $tableName 当前轮尝试 ${attempt}/${config.retryTimes}] " +
+            s"成功,schema=${df.schema.simpleString}, " +
+            s"row=${rows(0).mkString("[", ", ", "]")}, 
deletedCount=$deletedCount")
+        return deletedCount
+      } catch {
+        case NonFatal(t) =>
+          lastError = t
+          println(
+            s"[表 $tableName 当前轮尝试 ${attempt}/${config.retryTimes}] 失败: " +
+              s"${t.getClass.getSimpleName}: ${t.getMessage}")
+          if (attempt == config.retryTimes) {
+            throw new RuntimeException(
+              s"表 $tableName expire_snapshots 连续失败 ${config.retryTimes} 次",
+              lastError)
+          }
+          attempt += 1
+      }
+    }
+    throw new RuntimeException("不应到达此分支")
+  }
+
+  private def buildExpireSql(tableName: String, config: AppConfig): String = {
+    val escapedTableName = escapeSqlString(tableName)
+    val escapedOptions = escapeSqlString(config.procedureOptions)
+    val optionsSql =
+      if (config.procedureOptions.trim.isEmpty) "" else s", options => 
'$escapedOptions'"
+    s"""
+       |CALL sys.expire_snapshots(
+       |  table => '$escapedTableName',
+       |  retain_max => ${config.retainMax},
+       |  retain_min => ${config.retainMin},
+       |  max_deletes => ${config.maxDeletes}
+       |$optionsSql
+       |)
+       |""".stripMargin
+  }
+
+  private def quoteIdent(s: String): String = "`" + s.replace("`", "``") + "`"
+
+  private def escapeSqlString(value: String): String = value.replace("'", "''")
+
+  private def parseDeletedCount(row: Row): Long = {
+    val preferredNames = Set(
+      "deleted_count",
+      "deletedcount",
+      "deleted_snapshots",
+      "deleted_snapshots_count",
+      "deletedsnapshotcount",
+      "result")
+
+    val byName =
+      try {
+        val schema = row.schema
+        schema.fieldNames.zipWithIndex.collectFirst {
+          case (name, idx) if 
preferredNames.contains(name.toLowerCase(Locale.ROOT)) =>
+            row.get(idx)
+        }.flatMap(toLongOpt)
+      } catch {
+        case NonFatal(_) => None
+      }
+
+    byName
+      .orElse(row.toSeq.flatMap(toLongOpt).headOption)
+      .getOrElse {
+        throw new IllegalStateException(
+          s"无法从返回结果解析删除数量,row=${row.mkString("[", ", ", "]")}")
+      }
+  }
+
+  private def toLongOpt(v: Any): Option[Long] = v match {
+    case null => None
+    case n: java.lang.Number => Some(n.longValue())
+    case s: String if s.trim.matches("-?\\d+") => Some(s.trim.toLong)
+    case _ => None
+  }
+
+  private def fieldOpt(row: Row, names: Set[String]): Option[Any] = {
+    try {
+      val schema = row.schema
+      schema.fieldNames.zipWithIndex.collectFirst {
+        case (name, idx) if names.contains(name.toLowerCase(Locale.ROOT)) => 
row.get(idx)
+      }
+    } catch {
+      case NonFatal(_) => None
+    }
+  }
+
+  private def parseArgs(args: Array[String]): AppConfig = {
+    var databaseName: Option[String] = None
+    var tableName: Option[String] = None
+    var retainMax = DefaultRetainMax
+    var retainMin = DefaultRetainMin
+    var maxDeletes = DefaultMaxDeletes
+    var retryTimes = DefaultRetryTimes
+    var continueOnTableFailure = DefaultContinueOnTableFailure
+    var procedureOptions = DefaultProcedureOptions
+
+    var i = 0
+    while (i < args.length) {
+      args(i) match {
+        case "--help" | "-h" =>
+          printUsage()
+          sys.exit(0)
+        case "--databaseName" =>
+          i += 1
+          if (i >= args.length) {
+            throw new IllegalArgumentException(s"--databaseName 需要值")
+          }
+          databaseName = Some(args(i))
+        case "--tableName" =>
+          i += 1
+          if (i >= args.length) {
+            throw new IllegalArgumentException(s"--tableName 需要值")
+          }
+          tableName = Some(args(i))
+        case "--retainMax" =>
+          i += 1
+          if (i >= args.length) {
+            throw new IllegalArgumentException("--retainMax 需要值")
+          }
+          retainMax = parseIntArg("--retainMax", args(i))
+        case "--retainMin" =>
+          i += 1
+          if (i >= args.length) {
+            throw new IllegalArgumentException("--retainMin 需要值")
+          }
+          retainMin = parseIntArg("--retainMin", args(i))
+        case "--maxDeletes" =>
+          i += 1
+          if (i >= args.length) {
+            throw new IllegalArgumentException("--maxDeletes 需要值")
+          }
+          maxDeletes = parseLongArg("--maxDeletes", args(i))
+        case "--retryTimes" =>
+          i += 1
+          if (i >= args.length) {
+            throw new IllegalArgumentException("--retryTimes 需要值")
+          }
+          retryTimes = parseIntArg("--retryTimes", args(i))
+        case "--continueOnTableFailure" =>
+          i += 1
+          if (i >= args.length) {
+            throw new IllegalArgumentException("--continueOnTableFailure 需要 
true/false")
+          }
+          continueOnTableFailure = parseBooleanArg("--continueOnTableFailure", 
args(i))
+        case "--procedureOptions" =>
+          i += 1
+          if (i >= args.length) {
+            throw new IllegalArgumentException("--procedureOptions 需要值")
+          }
+          procedureOptions = args(i)
+        case value if value.startsWith("--") =>
+          throw new IllegalArgumentException(s"未知参数: $value")
+        case value =>
+          throw new IllegalArgumentException(s"参数格式错误: $value")
+      }
+      i += 1
+    }
+
+    AppConfig(
+      databaseName,
+      tableName,
+      retainMax,
+      retainMin,
+      maxDeletes,
+      retryTimes,
+      continueOnTableFailure,
+      procedureOptions)
+  }
+
+  private def parseIntArg(argName: String, value: String): Int = {
+    try {
+      value.toInt
+    } catch {
+      case NonFatal(_) => throw new IllegalArgumentException(s"$argName 
必须是整数,当前: $value")
+    }
+  }
+
+  private def parseLongArg(argName: String, value: String): Long = {
+    try {
+      value.toLong
+    } catch {
+      case NonFatal(_) => throw new IllegalArgumentException(s"$argName 
必须是整数,当前: $value")
+    }
+  }
+
+  private def parseBooleanArg(argName: String, value: String): Boolean = {
+    value.toLowerCase(Locale.ROOT) match {
+      case "true" | "1" | "yes" => true
+      case "false" | "0" | "no" => false
+      case _ =>
+        throw new IllegalArgumentException(
+          s"$argName 仅支持 true/false/1/0/yes/no,当前: $value")
+    }
+  }
+
+  private def printUsage(): Unit = {
+    println(
+      """
+        |Usage:
+        |  spark-submit --class 
org.apache.amoro.producer.PaimonExpireSnapshots <jar> [options]
+        |
+        |Options:
+        |  --databaseName <database>    指定数据库名(与 --tableName 不同时提供则扫描全库)
+        |  --tableName <table>          指定表名(与 --databaseName 
同时使用时只清理该表;仅单独使用时需 db.table)
+        |  --retainMax <int>            保留快照数上限,默认 20
+        |  --retainMin <int>            保留快照下限,默认 10
+        |  --maxDeletes <long>          单次最多删除快照数,默认 550
+        |  --retryTimes <int>           SQL 重试次数,默认 3
+        |  --continueOnTableFailure <bool> 出错是否继续,默认 true
+        |  --procedureOptions <string>   expire_snapshots options 字符串,默认 
file-operation.thread-num=32,snapshot.expire.execution-mode=sync
+        |  --help, -h                   打印帮助
+        |""".stripMargin)
+  }
+}
diff --git a/pom.xml b/pom.xml
index 6ed534fb1..622a0538a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -54,6 +54,7 @@
         <module>amoro-format-mixed</module>
         <module>amoro-format-paimon</module>
         <module>dist</module>
+        <module>amoro-producer</module>
     </modules>
 
     <scm>


Reply via email to