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 494ad6fe550dcb44bc7ce306a4bf4c02379de2ae
Author: ConradJam <[email protected]>
AuthorDate: Wed Jun 17 11:08:06 2026 +0800

    [JSPT-2553][feat] 小文件优化:新增Paimon孤儿文件独立清理脚本
---
 amoro-producer/README.md                           | 183 +++++++-
 .../amoro/producer/PaimonRemoveOrphanFiles.scala   | 478 +++++++++++++++++++++
 2 files changed, 648 insertions(+), 13 deletions(-)

diff --git a/amoro-producer/README.md b/amoro-producer/README.md
index 46e49bd99..57c88cfe9 100644
--- a/amoro-producer/README.md
+++ b/amoro-producer/README.md
@@ -15,11 +15,12 @@
  - limitations under the License.
  -->
 
-# Amoro Producer Snapshot 清理任务
+# Amoro Producer Paimon 清理任务
 
-`amoro-producer` 提供可通过 `spark-submit` 提交的 Paimon Snapshot 清理任务。入口类为
-`org.apache.amoro.producer.PaimonExpireSnapshots`,任务会通过 Spark SQL 执行
-`CALL sys.expire_snapshots`。
+`amoro-producer` 提供可通过 `spark-submit` 提交的 Paimon 清理任务:
+
+- Snapshot 清理入口类为 `org.apache.amoro.producer.PaimonExpireSnapshots`,任务会通过 
Spark SQL 执行 `CALL sys.expire_snapshots`。
+- 孤儿文件清理入口类为 `org.apache.amoro.producer.PaimonRemoveOrphanFiles`,任务会通过 Spark 
SQL 执行 `CALL <catalog>.sys.remove_orphan_files`。
 
 ## 打包
 
