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

danny0405 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git


The following commit(s) were added to refs/heads/master by this push:
     new 63a1ab19254f feat(spark): add repair_orphan_files stored procedure 
(#19121)
63a1ab19254f is described below

commit 63a1ab19254fe46f395b4b3ae81ae0c088499626
Author: Mahsood Ebrahim <[email protected]>
AuthorDate: Tue Jun 30 22:55:30 2026 -0700

    feat(spark): add repair_orphan_files stored procedure (#19121)
    
    Add a `repair_orphan_files` Spark SQL stored procedure that finds and
    optionally removes orphan data files - files present on the filesystem but
    not referenced by any commit (active or archived). This makes orphan-file
    detection and cleanup accessible from any Spark SQL session, reusing the
    same detection logic (org.apache.hudi.table.repair.RepairUtils) that backs
    the HoodieRepairTool spark-submit utility.
    
    Highlights:
    - Dry-run (view) mode by default; cleanup mode moves orphans to backup_path.
    - Per-partition scoping via `partition =>` for very large tables; detection
      runs one Spark task per partition (listing + classification) so only 
orphan
      candidates are collected to the driver.
    - Optional archived_start_ts / archived_end_ts to scope the instants 
considered.
    - max_orphans cap (default 100000) that fails fast with an actionable 
message
      instead of risking driver OOM when collecting candidates.
    - Metadata-table safety cross-check: candidates still visible in the MDT are
      surfaced as SKIPPED_PRESENT_IN_MDT rows rather than removed.
    - Structured logging of BACKUP_FAILED root causes.
    
    Handles COW (base files) and MOR (base + log files) and all commit action
    types (COMMIT, DELTA_COMMIT, REPLACE_COMMIT). Registered in 
HoodieProcedures.
    
    Adds TestRepairOrphanFilesProcedure covering dry-run, cleanup + backup, MOR
    log-file detection, inflight-commit exclusion, backup_path validation,
    partition scoping, and the max_orphans cap.
    
    Co-authored-by: mahsoode <[email protected]>
    Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
 .../hudi/command/procedures/HoodieProcedures.scala |   1 +
 .../procedures/RepairOrphanFilesProcedure.scala    | 336 +++++++++++++++++++++
 .../procedure/TestRepairOrphanFilesProcedure.scala | 256 ++++++++++++++++
 3 files changed, 593 insertions(+)

diff --git 
a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/HoodieProcedures.scala
 
b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/HoodieProcedures.scala
index 4fefef5ec65c..f602b6506264 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/HoodieProcedures.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/HoodieProcedures.scala
@@ -86,6 +86,7 @@ object HoodieProcedures {
       ,(RepairDeduplicateProcedure.NAME, RepairDeduplicateProcedure.builder)
       ,(RepairMigratePartitionMetaProcedure.NAME, 
RepairMigratePartitionMetaProcedure.builder)
       ,(RepairOverwriteHoodiePropsProcedure.NAME, 
RepairOverwriteHoodiePropsProcedure.builder)
+      ,(RepairOrphanFilesProcedure.NAME, RepairOrphanFilesProcedure.builder)
       ,(RunCleanProcedure.NAME, RunCleanProcedure.builder)
       ,(ValidateHoodieSyncProcedure.NAME, ValidateHoodieSyncProcedure.builder)
       ,(ShowInvalidParquetProcedure.NAME, ShowInvalidParquetProcedure.builder)
diff --git 
a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RepairOrphanFilesProcedure.scala
 
b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RepairOrphanFilesProcedure.scala
new file mode 100644
index 000000000000..2d15215f0c66
--- /dev/null
+++ 
b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RepairOrphanFilesProcedure.scala
@@ -0,0 +1,336 @@
+/*
+ * 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.spark.sql.hudi.command.procedures
+
+import org.apache.hudi.common.config.HoodieMetadataConfig
+import org.apache.hudi.common.engine.HoodieLocalEngineContext
+import org.apache.hudi.common.fs.FSUtils
+import org.apache.hudi.common.table.HoodieTableMetaClient
+import org.apache.hudi.metadata.{FileSystemBackedTableMetadata, 
HoodieBackedTableMetadata}
+import org.apache.hudi.storage.{HoodieStorageUtils, StoragePath, 
StoragePathInfo}
+import org.apache.hudi.table.repair.RepairUtils
+
+import org.apache.spark.internal.Logging
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.types.{DataTypes, Metadata, StructField, 
StructType}
+
+import java.util.function.Supplier
+
+import scala.collection.JavaConverters._
+
+/**
+ * Spark SQL stored procedure that finds and optionally removes orphan data 
files — files
+ * that exist on the filesystem but are not referenced by any commit (active 
or archived).
+ *
+ * Handles COW (base files) and MOR (base + log files), and all commit action 
types
+ * (COMMIT, DELTA_COMMIT, REPLACE_COMMIT). The detection reuses
+ * [[org.apache.hudi.table.repair.RepairUtils]], the same logic that backs the
+ * `HoodieRepairTool` spark-submit utility, so results are consistent with 
that tool.
+ *
+ * Usage:
+ * {{{
+ * -- View mode (default): list orphan files without touching them
+ * CALL repair_orphan_files(table => 'my_table')
+ *
+ * -- Scoped to one partition
+ * CALL repair_orphan_files(table => 'my_table', partition => '2024/01/15')
+ *
+ * -- Cleanup: move orphan files to a backup location
+ * CALL repair_orphan_files(
+ *   table       => 'my_table',
+ *   dry_run     => false,
+ *   backup_path => '/user/hudi/orphan_files_backup'
+ * )
+ * }}}
+ *
+ * For very large tables, scope to one partition at a time using `partition 
=>` to avoid
+ * collecting all orphan paths to the driver at once. The `max_orphans` 
parameter (default
+ * 100,000) acts as a safety cap: if the detected count exceeds it the 
procedure fails with
+ * a clear error instead of silently causing a driver OOM.
+ */
+class RepairOrphanFilesProcedure extends BaseProcedure with ProcedureBuilder 
with Logging {
+
+  private val PARAMETERS = Array[ProcedureParameter](
+    ProcedureParameter.optional(0, "table",              DataTypes.StringType, 
  null),
+    ProcedureParameter.optional(1, "path",               DataTypes.StringType, 
  null),
+    ProcedureParameter.optional(2, "partition",          DataTypes.StringType, 
  ""),
+    ProcedureParameter.optional(3, "dry_run",            
DataTypes.BooleanType,  true),
+    ProcedureParameter.optional(4, "backup_path",        DataTypes.StringType, 
  ""),
+    ProcedureParameter.optional(5, "archived_start_ts",  DataTypes.StringType, 
  ""),
+    ProcedureParameter.optional(6, "archived_end_ts",    DataTypes.StringType, 
  ""),
+    ProcedureParameter.optional(7, "max_orphans",        
DataTypes.IntegerType,  100000)
+  )
+
+  private val OUTPUT_TYPE = new StructType(Array[StructField](
+    StructField("partition",    DataTypes.StringType, nullable = true, 
Metadata.empty),
+    StructField("file_name",    DataTypes.StringType, nullable = true, 
Metadata.empty),
+    StructField("instant_time", DataTypes.StringType, nullable = true, 
Metadata.empty),
+    StructField("backup_path",  DataTypes.StringType, nullable = true, 
Metadata.empty),
+    StructField("status",       DataTypes.StringType, nullable = true, 
Metadata.empty)
+  ))
+
+  def parameters: Array[ProcedureParameter] = PARAMETERS
+
+  def outputType: StructType = OUTPUT_TYPE
+
+  override def call(args: ProcedureArgs): Seq[Row] = {
+    super.checkArgs(PARAMETERS, args)
+
+    val tableName       = getArgValueOrDefault(args, PARAMETERS(0))
+    val tablePathOpt    = getArgValueOrDefault(args, PARAMETERS(1))
+    val partition       = getArgValueOrDefault(args, 
PARAMETERS(2)).get.asInstanceOf[String]
+    val dryRun          = getArgValueOrDefault(args, 
PARAMETERS(3)).get.asInstanceOf[Boolean]
+    val backupPath      = getArgValueOrDefault(args, 
PARAMETERS(4)).get.asInstanceOf[String]
+    val archivedStartTs = getArgValueOrDefault(args, 
PARAMETERS(5)).get.asInstanceOf[String]
+    val archivedEndTs   = getArgValueOrDefault(args, 
PARAMETERS(6)).get.asInstanceOf[String]
+    val maxOrphans      = getArgValueOrDefault(args, 
PARAMETERS(7)).get.asInstanceOf[Int]
+
+    if (!dryRun && backupPath.isEmpty) {
+      throw new IllegalArgumentException("backup_path is required when dry_run 
is false")
+    }
+
+    // Phase 1: Partition listing (driver)
+    val basePath   = getBasePath(tableName, tablePathOpt)
+    val metaClient = createMetaClient(jsc, basePath)
+
+    val partitions: java.util.List[String] =
+      if (partition.nonEmpty) {
+        java.util.Collections.singletonList(partition)
+      } else {
+        // Use FileSystemBackedTableMetadata (filesystem listing, no MDT) for 
partition discovery.
+        // This avoids any reliance on the metadata table being 
present/consistent — the same
+        // approach HoodieRepairTool uses.
+        new FileSystemBackedTableMetadata(
+          new HoodieLocalEngineContext(metaClient.getStorageConf),
+          metaClient.getTableConfig, metaClient.getStorage, 
basePath).getAllPartitionPaths
+      }
+
+    if (partitions.isEmpty) {
+      Seq.empty
+    } else {
+      doRepairOrphanFiles(basePath, metaClient, partitions, dryRun, backupPath,
+        archivedStartTs, archivedEndTs, maxOrphans)
+    }
+  }
+
+  private def doRepairOrphanFiles(
+      basePath: String,
+      metaClient: HoodieTableMetaClient,
+      partitions: java.util.List[String],
+      dryRun: Boolean,
+      backupPath: String,
+      archivedStartTs: String,
+      archivedEndTs: String,
+      maxOrphans: Int): Seq[Row] = {
+    // Build the active and archived timelines once on the driver, loading 
completed-instant
+    // details into memory so executors can read commit metadata without 
further I/O. This is
+    // the same pattern as HoodieRepairTool: the loaded timelines are 
serializable and captured
+    // by the RDD closure below. The HoodieTableMetaClient itself is not 
captured (not needed
+    // on executors once details are loaded).
+    val activeTimeline = metaClient.getActiveTimeline
+    val archivedTimeline =
+      if (archivedStartTs.nonEmpty) 
metaClient.getArchivedTimeline(archivedStartTs)
+      else metaClient.getArchivedTimeline()
+    archivedTimeline.loadCompletedInstantDetailsInMemory()
+
+    // StorageConfiguration is Serializable and is the only stateful value 
captured into the
+    // closure; storage handles are rebuilt per task from it.
+    val storageConf = metaClient.getStorageConf
+    val basePathStr = basePath
+    val archStartTs = archivedStartTs
+    val archEndTs   = archivedEndTs
+
+    // Phase 2: Parallel orphan file detection (Spark RDD, one task per 
partition). Each task
+    // lists its own partition and runs detection locally, so only the (small) 
set of orphan
+    // candidates is collected back to the driver rather than the full file 
listing.
+    val orphanRelPaths: List[String] = jsc.parallelize(partitions, 
partitions.size())
+      .rdd
+      .flatMap { partitionStr =>
+        val storage     = HoodieStorageUtils.getStorage(basePathStr, 
storageConf)
+        val partPath    = FSUtils.getAbsolutePartitionPath(new 
StoragePath(basePathStr), partitionStr)
+        // getAllDataFilesInPartition handles FileNotFoundException (partition 
deleted between
+        // listing and task execution) by returning an empty list rather than 
throwing.
+        val allStatuses = FSUtils.getAllDataFilesInPartition(storage, partPath)
+        val allPaths    = allStatuses.asScala.map((info: StoragePathInfo) => 
info.getPath).asJava
+
+        val instantToFilesMap = 
RepairUtils.tagInstantsOfBaseAndLogFiles(basePathStr, allPaths)
+
+        if (instantToFilesMap.isEmpty) {
+          Iterator.empty
+        } else {
+          // Optionally scope detection to instants within [archived_start_ts, 
archived_end_ts].
+          // Instants outside the range are left untouched (not reported as 
orphans).
+          val instants = instantToFilesMap.keySet.asScala.toSeq.sorted.filter 
{ instant =>
+            (archStartTs.isEmpty || instant >= archStartTs) &&
+              (archEndTs.isEmpty || instant <= archEndTs)
+          }
+
+          instants.flatMap { instant =>
+            RepairUtils.findInstantFilesToRemove(
+              instant,
+              instantToFilesMap.get(instant),
+              activeTimeline,
+              archivedTimeline
+            ).asScala
+          }.iterator
+        }
+      }
+      .collect()
+      .toList
+
+    if (orphanRelPaths.size > maxOrphans) {
+      throw new IllegalStateException(
+        s"Found ${orphanRelPaths.size} orphan candidates, which exceeds 
max_orphans=$maxOrphans. " +
+        s"Re-run with partition => '<partition>' to scope to one partition at 
a time, " +
+        s"or raise max_orphans if you are sure the driver has enough memory.")
+    }
+
+    // Phase 3: Metadata table (MDT) safety check (driver).
+    // Files still visible in the MDT are not true orphans — surface them as
+    // SKIPPED_PRESENT_IN_MDT (per-file exclusion) so the operator sees which 
candidates the
+    // safety check held back, rather than silently dropping them.
+    val mdtConfig = HoodieMetadataConfig.newBuilder
+      .enable(true).ignoreSpuriousDeletes(true).build
+    val mdtReader = new HoodieBackedTableMetadata(
+      new HoodieLocalEngineContext(metaClient.getStorageConf), 
metaClient.getStorage, mdtConfig, basePath)
+
+    val mdtUnsafePaths: Set[String] =
+      if (mdtReader.enabled) {
+        val byPartition = orphanRelPaths.groupBy(partitionOf)
+        val unsafe = byPartition.flatMap { case (partRel, paths) =>
+          val mdtPartPath = FSUtils.getAbsolutePartitionPath(new 
StoragePath(basePath), partRel)
+          val mdtNames = mdtReader.getAllFilesInPartition(mdtPartPath).asScala
+            .map(_.getPath.getName).toSet
+          paths.filter(p => mdtNames.contains(new StoragePath(p).getName))
+        }.toSet
+        if (unsafe.nonEmpty) {
+          logWarning(s"Found ${unsafe.size} orphan candidate(s) still visible 
in MDT — " +
+            s"emitting as SKIPPED_PRESENT_IN_MDT (per-file exclusion): 
$unsafe")
+        }
+        unsafe
+      } else {
+        logWarning("Metadata table not enabled; skipping MDT safety 
cross-check")
+        Set.empty[String]
+      }
+
+    val safeOrphanPaths = orphanRelPaths.filterNot(mdtUnsafePaths.contains)
+
+    // Phase 4: Build result rows.
+    val skippedRows: Seq[Row] = mdtUnsafePaths.toSeq.map { relPath =>
+      val fileName    = new StoragePath(relPath).getName
+      val partRel     = partitionOf(relPath)
+      val instantTime = FSUtils.getCommitTime(fileName)
+      Row(partRel, fileName, instantTime, "", "SKIPPED_PRESENT_IN_MDT")
+    }
+
+    val safeRows: Seq[Row] = if (dryRun) {
+      safeOrphanPaths.map { relPath =>
+        val fileName    = new StoragePath(relPath).getName
+        val partRel     = partitionOf(relPath)
+        val instantTime = FSUtils.getCommitTime(fileName)
+        Row(partRel, fileName, instantTime, "", "IDENTIFIED")
+      }
+    } else {
+      val storage = metaClient.getStorage
+      val tableNm = metaClient.getTableConfig.getTableName  // always from 
metaClient — the 'table'
+                                                            // proc arg is 
null when invoked via path =>
+      safeOrphanPaths.map { relPath =>
+        val fileName    = new StoragePath(relPath).getName
+        val partRel     = partitionOf(relPath)
+        val instantTime = FSUtils.getCommitTime(fileName)
+        val srcPath     = new StoragePath(basePath, relPath)
+        // Non-partitioned tables have partRel="" — back up directly under 
<backup>/<table>.
+        val destDir     =
+          if (partRel.isEmpty) new StoragePath(s"$backupPath/$tableNm")
+          else new StoragePath(s"$backupPath/$tableNm/$partRel")
+        val destPath    = new StoragePath(destDir, fileName)
+
+        // Each storage op is wrapped to capture exception class+message — a 
backup can fail for
+        // permissions, missing parent, RPC, or concurrent-delete reasons, and 
a status of
+        // BACKUP_FAILED with no log line leaves the operator with nothing to 
diagnose. Causes
+        // are accumulated and logged once at the end iff the final outcome is 
a failure.
+        val causes = scala.collection.mutable.ArrayBuffer.empty[String]
+
+        val dirCreated: Boolean =
+          try {
+            val ok = storage.createDirectory(destDir)
+            if (!ok) causes += s"createDirectory($destDir)=false (likely 
permissions or destDir exists as a file)"
+            ok
+          } catch {
+            case t: Throwable =>
+              causes += s"createDirectory($destDir) threw 
${t.getClass.getSimpleName}: ${t.getMessage}"
+              false
+          }
+
+        val moved: Boolean =
+          if (!dirCreated) {
+            false
+          } else {
+            try {
+              val ok = storage.rename(srcPath, destPath)
+              if (!ok) causes += s"rename returned false (srcPath missing, 
destPath exists, or cross-volume rename)"
+              ok
+            } catch {
+              case t: Throwable =>
+                causes += s"rename threw ${t.getClass.getSimpleName}: 
${t.getMessage}"
+                false
+            }
+          }
+
+        // A concurrent cleaner may have already deleted srcPath — that is 
success, not failure.
+        val srcStillExists: Boolean =
+          try {
+            storage.exists(srcPath)
+          } catch {
+            case t: Throwable =>
+              // Cannot confirm the concurrent-delete recovery path; treat as 
failure.
+              causes += s"exists($srcPath) threw ${t.getClass.getSimpleName}: 
${t.getMessage}"
+              true
+          }
+        val succeeded = moved || !srcStillExists
+
+        if (!succeeded) {
+          logWarning(s"BACKUP_FAILED for $srcPath -> $destPath; causes: 
${causes.mkString("; ")}")
+        }
+        val status    = if (succeeded) "BACKED_UP" else "BACKUP_FAILED"
+        val backupOut = if (succeeded) destPath.toString else ""
+        Row(partRel, fileName, instantTime, backupOut, status)
+      }
+    }
+
+    safeRows ++ skippedRows
+  }
+
+  // Extract the relative partition path from a relative file path using 
string ops. This avoids
+  // any path-normalization surprises and works uniformly for partitioned and 
non-partitioned
+  // tables (root files yield "").
+  private def partitionOf(relPath: String): String = {
+    val s = relPath.lastIndexOf('/')
+    if (s < 0) "" else relPath.substring(0, s)
+  }
+
+  override def build: Procedure = new RepairOrphanFilesProcedure()
+}
+
+object RepairOrphanFilesProcedure {
+  val NAME = "repair_orphan_files"
+
+  def builder: Supplier[ProcedureBuilder] = new Supplier[ProcedureBuilder] {
+    override def get(): ProcedureBuilder = new RepairOrphanFilesProcedure()
+  }
+}
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestRepairOrphanFilesProcedure.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestRepairOrphanFilesProcedure.scala
new file mode 100644
index 000000000000..b77c3bd11e43
--- /dev/null
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestRepairOrphanFilesProcedure.scala
@@ -0,0 +1,256 @@
+/*
+ * 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.spark.sql.hudi.procedure
+
+import org.apache.hudi.common.table.HoodieTableMetaClient
+import org.apache.hudi.common.testutils.FileCreateUtils
+import org.apache.hudi.hadoop.fs.HadoopFSUtils
+
+import org.apache.hadoop.fs.Path
+
+import java.util.UUID
+
+class TestRepairOrphanFilesProcedure extends HoodieSparkProcedureTestBase {
+
+  private val ORPHAN_INSTANT = "20000101000000000"  // Year 2000; never in any 
test timeline
+
+  private def metaClientFor(tablePath: String): HoodieTableMetaClient = {
+    HoodieTableMetaClient.builder
+      
.setConf(HadoopFSUtils.getStorageConfWithCopy(spark.sparkContext.hadoopConfiguration))
+      .setBasePath(tablePath)
+      .build
+  }
+
+  // Test 1 — dry run detects a base-file orphan in a non-partitioned COW table
+  test("Test Call repair_orphan_files dry run finds base file orphan") {
+    withTempDir { tmp =>
+      val tableName = generateTableName
+      val tablePath = s"${tmp.getCanonicalPath}/$tableName"
+
+      // Create a non-partitioned COW table and write one real commit
+      spark.sql(
+        s"""create table $tableName (id int, name string, price double, ts 
long)
+           |using hudi
+           |location '$tablePath'
+           |tblproperties (primaryKey = 'id', preCombineField = 'ts')
+           |""".stripMargin)
+      spark.sql(s"insert into $tableName select 1, 'a1', 10, 1000")
+
+      // Inject orphan base file directly onto the filesystem with a stale 
instant timestamp
+      val orphanFileId = UUID.randomUUID().toString
+      FileCreateUtils.createBaseFile(metaClientFor(tablePath), "", 
ORPHAN_INSTANT, orphanFileId)
+
+      // dry_run=true (default): should return exactly 1 row for the orphan, 
touch nothing
+      val result = spark.sql(s"call repair_orphan_files(table => 
'$tableName')").collect()
+      assertResult(1)(result.length)
+
+      val row = result(0)
+      assertResult("")(row.getString(0))             // partition (root = "" 
for non-partitioned)
+      assert(row.getString(1).contains(ORPHAN_INSTANT), s"file_name should 
contain orphan instant: ${row.getString(1)}")
+      assertResult(ORPHAN_INSTANT)(row.getString(2)) // instant_time
+      assertResult("")(row.getString(3))             // backup_path is empty 
in dry run
+      assertResult("IDENTIFIED")(row.getString(4))   // status
+    }
+  }
+
+  // Test 2 — cleanup mode backs up the orphan file and removes it from the 
table path
+  test("Test Call repair_orphan_files cleanup backs up base file orphan") {
+    withTempDir { tmp =>
+      val tableName  = generateTableName
+      val tablePath  = s"${tmp.getCanonicalPath}/$tableName"
+      val backupDir  = s"${tmp.getCanonicalPath}/backup"
+
+      spark.sql(
+        s"""create table $tableName (id int, name string, price double, ts 
long)
+           |using hudi
+           |location '$tablePath'
+           |tblproperties (primaryKey = 'id', preCombineField = 'ts')
+           |""".stripMargin)
+      spark.sql(s"insert into $tableName select 1, 'a1', 10, 1000")
+
+      val orphanFileId   = UUID.randomUUID().toString
+      // FileCreateUtils.createBaseFile returns the absolute path of the 
created file
+      val orphanAbsPath  = 
FileCreateUtils.createBaseFile(metaClientFor(tablePath), "", ORPHAN_INSTANT, 
orphanFileId)
+      val orphanFilePath = new Path(orphanAbsPath)
+
+      val hadoopConf = spark.sparkContext.hadoopConfiguration
+      val fs = HadoopFSUtils.getFs(tablePath, hadoopConf)
+      assert(fs.exists(orphanFilePath), "Orphan file should exist before 
cleanup")
+
+      // dry_run=false: orphan should be moved to backup
+      val result = spark.sql(
+        s"""call repair_orphan_files(
+           |  table => '$tableName',
+           |  dry_run => false,
+           |  backup_path => '$backupDir'
+           |)""".stripMargin).collect()
+
+      assertResult(1)(result.length)
+      assertResult("BACKED_UP")(result(0).getString(4))
+
+      // Orphan file must be gone from the table path
+      assert(!fs.exists(orphanFilePath), "Orphan file should no longer exist 
at original path")
+
+      // Orphan file must exist at the backup path
+      val backedUpPath = new Path(result(0).getString(3))
+      assert(fs.exists(backedUpPath), "Orphan file should exist at backup 
path")
+
+      // Real data must still be readable
+      assertResult(1)(spark.sql(s"select id from $tableName").collect().length)
+    }
+  }
+
+  // Test 3 — inflight commit files are skipped (RepairUtils skips 
non-completed instants)
+  test("Test Call repair_orphan_files skips inflight commit files") {
+    withTempDir { tmp =>
+      val tableName    = generateTableName
+      val tablePath    = s"${tmp.getCanonicalPath}/$tableName"
+      val inflightTs   = "20010101000000000"
+
+      spark.sql(
+        s"""create table $tableName (id int, name string, price double, ts 
long)
+           |using hudi
+           |location '$tablePath'
+           |tblproperties (primaryKey = 'id', preCombineField = 'ts')
+           |""".stripMargin)
+      spark.sql(s"insert into $tableName select 1, 'a1', 10, 1000")
+
+      val metaClient = metaClientFor(tablePath)
+      // Create the inflight marker in .hoodie so the active timeline sees it 
as non-completed
+      FileCreateUtils.createInflightCommit(metaClient, inflightTs)
+
+      // Place a data file with the inflight instant timestamp on disk
+      FileCreateUtils.createBaseFile(metaClient, "", inflightTs, 
UUID.randomUUID().toString)
+
+      // Procedure must return 0 rows — inflight instant is excluded by 
RepairUtils
+      val result = spark.sql(s"call repair_orphan_files(table => 
'$tableName')").collect()
+      assertResult(0)(result.length)
+    }
+  }
+
+  // Test 4 — backup_path validation: error when dry_run=false and no 
backup_path given
+  test("Test Call repair_orphan_files requires backup_path when not dry run") {
+    withTempDir { tmp =>
+      val tableName = generateTableName
+      val tablePath = s"${tmp.getCanonicalPath}/$tableName"
+
+      spark.sql(
+        s"""create table $tableName (id int, name string, price double, ts 
long)
+           |using hudi
+           |location '$tablePath'
+           |tblproperties (primaryKey = 'id', preCombineField = 'ts')
+           |""".stripMargin)
+
+      checkExceptionContain(
+        s"call repair_orphan_files(table => '$tableName', dry_run => false)"
+      )("backup_path is required")
+    }
+  }
+
+  // Test 5 — partition filter scopes the scan to a specific partition
+  test("Test Call repair_orphan_files partition filter scopes scan") {
+    withTempDir { tmp =>
+      val tableName = generateTableName
+      val tablePath = s"${tmp.getCanonicalPath}/$tableName"
+
+      spark.sql(
+        s"""create table $tableName (id int, name string, price double, ts 
long)
+           |using hudi
+           |location '$tablePath'
+           |tblproperties (primaryKey = 'id', preCombineField = 'ts')
+           |""".stripMargin)
+      spark.sql(s"insert into $tableName select 1, 'a1', 10, 1000")
+
+      // Inject orphan at table root (partition = "")
+      FileCreateUtils.createBaseFile(metaClientFor(tablePath), "", 
ORPHAN_INSTANT, UUID.randomUUID().toString)
+
+      // Filtering to a non-existent partition should find nothing
+      val filtered = spark.sql(
+        s"call repair_orphan_files(table => '$tableName', partition => 
'nonexistent')").collect()
+      assertResult(0)(filtered.length)
+
+      // Filtering to the root partition ("") should find the orphan
+      val root = spark.sql(
+        s"call repair_orphan_files(table => '$tableName', partition => 
'')").collect()
+      assertResult(1)(root.length)
+      assertResult("IDENTIFIED")(root(0).getString(4))
+    }
+  }
+
+  // Test 6 — log file orphan detected on a MOR table
+  test("Test Call repair_orphan_files detects log file orphan on MOR table") {
+    withTempDir { tmp =>
+      val tableName = generateTableName
+      val tablePath = s"${tmp.getCanonicalPath}/$tableName"
+
+      spark.sql(
+        s"""create table $tableName (id int, name string, price double, ts 
long)
+           |using hudi
+           |location '$tablePath'
+           |tblproperties (primaryKey = 'id', preCombineField = 'ts', type = 
'mor')
+           |""".stripMargin)
+      spark.sql(s"insert into $tableName select 1, 'a1', 10, 1000")
+
+      // Inject an orphan log file with a stale instant timestamp
+      val orphanFileId = UUID.randomUUID().toString
+      FileCreateUtils.createLogFile(metaClientFor(tablePath), "", 
ORPHAN_INSTANT, orphanFileId, 1)
+
+      val result = spark.sql(s"call repair_orphan_files(table => 
'$tableName')").collect()
+
+      // At least the orphan log file must appear in the result
+      val orphanRows = result.filter(_.getString(2) == ORPHAN_INSTANT)
+      assert(orphanRows.length >= 1,
+        s"Expected at least 1 orphan row with instant $ORPHAN_INSTANT, got: 
${result.mkString(", ")}")
+      assert(orphanRows.forall(_.getString(4) == "IDENTIFIED"))
+    }
+  }
+
+  // Note: the SKIPPED_PRESENT_IN_MDT branch (surfaces MDT-visible orphan 
candidates instead of
+  // silently dropping them) is verified by inspection rather than an 
end-to-end test.
+  // HoodieBackedTableMetadata.getAllFilesInPartition dynamically filters by 
the data table's
+  // timeline state — deleting a timeline file immediately removes the 
corresponding data file
+  // from the MDT's view as well. As a result, the (orphan ∧ in-MDT) state 
cannot be constructed
+  // by manipulating the timeline alone; only direct MDT writes could produce 
it, and the setup
+  // cost outweighs the value for what is a defense-in-depth path.
+
+  // Test 7 — max_orphans cap prevents driver OOM: error when detected count 
exceeds the cap
+  test("Test Call repair_orphan_files max_orphans cap triggers error when 
exceeded") {
+    withTempDir { tmp =>
+      val tableName = generateTableName
+      val tablePath = s"${tmp.getCanonicalPath}/$tableName"
+
+      spark.sql(
+        s"""create table $tableName (id int, name string, price double, ts 
long)
+           |using hudi
+           |location '$tablePath'
+           |tblproperties (primaryKey = 'id', preCombineField = 'ts')
+           |""".stripMargin)
+      spark.sql(s"insert into $tableName select 1, 'a1', 10, 1000")
+
+      // Inject 3 orphan files, then cap at 2 — should throw
+      val metaClient = metaClientFor(tablePath)
+      FileCreateUtils.createBaseFile(metaClient, "", ORPHAN_INSTANT, 
UUID.randomUUID().toString)
+      FileCreateUtils.createBaseFile(metaClient, "", ORPHAN_INSTANT, 
UUID.randomUUID().toString)
+      FileCreateUtils.createBaseFile(metaClient, "", ORPHAN_INSTANT, 
UUID.randomUUID().toString)
+
+      checkExceptionContain(
+        s"call repair_orphan_files(table => '$tableName', max_orphans => 2)"
+      )("exceeds max_orphans=2")
+    }
+  }
+}

Reply via email to