@@ -33,7 +34,7 @@
 amoro-producer/target/amoro-producer-0.9-SNAPSHOT-jar-with-dependencies.jar
 ```
 
-## 参数说明
+## Snapshot 清理参数说明
 
 | 参数 | 是否必填 | 默认值 | 说明 |
 |------|----------|--------|------|
@@ -57,11 +58,28 @@ 
amoro-producer/target/amoro-producer-0.9-SNAPSHOT-jar-with-dependencies.jar
 | `--tableName t1` | 非法参数,任务打印 usage 并以退出码 `2` 结束。 |
 | `--databaseName db --tableName db.t1` | 非法参数,任务打印 usage 并以退出码 `2` 结束。 |
 
-## Spark Submit Demo
+## 孤儿文件清理参数说明
+
+| 参数 | 是否必填 | 默认值 | 说明 |
+|------|----------|--------|------|
+| `--catalogName` | 否 | `paimon` | Paimon catalog 名称。任务会使用该 catalog 扫表并调用 
`remove_orphan_files`。 |
+| `--databaseName` | 否 | 无 | 数据库名。仅传该参数时,任务会扫描并清理该库下全部非临时表。 |
+| `--tableName` | 否 | 无 | 表名。与 `--databaseName` 同时传入时,只清理指定表;单独传入时必须使用 
`db.table` 格式。 |
+| `--parallelism` | 否 | `10` | 传给 `remove_orphan_files` 的 `parallelism`。 |
+| `--mode` | 否 | `distributed` | 传给 `remove_orphan_files` 的 `mode`。 |
+| `--retryTimes` | 否 | `3` | 单表 `remove_orphan_files` SQL 失败后的最大执行次数。 |
+| `--continueOnTableFailure` | 否 | `true` | 单表清理失败后是否继续处理后续表。 |
+| `--help` / `-h` | 否 | 无 | 打印帮助信息。 |
+
+孤儿文件清理的目标表参数组合规则与 Snapshot 清理保持一致。
+
+## Snapshot 清理 Spark Submit Demo
 
 以下示例基于 YARN cluster 模式提交,主类替换为
 `org.apache.amoro.producer.PaimonExpireSnapshots`,应用 JAR 替换为
-`amoro-producer-0.9-SNAPSHOT-jar-with-dependencies.jar`。
+`amoro-producer-0.9-SNAPSHOT-jar-with-dependencies.jar`。示例中显式声明
+Paimon catalog,并通过 `spark.sql.defaultCatalog=paimon` 让 `--databaseName`
+按 Paimon catalog 下的库解析。
 
 ### 按数据库清理
 
@@ -71,12 +89,12 @@ 
amoro-producer/target/amoro-producer-0.9-SNAPSHOT-jar-with-dependencies.jar
     --deploy-mode cluster \
     --driver-memory 8g \
     --driver-cores 4 \
-    --executor-memory 16g \
+    --executor-memory 4g \
     --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.initialExecutors=1 \
     --conf spark.dynamicAllocation.shuffleTracking.enabled=true \
     --conf spark.driver.memoryOverhead=2g \
     --conf spark.executor.memoryOverhead=2g \
@@ -104,8 +122,14 @@ 
amoro-producer/target/amoro-producer-0.9-SNAPSHOT-jar-with-dependencies.jar
     --conf spark.locality.wait.node=0s \
     --conf spark.sql.sources.ignoreDataLocality=true \
     --conf spark.driver.host=10.89.56.103 \
+    --conf spark.sql.catalog.paimon=org.apache.paimon.spark.SparkCatalog \
+    --conf 
spark.sql.extensions=org.apache.paimon.spark.extensions.PaimonSparkSessionExtensions
 \
+    --conf spark.sql.catalog.paimon.type=hive \
+    --conf 
spark.sql.catalog.paimon.warehouse=hdfs://slcluster01/hive_warehouse \
     --conf spark.hadoop.net.topology.script.file.name=/dev/null \
+    --conf spark.sql.defaultCatalog=paimon \
     --keytab /data/work/hadoop-shopline/etc/hadoop/sljdp.keytab \
+    --jars 
/tmp/paimon-hive-connector-3.1-1.5-SNAPSHOT.jar,/tmp/paimon-spark-3.5_2.12-1.5-SNAPSHOT.jar
 \
     --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
 \
@@ -118,7 +142,7 @@ 
amoro-producer/target/amoro-producer-0.9-SNAPSHOT-jar-with-dependencies.jar
     --procedureOptions 
file-operation.thread-num=32,snapshot.expire.execution-mode=sync
 ```
 
-### 按单表清理
+### Snapshot 按单表清理
 
 ```bash
 ./spark-submit \
@@ -126,12 +150,12 @@ 
amoro-producer/target/amoro-producer-0.9-SNAPSHOT-jar-with-dependencies.jar
     --deploy-mode cluster \
     --driver-memory 8g \
     --driver-cores 4 \
-    --executor-memory 16g \
+    --executor-memory 4g \
     --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.initialExecutors=1 \
     --conf spark.dynamicAllocation.shuffleTracking.enabled=true \
     --conf spark.driver.memoryOverhead=2g \
     --conf spark.executor.memoryOverhead=2g \
@@ -159,8 +183,14 @@ 
amoro-producer/target/amoro-producer-0.9-SNAPSHOT-jar-with-dependencies.jar
     --conf spark.locality.wait.node=0s \
     --conf spark.sql.sources.ignoreDataLocality=true \
     --conf spark.driver.host=10.89.56.103 \
+    --conf spark.sql.catalog.paimon=org.apache.paimon.spark.SparkCatalog \
+    --conf 
spark.sql.extensions=org.apache.paimon.spark.extensions.PaimonSparkSessionExtensions
 \
+    --conf spark.sql.catalog.paimon.type=hive \
+    --conf 
spark.sql.catalog.paimon.warehouse=hdfs://slcluster01/hive_warehouse \
     --conf spark.hadoop.net.topology.script.file.name=/dev/null \
+    --conf spark.sql.defaultCatalog=paimon \
     --keytab /data/work/hadoop-shopline/etc/hadoop/sljdp.keytab \
+    --jars 
/tmp/paimon-hive-connector-3.1-1.5-SNAPSHOT.jar,/tmp/paimon-spark-3.5_2.12-1.5-SNAPSHOT.jar
 \
     --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
 \
@@ -174,10 +204,137 @@ 
amoro-producer/target/amoro-producer-0.9-SNAPSHOT-jar-with-dependencies.jar
     --procedureOptions 
file-operation.thread-num=32,snapshot.expire.execution-mode=sync
 ```
 
+## 孤儿文件清理 Spark Submit Demo
+
+孤儿文件清理入口显式接收 `--catalogName`,示例中使用 `paimon`。如果生产环境
+catalog 名称不同,只需要调整 `--catalogName` 以及对应的
+`spark.sql.catalog.<catalog>` 配置。
+
+### 孤儿文件按数据库清理
+
+```bash
+./spark-submit \
+    --master yarn \
+    --deploy-mode cluster \
+    --driver-memory 8g \
+    --driver-cores 4 \
+    --executor-memory 4g \
+    --executor-cores 4 \
+    --conf spark.dynamicAllocation.enabled=true \
+    --conf spark.dynamicAllocation.minExecutors=1 \
+    --conf spark.dynamicAllocation.maxExecutors=2 \
+    --conf spark.dynamicAllocation.initialExecutors=1 \
+    --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.sql.catalog.paimon=org.apache.paimon.spark.SparkCatalog \
+    --conf 
spark.sql.extensions=org.apache.paimon.spark.extensions.PaimonSparkSessionExtensions
 \
+    --conf spark.sql.catalog.paimon.type=hive \
+    --conf 
spark.sql.catalog.paimon.warehouse=hdfs://slcluster01/hive_warehouse \
+    --conf spark.hadoop.net.topology.script.file.name=/dev/null \
+    --conf spark.sql.defaultCatalog=paimon \
+    --keytab /data/work/hadoop-shopline/etc/hadoop/sljdp.keytab \
+    --jars 
/tmp/paimon-hive-connector-3.1-1.5-SNAPSHOT.jar,/tmp/paimon-spark-3.5_2.12-1.5-SNAPSHOT.jar
 \
+    --principal [email protected] \
+    --class org.apache.amoro.producer.PaimonRemoveOrphanFiles \
+    
/data/work/frame/amoro-0.9-SNAPSHOT/plugin/producer/amoro-producer-0.9-SNAPSHOT-jar-with-dependencies.jar
 \
+    --catalogName paimon \
+    --databaseName sl_oki_test \
+    --parallelism 10 \
+    --mode distributed \
+    --retryTimes 3 \
+    --continueOnTableFailure true
+```
+
+### 孤儿文件按单表清理
+
+```bash
+./spark-submit \
+    --master yarn \
+    --deploy-mode cluster \
+    --driver-memory 8g \
+    --driver-cores 4 \
+    --executor-memory 4g \
+    --executor-cores 4 \
+    --conf spark.dynamicAllocation.enabled=true \
+    --conf spark.dynamicAllocation.minExecutors=1 \
+    --conf spark.dynamicAllocation.maxExecutors=2 \
+    --conf spark.dynamicAllocation.initialExecutors=1 \
+    --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.sql.catalog.paimon=org.apache.paimon.spark.SparkCatalog \
+    --conf 
spark.sql.extensions=org.apache.paimon.spark.extensions.PaimonSparkSessionExtensions
 \
+    --conf spark.sql.catalog.paimon.type=hive \
+    --conf 
spark.sql.catalog.paimon.warehouse=hdfs://slcluster01/hive_warehouse \
+    --conf spark.hadoop.net.topology.script.file.name=/dev/null \
+    --conf spark.sql.defaultCatalog=paimon \
+    --keytab /data/work/hadoop-shopline/etc/hadoop/sljdp.keytab \
+    --jars 
/tmp/paimon-hive-connector-3.1-1.5-SNAPSHOT.jar,/tmp/paimon-spark-3.5_2.12-1.5-SNAPSHOT.jar
 \
+    --principal [email protected] \
+    --class org.apache.amoro.producer.PaimonRemoveOrphanFiles \
+    
/data/work/frame/amoro-0.9-SNAPSHOT/plugin/producer/amoro-producer-0.9-SNAPSHOT-jar-with-dependencies.jar
 \
+    --catalogName paimon \
+    --databaseName sl_oki_test \
+    --tableName t_order \
+    --parallelism 10 \
+    --mode distributed \
+    --retryTimes 3 \
+    --continueOnTableFailure true
+```
+
 ## 运行注意事项
 
 Spark 运行环境必须提前配置好 Paimon catalog,否则 `SHOW TABLES` 或
-`CALL sys.expire_snapshots` 会在运行期失败。
+`CALL sys.expire_snapshots`、`CALL <catalog>.sys.remove_orphan_files` 会在运行期失败。
 
 如果运行环境已经通过 Spark 安装目录或平台层提供 Paimon 相关 JAR,可以直接提交
 `amoro-producer-0.9-SNAPSHOT.jar`;如果希望单 JAR 提交,使用
diff --git 
a/amoro-producer/src/main/scala/org/apache/amoro/producer/PaimonRemoveOrphanFiles.scala
 
b/amoro-producer/src/main/scala/org/apache/amoro/producer/PaimonRemoveOrphanFiles.scala
new file mode 100644
index 000000000..4a754876f
--- /dev/null
+++ 
b/amoro-producer/src/main/scala/org/apache/amoro/producer/PaimonRemoveOrphanFiles.scala
@@ -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.amoro.producer
+
+import java.util.Locale
+
+import scala.util.control.NonFatal
+
+import org.apache.spark.sql.{Row, SparkSession}
+
+object PaimonRemoveOrphanFiles {
+
+  private val DefaultCatalogName = "paimon"
+  private val DefaultParallelism = 10
+  private val DefaultMode = "distributed"
+  private val DefaultRetryTimes = 3
+  private val DefaultContinueOnTableFailure = true
+
+  final private case class AppConfig(
+      catalogName: String,
+      databaseName: Option[String],
+      tableName: Option[String],
+      parallelism: Int,
+      mode: String,
+      retryTimes: Int,
+      continueOnTableFailure: Boolean)
+
+  final private case class TargetTable(raw: String, database: String, table: 
String)
+
+  final private case class CleanResult(
+      deletedFileCount: Long,
+      deletedFileTotalLenInBytes: Long,
+      costMs: Double)
+
+  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 modeDesc =
+        if (targets.size > 1 || config.tableName.isEmpty) {
+          "整库孤儿文件清理"
+        } else {
+          "单表孤儿文件清理"
+        }
+
+      if (targets.nonEmpty) {
+        if (targets.size > 1 || config.tableName.isEmpty) {
+          val databaseName = 
config.databaseName.getOrElse(targets.head.database)
+          println(
+            s"${modeDesc}启动,catalog=${config.catalogName}, 数据库 $databaseName 
共发现 ${targets.size} 张表:")
+          targets.foreach(t => println(s"  - ${t.raw}"))
+        } else {
+          println(s"${modeDesc}启动,catalog=${config.catalogName}, 目标表: 
${targets.head.raw}")
+        }
+      }
+
+      var successTables = 0
+      var failedTables = 0
+      var totalDeletedFileCount = 0L
+      var totalDeletedFileTotalLenInBytes = 0L
+      val allStartNs = System.nanoTime()
+
+      targets.foreach { table =>
+        try {
+          val result = removeOneTable(spark, config, table)
+          successTables += 1
+          totalDeletedFileCount += result.deletedFileCount
+          totalDeletedFileTotalLenInBytes += result.deletedFileTotalLenInBytes
+        } 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("=========== ORPHAN FILES CLEAN SUMMARY ===========")
+      println(s"catalog              = ${config.catalogName}")
+      println(s"mode                 = $modeDesc")
+      println(s"tables scanned       = ${targets.size}")
+      println(s"tables success       = $successTables")
+      println(s"tables failed        = $failedTables")
+      println(s"TOTAL deleted files  = $totalDeletedFileCount")
+      println(
+        s"TOTAL deleted bytes  = $totalDeletedFileTotalLenInBytes 
(${humanBytes(totalDeletedFileTotalLenInBytes)})")
+      println(f"total cost           = ${allCostMs}%.3f ms")
+      println("==================================================")
+    } 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, config.catalogName, 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")
+        }
+        Seq(makeTarget(parts(0).trim, parts(1).trim))
+
+      case (None, None) =>
+        throw new IllegalArgumentException("参数不足:请至少提供 --databaseName 或 
--tableName")
+    }
+  }
+
+  private def listTables(
+      spark: SparkSession,
+      catalogName: String,
+      database: String): Seq[TargetTable] = {
+    val showTablesSql = s"SHOW TABLES IN 
${quoteIdent(catalogName)}.${quoteIdent(database)}"
+    println(s"listing tables: $showTablesSql")
+
+    val rows = spark.sql(showTablesSql).collect()
+    rows.flatMap { row =>
+      val tableNameOpt = fieldOpt(row, Set("tablename", "table"))
+        .orElse(valueAt(row, 1))
+        .filter(_ != null)
+        .map(_.toString.trim)
+
+      val isTemporary = fieldOpt(row, Set("istemporary", "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.nonEmpty && !isTemporary)
+        .map(name => makeTarget(database, name))
+    }.toSeq
+  }
+
+  private def removeOneTable(
+      spark: SparkSession,
+      config: AppConfig,
+      table: TargetTable): CleanResult = {
+    println(s"========== 表 ${table.raw} 开始孤儿文件清理 ==========")
+    val startNs = System.nanoTime()
+    val (deletedFileCount, deletedFileTotalLenInBytes) = 
runOnceWithRetry(spark, config, table)
+    val costMs = (System.nanoTime() - startNs) / 1e6
+
+    println(
+      f"[表 ${table.raw} 孤儿文件清理结束] " +
+        f"耗时=${costMs}%.3f ms, " +
+        f"deletedFileCount=$deletedFileCount, " +
+        f"deletedFileTotalLenInBytes=$deletedFileTotalLenInBytes, " +
+        f"humanDeletedBytes=${humanBytes(deletedFileTotalLenInBytes)}")
+
+    CleanResult(deletedFileCount, deletedFileTotalLenInBytes, costMs)
+  }
+
+  private def runOnceWithRetry(
+      spark: SparkSession,
+      config: AppConfig,
+      table: TargetTable): (Long, Long) = {
+    var attempt = 1
+    var lastError: Throwable = null
+
+    while (attempt <= config.retryTimes) {
+      try {
+        println(s"[表 ${table.raw}] 第 $attempt 次执行 remove_orphan_files")
+        return runRemoveOrphanFiles(spark, config, table)
+      } catch {
+        case NonFatal(t) =>
+          lastError = t
+          println(
+            s"[表 ${table.raw}] 第 $attempt 次执行失败: ${t.getClass.getSimpleName}: 
${t.getMessage}")
+          attempt += 1
+      }
+    }
+
+    throw lastError
+  }
+
+  private def runRemoveOrphanFiles(
+      spark: SparkSession,
+      config: AppConfig,
+      table: TargetTable): (Long, Long) = {
+    val sqlText = buildRemoveOrphanFilesSql(config, table)
+    println(s"executing remove_orphan_files, table = ${table.raw}")
+    println(sqlText)
+
+    val resultRows = spark.sql(sqlText).collect()
+    var deletedFileCount = 0L
+    var deletedFileTotalLenInBytes = 0L
+
+    resultRows.foreach { row =>
+      val countValue = fieldOpt(row, Set("deletedfilecount"))
+        .orElse(valueAt(row, 0))
+        .getOrElse(0L)
+      val bytesValue = fieldOpt(row, Set("deletedfiletotalleninbytes"))
+        .orElse(valueAt(row, 1))
+        .getOrElse(0L)
+
+      deletedFileCount += toLong(countValue)
+      deletedFileTotalLenInBytes += toLong(bytesValue)
+    }
+
+    println(
+      s"SUCCESS table = ${table.raw}, deletedFileCount = $deletedFileCount, " +
+        s"deletedFileTotalLenInBytes = $deletedFileTotalLenInBytes")
+    (deletedFileCount, deletedFileTotalLenInBytes)
+  }
+
+  private def buildRemoveOrphanFilesSql(config: AppConfig, table: 
TargetTable): String = {
+    s"""
+       |CALL ${quoteIdent(config.catalogName)}.sys.remove_orphan_files(
+       |  table => ${sqlString(table.raw)},
+       |  parallelism => ${config.parallelism},
+       |  mode => ${sqlString(config.mode)}
+       |)
+       |""".stripMargin
+  }
+
+  private def makeTarget(database: String, table: String): TargetTable = {
+    TargetTable(s"${database}.${table}", database, table)
+  }
+
+  private def validateConfig(config: AppConfig): Unit = {
+    requireNonEmpty(config.catalogName, "--catalogName")
+
+    (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.parallelism <= 0) {
+      throw new IllegalArgumentException("--parallelism 必须大于 0")
+    }
+    requireNonEmpty(config.mode, "--mode")
+    if (config.retryTimes <= 0) {
+      throw new IllegalArgumentException("--retryTimes 必须大于 0")
+    }
+  }
+
+  private def parseArgs(args: Array[String]): AppConfig = {
+    var catalogName = DefaultCatalogName
+    var databaseName: Option[String] = None
+    var tableName: Option[String] = None
+    var parallelism = DefaultParallelism
+    var mode = DefaultMode
+    var retryTimes = DefaultRetryTimes
+    var continueOnTableFailure = DefaultContinueOnTableFailure
+
+    var i = 0
+    while (i < args.length) {
+      args(i) match {
+        case "--help" | "-h" =>
+          printUsage()
+          sys.exit(0)
+
+        case "--catalogName" =>
+          catalogName = readValue(args, i, "--catalogName")
+          i += 1
+
+        case "--databaseName" =>
+          databaseName = Some(readValue(args, i, "--databaseName"))
+          i += 1
+
+        case "--tableName" =>
+          tableName = Some(readValue(args, i, "--tableName"))
+          i += 1
+
+        case "--parallelism" =>
+          parallelism = parseInt(readValue(args, i, "--parallelism"), 
"--parallelism")
+          i += 1
+
+        case "--mode" =>
+          mode = readValue(args, i, "--mode")
+          i += 1
+
+        case "--retryTimes" =>
+          retryTimes = parseInt(readValue(args, i, "--retryTimes"), 
"--retryTimes")
+          i += 1
+
+        case "--continueOnTableFailure" =>
+          continueOnTableFailure =
+            parseBoolean(readValue(args, i, "--continueOnTableFailure"), 
"--continueOnTableFailure")
+          i += 1
+
+        case other =>
+          throw new IllegalArgumentException(s"未知参数: $other")
+      }
+      i += 1
+    }
+
+    AppConfig(
+      catalogName,
+      databaseName,
+      tableName,
+      parallelism,
+      mode,
+      retryTimes,
+      continueOnTableFailure)
+  }
+
+  private def readValue(args: Array[String], index: Int, argName: String): 
String = {
+    if (index + 1 >= args.length || args(index + 1).startsWith("--")) {
+      throw new IllegalArgumentException(s"$argName 缺少参数值")
+    }
+    args(index + 1)
+  }
+
+  private def parseInt(value: String, argName: String): Int = {
+    try {
+      value.toInt
+    } catch {
+      case _: NumberFormatException =>
+        throw new IllegalArgumentException(s"$argName 必须是整数")
+    }
+  }
+
+  private def parseBoolean(value: String, argName: String): Boolean = {
+    value.trim.toLowerCase(Locale.ROOT) match {
+      case "true" => true
+      case "false" => false
+      case _ => throw new IllegalArgumentException(s"$argName 必须是 true 或 
false")
+    }
+  }
+
+  private def requireNonEmpty(value: String, argName: String): String = {
+    val trimmed = value.trim
+    if (trimmed.isEmpty) {
+      throw new IllegalArgumentException(s"$argName 的值不能为空")
+    }
+    trimmed
+  }
+
+  private def quoteIdent(name: String): String = {
+    "`" + name.replace("`", "``") + "`"
+  }
+
+  private def sqlString(value: String): String = {
+    "'" + value.replace("'", "''") + "'"
+  }
+
+  private def fieldOpt(row: Row, normalizedNames: Set[String]): Option[Any] = {
+    val schemaOpt =
+      try {
+        Option(row.schema)
+      } catch {
+        case NonFatal(_) => None
+      }
+
+    schemaOpt.flatMap { schema =>
+      schema.fields.zipWithIndex
+        .find { case (field, _) => 
normalizedNames.contains(normalizeFieldName(field.name)) }
+        .flatMap { case (_, index) => valueAt(row, index) }
+    }
+  }
+
+  private def valueAt(row: Row, index: Int): Option[Any] = {
+    if (index >= 0 && index < row.size && !row.isNullAt(index)) {
+      Some(row.get(index))
+    } else {
+      None
+    }
+  }
+
+  private def normalizeFieldName(name: String): String = {
+    name.toLowerCase(Locale.ROOT).replace("_", "")
+  }
+
+  private def toLong(value: Any): Long = {
+    value match {
+      case null => 0L
+      case n: java.lang.Number => n.longValue()
+      case s: String =>
+        val trimmed = s.trim
+        if (trimmed.isEmpty) {
+          0L
+        } else {
+          trimmed.toLong
+        }
+      case other => other.toString.toLong
+    }
+  }
+
+  private def humanBytes(bytes: Long): String = {
+    val units = Array("B", "KB", "MB", "GB", "TB", "PB")
+    var value = bytes.toDouble
+    var index = 0
+    while (value >= 1024.0 && index < units.length - 1) {
+      value = value / 1024.0
+      index += 1
+    }
+    f"$value%.2f ${units(index)}"
+  }
+
+  private def printUsage(): Unit = {
+    println(
+      s"""
+         |Usage:
+         |  spark-submit --class 
org.apache.amoro.producer.PaimonRemoveOrphanFiles <jar> [options]
+         |
+         |Required target options:
+         |  --databaseName <db>                 清理指定库下全部非临时表
+         |  --databaseName <db> --tableName <t> 只清理指定库下的单表
+         |  --tableName <db.table>              只清理全限定单表
+         |
+         |Optional options:
+         |  --catalogName <catalog>             Paimon catalog 名称,默认: 
$DefaultCatalogName
+         |  --parallelism <num>                 remove_orphan_files 
parallelism,默认: $DefaultParallelism
+         |  --mode <mode>                       remove_orphan_files mode,默认: 
$DefaultMode
+         |  --retryTimes <num>                  单表失败最大执行次数,默认: 
$DefaultRetryTimes
+         |  --continueOnTableFailure <true|false>
+         |                                      单表失败后是否继续后续表,默认: 
$DefaultContinueOnTableFailure
+         |  --help, -h                          打印帮助信息
+         |
+         |Examples:
+         |  --databaseName sl_oki_test
+         |  --databaseName sl_oki_test --tableName t_order
+         |  --tableName sl_oki_test.t_order
+         |""".stripMargin)
+  }
+}

Reply via email to